From 152152ccca5b075ac9764bdcc86b38415c756955 Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Fri, 14 Aug 2026 23:20:27 -0400 Subject: [PATCH] feat(lee): anchor privacy-circuit image ids and migrate all programs to Deploy Privacy-preserving circuit: - Add ProgramImageClaim, letting a Deploy-created program's real image_id be anchored to its account for privacy-circuit env::verify, with the sequencer independently re-deriving the real image_id via get_program to authenticate the claim (journal reconstruction, same pattern as public_actions pre-states). - Fix compute_public_authorized_pdas and the private circuit's resolve_authorization_and_record_bindings to use the caller's real recovered image_id instead of bijection-guessing it from the caller's account_id, which was wrong for Deploy-created callers. Program storage: - V03State::insert_program (used by both genesis's with_programs and live ProgramDeploymentTransaction execution) now always writes the Deploy two-account shape (ProgramData header + segment), not the legacy PROGRAM_STORAGE_OWNER raw-elf shape. The header stays at the existing bijection address so no dispatch-address reference needed to change; only the segment (never a caller-facing address) moves to its PDA. - Drop the now-redundant ProgramAlreadyExists pre-check in ProgramDeploymentTransaction validation; PDA claiming already prevents redeploying an account. - Migrate remaining tests off ProgramDeploymentTransaction onto native Deploy (sequencer_core, integration_tests' auth_transfer and block_size_limit), adding a shared deploy_targets/deploy_transaction/encoded_tx_size helper. Rebuild artifacts and the prebuilt test fixture for the circuit and program storage changes. --- Cargo.lock | 3 + ...n_hello_world_through_tail_call_private.rs | 7 +- integration_tests/Cargo.toml | 3 + integration_tests/src/utils.rs | 57 +++- .../tests/auth_transfer/private.rs | 46 ++- .../tests/auth_transfer/public.rs | 37 ++- integration_tests/tests/block_size_limit.rs | 103 +++--- integration_tests/tests/bridge.rs | 4 +- integration_tests/tests/private_pda.rs | 2 +- .../src/execution_state.rs | 313 ++++++------------ lee/privacy_preserving_circuit/src/main.rs | 6 +- lee/privacy_preserving_circuit/src/output.rs | 2 + lee/state_machine/core/src/circuit_io.rs | 29 +- lee/state_machine/core/src/lib.rs | 3 +- lee/state_machine/core/src/program/mod.rs | 78 +++-- lee/state_machine/core/src/program/tests.rs | 2 +- lee/state_machine/src/error.rs | 12 +- .../circuit/mod.rs | 54 ++- .../circuit/tests.rs | 12 +- .../privacy_preserving_transaction/message.rs | 11 + lee/state_machine/src/program/mod.rs | 3 +- lee/state_machine/src/program/tests.rs | 7 +- lee/state_machine/src/state/mod.rs | 92 ++++- lee/state_machine/src/state/tests/circuit.rs | 20 +- lee/state_machine/src/state/tests/claiming.rs | 2 +- .../src/state/tests/privacy_preserving.rs | 2 +- .../src/validated_state_diff/mod.rs | 103 ++++-- .../src/validated_state_diff/tests.rs | 5 +- lez/common/src/transaction.rs | 2 +- .../src/components/transaction_details.rs | 8 + lez/indexer/ffi/indexer_ffi.h | 21 ++ lez/indexer/ffi/src/api/types/transaction.rs | 45 ++- lez/indexer/ffi/src/api/types/vectors.rs | 7 +- lez/indexer/service/protocol/src/convert.rs | 25 +- lez/indexer/service/protocol/src/lib.rs | 7 + lez/indexer/service/src/mock_service.rs | 1 + lez/programs/program_loader/core/src/lib.rs | 164 ++++++--- lez/sequencer/core/src/tests.rs | 138 +++++--- lez/wallet-ffi/src/generic_transaction.rs | 7 +- lez/wallet/src/program_facades/ata.rs | 2 +- lez/wallet/src/program_facades/vault.rs | 2 +- tools/cycle_bench/src/ppe/ppe_impl.rs | 2 +- 42 files changed, 954 insertions(+), 495 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c0a9cec0..e29d74a13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4770,6 +4770,7 @@ dependencies = [ "anyhow", "async-trait", "authenticated_transfer_core", + "borsh", "bridge_core", "bridge_lock_core", "bytesize", @@ -4793,7 +4794,9 @@ dependencies = [ "logos-blockchain-zone-sdk", "num-bigint 0.4.6", "ping_core", + "program_loader_core", "programs", + "risc0-binfmt", "risc0-zkvm", "sequencer_core", "sequencer_service", diff --git a/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs b/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs index c1282dae6..8229532a1 100644 --- a/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs +++ b/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs @@ -1,8 +1,7 @@ use std::collections::HashMap; use lee::{ - AccountId, ProgramId, privacy_preserving_transaction::circuit::ProgramWithDependencies, - program::Program, + AccountId, privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program, }; use wallet::{AccountIdentity, WalletCore}; @@ -47,8 +46,8 @@ async fn main() { let simple_tail_call = Program::new(simple_tail_call_bytecode.into()).unwrap(); let hello_world_bytecode: Vec = std::fs::read(hello_world_path).unwrap(); let hello_world = Program::new(hello_world_bytecode.into()).unwrap(); - let dependencies: HashMap = - std::iter::once((hello_world.id(), hello_world)).collect(); + let dependencies: HashMap = + std::iter::once((hello_world.id().into(), hello_world)).collect(); let program_with_dependencies = ProgramWithDependencies::new(simple_tail_call, dependencies); let accounts = vec![AccountIdentity::PrivateOwned(account_id)]; diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 56aab0f18..207747692 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -40,6 +40,9 @@ sequencer_stake_core.workspace = true programs.workspace = true test_programs.workspace = true testnet_initial_state.workspace = true +program_loader_core.workspace = true +risc0-binfmt.workspace = true +borsh.workspace = true logos-blockchain-core.workspace = true logos-blockchain-key-management-system-service.workspace = true diff --git a/integration_tests/src/utils.rs b/integration_tests/src/utils.rs index a99059860..b6cf03302 100644 --- a/integration_tests/src/utils.rs +++ b/integration_tests/src/utils.rs @@ -2,7 +2,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, ensure}; use key_protocol::key_management::key_tree::chain_index::ChainIndex; -use lee_core::account::AccountId; +use lee_core::{account::AccountId, program::RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID}; use log::info; use sequencer_core::{ block_publisher::{Ed25519PublicKey, read_channel_state}, @@ -321,3 +321,58 @@ pub async fn wait_for_indexer_to_catch_up(ctx: &TestContext) -> anyhow::Result (AccountId, AccountId) { + let loader_id: lee_core::program::ProgramId = RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID.into(); + let image_id: lee_core::program::ProgramId = + risc0_binfmt::compute_image_id(bytecode).unwrap().into(); + let header = + program_loader_core::deploy_header_account_id(loader_id, image_id, 0, AccountId::default()); + let segment = + program_loader_core::deploy_segment_account_id(loader_id, image_id, 0, AccountId::default()); + (header, segment) +} + +/// Builds the `PublicTransaction` that deploys `bytecode` to `(header, segment)`. +/// +/// `(header, segment)` are the targets [`deploy_targets`] derives for it. Tests should invoke +/// programs at the returned `header` address afterward, not the program's own bijection +/// `AccountId::from(image_id)`. +#[must_use] +pub fn deploy_transaction( + header: AccountId, + segment: AccountId, + bytecode: Vec, +) -> lee::PublicTransaction { + let loader_id: lee_core::program::ProgramId = RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID.into(); + let message = lee::public_transaction::Message::try_new( + loader_id.into(), + vec![header, segment], + vec![], + program_loader_core::Instruction::Deploy { bytecode }, + ) + .expect("deploy instruction data should always be serializable"); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[]); + lee::PublicTransaction::new(message, witness_set) +} + +/// The exact wire size the sequencer measures a transaction by (see +/// `sequencer_rpc_server_actor::actor::service`'s `send_transaction`). +/// +/// A `Deploy`'s bytecode is transported through `instruction_data` (`Vec`), and RISC0's +/// word-oriented serde doesn't pack `Vec` efficiently: each byte becomes its own 4-byte word, +/// so a `Deploy` transaction's wire size runs ~4x its raw bytecode length. Measuring the real +/// encoded size here (rather than guessing from bytecode length) keeps size-sensitive tests +/// correct regardless of that encoding overhead. +#[must_use] +pub fn encoded_tx_size(tx: &common::transaction::LeeTransaction) -> u64 { + u64::try_from( + borsh::to_vec(tx) + .expect("transaction should serialize") + .len(), + ) + .expect("transaction size should fit in u64") +} diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index ca7ced2ab..31223ab66 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -1,13 +1,14 @@ use std::time::Duration; use anyhow::{Context as _, Result}; +use bytesize::ByteSize; use common::transaction::LeeTransaction; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention, public_mention, utils::{ - account_balance, assert_private_commitment_in_state, get_account, new_account, send, - sync_private, + account_balance, assert_private_commitment_in_state, deploy_targets, deploy_transaction, + encoded_tx_size, get_account, new_account, send, sync_private, }, verify_commitment_is_in_state, }; @@ -22,6 +23,10 @@ use lee_core::{ encryption::ViewingPublicKey, }; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, + config::{MultiNodeTestContextConfig, SequencerPartialConfig}, +}; use tokio::test; use wallet::{ account::Label, @@ -579,12 +584,29 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { #[test] async fn ppt_cant_chain_call_faucet() -> Result<()> { - let ctx = TestContext::new().await?; - let faucet_chain_caller = test_programs::faucet_chain_caller(); - let deploy_tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(faucet_chain_caller.elf().to_owned()), + let bytecode = faucet_chain_caller.elf().to_vec(); + let (faucet_chain_caller_header, faucet_chain_caller_segment) = deploy_targets(&bytecode); + let deploy_tx = LeeTransaction::Public(deploy_transaction( + faucet_chain_caller_header, + faucet_chain_caller_segment, + bytecode, )); + + // `Deploy`'s bytecode payload runs ~4x its raw size on the wire (see `encoded_tx_size`'s + // docs), so the default 1 MiB block size isn't enough headroom for a real guest binary. + let tx_size = encoded_tx_size(&deploy_tx); + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(SequencerPartialConfig { + max_block_size: ByteSize::b(tx_size + 10 * 1024), + ..SequencerPartialConfig::default() + }), + ) + .build() + .await?; + ctx.sequencer_client().send_transaction(deploy_tx).await?; log::info!("Waiting for deploy block creation"); @@ -619,12 +641,16 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { let program_with_deps = ProgramWithDependencies::new( faucet_chain_caller, [ - (faucet_program_id, programs::faucet()), - (vault_program_id, programs::vault()), - (auth_transfer_program_id, programs::authenticated_transfer()), + (faucet_program_id.into(), programs::faucet()), + (vault_program_id.into(), programs::vault()), + ( + auth_transfer_program_id.into(), + programs::authenticated_transfer(), + ), ] .into(), - ); + ) + .with_program_account_id(faucet_chain_caller_header); let instruction = Program::serialize_instruction((faucet_program_id, vault_program_id, attacker_id, amount))?; diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index 2a99e8bd7..2077bc031 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -1,13 +1,21 @@ use std::time::Duration; use anyhow::{Context as _, Result}; +use bytesize::ByteSize; use common::transaction::LeeTransaction; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention, - utils::{account_balance, get_account, new_account, send, send_claiming_new_account}, + utils::{ + account_balance, deploy_targets, deploy_transaction, encoded_tx_size, get_account, + new_account, send, send_claiming_new_account, + }, }; use lee::{PublicKey, public_transaction}; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, + config::{MultiNodeTestContextConfig, SequencerPartialConfig}, +}; use tokio::test; use wallet::{ account::Label, @@ -387,12 +395,29 @@ async fn cannot_execute_faucet_program() -> Result<()> { #[test] async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { - let ctx = TestContext::new().await?; - let faucet_chain_caller = test_programs::faucet_chain_caller(); - let deploy_tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(faucet_chain_caller.elf().to_owned()), + let bytecode = faucet_chain_caller.elf().to_vec(); + let (faucet_chain_caller_header, faucet_chain_caller_segment) = deploy_targets(&bytecode); + let deploy_tx = LeeTransaction::Public(deploy_transaction( + faucet_chain_caller_header, + faucet_chain_caller_segment, + bytecode, )); + + // `Deploy`'s bytecode payload runs ~4x its raw size on the wire (see `encoded_tx_size`'s + // docs), so the default 1 MiB block size isn't enough headroom for a real guest binary. + let tx_size = encoded_tx_size(&deploy_tx); + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(SequencerPartialConfig { + max_block_size: ByteSize::b(tx_size + 10 * 1024), + ..SequencerPartialConfig::default() + }), + ) + .build() + .await?; + ctx.sequencer_client().send_transaction(deploy_tx).await?; log::info!("Waiting for deploy block creation"); @@ -406,7 +431,7 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { let amount: u128 = 1; let message = public_transaction::Message::try_new( - faucet_chain_caller.id().into(), + faucet_chain_caller_header, vec![faucet_account_id, attacker_vault_id], vec![], (faucet_program_id, vault_program_id, attacker, amount), diff --git a/integration_tests/tests/block_size_limit.rs b/integration_tests/tests/block_size_limit.rs index b8fab7daa..dbcbb1312 100644 --- a/integration_tests/tests/block_size_limit.rs +++ b/integration_tests/tests/block_size_limit.rs @@ -1,5 +1,4 @@ #![expect( - clippy::as_conversions, clippy::tests_outside_test_module, reason = "We don't care about these in tests" )] @@ -9,8 +8,12 @@ use std::time::Duration; use anyhow::Result; use bytesize::ByteSize; use common::transaction::LeeTransaction; -use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, config::SequencerPartialConfig}; +use integration_tests::{ + TIME_TO_WAIT_FOR_BLOCK_SECONDS, config::SequencerPartialConfig, deploy_targets, + deploy_transaction, encoded_tx_size, +}; use lee::program::Program; +use lee_core::program::RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID; use sequencer_service_rpc::RpcClient as _; use test_fixtures::{ MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, @@ -19,12 +22,20 @@ use tokio::test; #[test] async fn reject_oversized_transaction() -> Result<()> { + let bytecode = test_programs::claimer().elf().to_vec(); + let (header, segment) = deploy_targets(&bytecode); + let tx = LeeTransaction::Public(deploy_transaction(header, segment, bytecode)); + let tx_size = encoded_tx_size(&tx); + let ctx = MultiZoneTestContextBuilder::default() .with_zone( ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) .with_sequencer_partial_config(SequencerPartialConfig { max_num_tx_in_block: 100, - max_block_size: ByteSize::mib(1), + // Below the transaction's actual size, so it's rejected outright (the + // sequencer additionally reserves ~200 bytes of block-header overhead off of + // this limit, so being equal to `tx_size` is already enough of a margin). + max_block_size: ByteSize::b(tx_size), mempool_max_size: 1000, block_create_timeout: Duration::from_secs(10), priority_fee: sequencer_core::config::default_priority_fee(), @@ -33,19 +44,8 @@ async fn reject_oversized_transaction() -> Result<()> { .build() .await?; - // Create a transaction that's definitely too large - // Block size is 1 MiB (1,048,576 bytes), minus ~200 bytes for header = ~1,048,376 bytes max tx - // Create a 1.1 MiB binary to ensure it exceeds the limit - let oversized_binary = vec![0_u8; 1100 * 1024]; // 1.1 MiB binary - - let message = lee::program_deployment_transaction::Message::new(oversized_binary); - let tx = lee::ProgramDeploymentTransaction::new(message); - // Try to submit the transaction and expect an error - let result = ctx - .sequencer_client() - .send_transaction(LeeTransaction::ProgramDeployment(tx)) - .await; + let result = ctx.sequencer_client().send_transaction(tx).await; assert!( result.is_err(), @@ -66,12 +66,18 @@ async fn reject_oversized_transaction() -> Result<()> { #[test] async fn accept_transaction_within_limit() -> Result<()> { + let bytecode = test_programs::claimer().elf().to_vec(); + let (header, segment) = deploy_targets(&bytecode); + let tx = LeeTransaction::Public(deploy_transaction(header, segment, bytecode)); + let tx_size = encoded_tx_size(&tx); + let ctx = MultiZoneTestContextBuilder::default() .with_zone( ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) .with_sequencer_partial_config(SequencerPartialConfig { max_num_tx_in_block: 100, - max_block_size: ByteSize::mib(1), + // Comfortably above the transaction's actual size. + max_block_size: ByteSize::b(tx_size + 10 * 1024), mempool_max_size: 1000, block_create_timeout: Duration::from_secs(10), priority_fee: sequencer_core::config::default_priority_fee(), @@ -80,17 +86,8 @@ async fn accept_transaction_within_limit() -> Result<()> { .build() .await?; - // Create a small program deployment that should fit - let small_binary = vec![0_u8; 1024]; // 1 KiB binary - - let message = lee::program_deployment_transaction::Message::new(small_binary); - let tx = lee::ProgramDeploymentTransaction::new(message); - // This should succeed - let result = ctx - .sequencer_client() - .send_transaction(LeeTransaction::ProgramDeployment(tx)) - .await; + let result = ctx.sequencer_client().send_transaction(tx).await; assert!( result.is_ok(), @@ -106,10 +103,24 @@ async fn transaction_deferred_to_next_block_when_current_full() -> Result<()> { let claimer = test_programs::claimer(); let chain_caller = test_programs::chain_caller(); - // Calculate block size to fit only one of the two transactions, leaving some room for headers + let (claimer_header, claimer_segment) = deploy_targets(claimer.elf()); + let claimer_tx = LeeTransaction::Public(deploy_transaction( + claimer_header, + claimer_segment, + claimer.elf().to_vec(), + )); + + let (chain_caller_header, chain_caller_segment) = deploy_targets(chain_caller.elf()); + let chain_caller_tx = LeeTransaction::Public(deploy_transaction( + chain_caller_header, + chain_caller_segment, + chain_caller.elf().to_vec(), + )); + + // Block size to fit only one of the two transactions, leaving some room for headers // (e.g., 10 KiB) - let max_program_size = claimer.elf().len().max(chain_caller.elf().len()); - let block_size = ByteSize::b((max_program_size + 10 * 1024) as u64); + let max_tx_size = encoded_tx_size(&claimer_tx).max(encoded_tx_size(&chain_caller_tx)); + let block_size = ByteSize::b(max_tx_size + 10 * 1024); let ctx = MultiZoneTestContextBuilder::default() .with_zone( @@ -128,20 +139,9 @@ async fn transaction_deferred_to_next_block_when_current_full() -> Result<()> { let initial_block_height = ctx.sequencer_client().get_last_block_id().await?; // Submit both program deployments + ctx.sequencer_client().send_transaction(claimer_tx).await?; ctx.sequencer_client() - .send_transaction(LeeTransaction::ProgramDeployment( - lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(claimer.elf().to_owned()), - ), - )) - .await?; - - ctx.sequencer_client() - .send_transaction(LeeTransaction::ProgramDeployment( - lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(chain_caller.elf().to_owned()), - ), - )) + .send_transaction(chain_caller_tx) .await?; // Wait for first block @@ -153,19 +153,26 @@ async fn transaction_deferred_to_next_block_when_current_full() -> Result<()> { .await? .unwrap(); - // Check which program is in block 1 + // Check which program is deployed in a block, by picking out its `Deploy` transactions and + // decoding the real `image_id` of each one's bytecode. let get_program_ids = |block: &common::block::Block| -> Vec { block .body .transactions .iter() .filter_map(|tx| { - if let LeeTransaction::ProgramDeployment(deployment) = tx { - let bytecode = deployment.message.clone().into_bytecode(); - Program::new(bytecode.into()).ok().map(|p| p.id()) - } else { - None + let LeeTransaction::Public(public_tx) = tx else { + return None; + }; + if public_tx.message.program_account_id != RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID { + return None; } + let loader_core::Instruction::Deploy { bytecode } = + risc0_zkvm::serde::from_slice::( + &public_tx.message.instruction_data, + ) + .ok()?; + Program::new(bytecode.into()).ok().map(|p| p.id()) }) .collect() }; diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 8f73cfacc..a359739ce 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -159,9 +159,9 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { lee::privacy_preserving_transaction::circuit::ProgramWithDependencies::new( programs::bridge(), [ - (vault_program_id, programs::vault()), + (vault_program_id.into(), programs::vault()), ( - programs::authenticated_transfer().id(), + programs::authenticated_transfer().id().into(), programs::authenticated_transfer(), ), ] diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs index a78c4f085..9abe8f2a5 100644 --- a/integration_tests/tests/private_pda.rs +++ b/integration_tests/tests/private_pda.rs @@ -168,7 +168,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { let auth_transfer_program = ProgramWithDependencies::new(auth_transfer.clone(), [].into()); let spend_program = - ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into()); + ProgramWithDependencies::new(proxy, [(auth_transfer_id.into(), auth_transfer)].into()); let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, &alice_vpk, 0); let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, &alice_vpk, 1); diff --git a/lee/privacy_preserving_circuit/src/execution_state.rs b/lee/privacy_preserving_circuit/src/execution_state.rs index 9347d6522..1c8048c85 100644 --- a/lee/privacy_preserving_circuit/src/execution_state.rs +++ b/lee/privacy_preserving_circuit/src/execution_state.rs @@ -4,13 +4,14 @@ use std::{ }; use lee_core::{ - Identifier, InputAccountIdentity, NullifierPublicKey, PrivateWitness, WitnessKind, + Identifier, InputAccountIdentity, NullifierPublicKey, PrivateWitness, ProgramImageClaim, + WitnessKind, account::{Account, AccountId, AccountWithMetadata}, encryption::ViewingPublicKey, program::{ - AccountPostState, BlockValidityWindow, CallerData, ChainedCall, Claim, - DEFAULT_PROGRAM_OWNER, MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, - TimestampValidityWindow, validate_execution, + AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, + MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow, + validate_execution, }, }; use risc0_zkvm::{guest::env, serde::to_vec}; @@ -51,9 +52,7 @@ pub struct ExecutionState { /// `AccountId::for_private_pda(program_id, seed, npk, vpk, identifier) == /// pre_state.account_id`. private_pda_by_position: HashMap, - /// The set containing non-PDA accounts authorized at their first sight, anywhere in the - /// call tree, remaining authorized throughout all calls. - globally_authorized: HashSet, + authorized_accounts: HashSet, } impl ExecutionState { @@ -62,6 +61,7 @@ impl ExecutionState { account_identities: &[InputAccountIdentity], program_id: ProgramId, program_outputs: Vec, + program_image_claims: &[ProgramImageClaim], ) -> Self { // Build position → (npk, identifier) map for private-PDA pre_states, indexed by position // in `account_identities`. The vec is documented as 1:1 with the program's pre_state @@ -114,7 +114,7 @@ impl ExecutionState { private_pda_bound_positions: HashMap::new(), pda_family_binding: HashMap::new(), private_pda_by_position, - globally_authorized: HashSet::new(), + authorized_accounts: HashSet::new(), }; let Some(first_output) = program_outputs.first() else { @@ -127,17 +127,14 @@ impl ExecutionState { pre_states: first_output.pre_states.clone(), pda_seeds: Vec::new(), }; - let initial_caller_data = CallerData { - caller_account_id: None, - authorized_accounts: HashSet::new(), - }; - let mut chained_calls = - VecDeque::<(ChainedCall, CallerData)>::from_iter([(initial_call, initial_caller_data)]); + let mut chained_calls = VecDeque::from_iter([(initial_call, None, None)]); let mut program_outputs_iter = program_outputs.into_iter(); let mut chain_calls_counter = 0; - while let Some((chained_call, caller_data)) = chained_calls.pop_front() { + while let Some((chained_call, caller_account_id, caller_image_id)) = + chained_calls.pop_front() + { assert!( chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS, "Max chained calls depth is exceeded" @@ -147,12 +144,18 @@ impl ExecutionState { panic!("Insufficient program outputs for chained calls"); }; - // Recover the real `ProgramId` (RISC0 image id) from the account's address: on this - // branch every program account lives at the direct `AccountId::from(program_id)` - // bijection, so this round-trip is exact. Needed wherever proof verification/PDA - // derivation requires the underlying image id rather than the dispatch-facing - // `AccountId`. - let current_program_id = ProgramId::from(chained_call.program_account_id); + // The real `image_id` for this dispatch address. A `Deploy`-created program's + // address doesn't encode its image id (unlike a legacy program's, where the + // `AccountId::from(program_id)` bijection is exact by construction), so its real + // image id has to come from a claim instead — see `ProgramImageClaim`'s doc comment + // for how that claim gets anchored to real chain state (by the sequencer, not here). + let current_program_id = program_image_claims + .iter() + .find(|claim| claim.account_id == chained_call.program_account_id) + .map_or_else( + || ProgramId::from(chained_call.program_account_id), + |claim| claim.image_id, + ); // Check that instruction data in chained call is the instruction data in program output assert_eq!( @@ -180,7 +183,7 @@ impl ExecutionState { // by spoofing caller_account_id (e.g. passing caller_account_id = self_account_id // to bypass access control checks). assert_eq!( - program_output.caller_account_id, caller_data.caller_account_id, + program_output.caller_account_id, caller_account_id, "Program output caller_account_id does not match actual caller" ); @@ -195,25 +198,22 @@ impl ExecutionState { panic!("Invalid program behavior in program {current_program_id:?}: {err}"); } - let authorized_accounts = execution_state.validate_and_sync_states( + for next_call in program_output.chained_calls.iter().rev() { + chained_calls.push_front(( + next_call.clone(), + Some(chained_call.program_account_id), + Some(current_program_id), + )); + } + + execution_state.validate_and_sync_states( account_identities, current_program_id, - caller_data, + caller_image_id, &chained_call.pda_seeds, program_output.pre_states, program_output.post_states, ); - - for next_call in program_output.chained_calls.into_iter().rev() { - // Push the call with newly-authorized account set. - chained_calls.push_front(( - next_call, - CallerData { - caller_account_id: Some(chained_call.program_account_id), - authorized_accounts: authorized_accounts.clone(), - }, - )); - } chain_calls_counter = chain_calls_counter.checked_add(1).expect( "Chain calls counter should not overflow as it checked before incrementing", ); @@ -264,20 +264,16 @@ impl ExecutionState { } /// Validate program pre and post states and populate the execution state. - /// - /// Return the set of authorized accounts as the result of the processed - /// call. fn validate_and_sync_states( &mut self, account_identities: &[InputAccountIdentity], program_id: ProgramId, - caller: CallerData, + caller_image_id: Option, caller_pda_seeds: &[PdaSeed], output_pre_states: Vec, output_post_states: Vec, - ) -> HashSet { - let mut authorized_output_accounts = Vec::new(); - for (mut pre, mut post) in output_pre_states.into_iter().zip(output_post_states) { + ) { + for (pre, mut post) in output_pre_states.into_iter().zip(output_post_states) { let pre_account_id = pre.account_id; let pre_is_authorized = pre.is_authorized; let post_states_entry = self.post_states.entry(pre.account_id); @@ -300,26 +296,35 @@ impl ExecutionState { "Inconsistent pre state for account {pre_account_id}", ); - let pre_state_position = self + let (previous_is_authorized, pre_state_position) = self .pre_states .iter() - .position(|acc| acc.account_id == pre_account_id) - .unwrap_or_else(|| { - panic!( - "Pre state must exist in execution state for account {pre_account_id}", - ) - }); + .enumerate() + .find(|(_, acc)| acc.account_id == pre_account_id) + .map_or_else( + || { + panic!( + "Pre state must exist in execution state for account {pre_account_id}", + ) + }, + |(pos, acc)| (acc.is_authorized, pos), + ); - assert_authorization_and_record_bindings( + let is_authorized = resolve_authorization_and_record_bindings( &mut self.pda_family_binding, &mut self.private_pda_bound_positions, &self.private_pda_by_position, - &self.globally_authorized, - &caller, - caller_pda_seeds, + &mut self.authorized_accounts, pre_account_id, pre_state_position, - pre_is_authorized, + caller_image_id, + caller_pda_seeds, + previous_is_authorized, + ); + + assert_eq!( + pre_is_authorized, is_authorized, + "Inconsistent authorization for account {pre_account_id}", ); } Entry::Vacant(_) => { @@ -368,50 +373,10 @@ impl ExecutionState { pre_account_id, ); } - let has_private_pda_witness = self - .private_pda_by_position - .contains_key(&pre_state_position); - if has_private_pda_witness { - assert_authorization_and_record_bindings( - &mut self.pda_family_binding, - &mut self.private_pda_bound_positions, - &self.private_pda_by_position, - &self.globally_authorized, - &caller, - caller_pda_seeds, - pre_account_id, - pre_state_position, - pre_is_authorized, - ); - } - if !has_private_pda_witness - && authorize_first_sight_without_pda_witness( - &mut self.pda_family_binding, - &mut self.globally_authorized, - &caller, - caller_pda_seeds, - pre_account_id, - pre_is_authorized, - ) - { - // authorize_first_sight_without_pda_witness is only true for PDAs - // which will be recorded in output journal. - // - // Since we are in a privacy circuit, the verifier cannot - // replay the transaction to see which public PDAs were - // actually authorized. We mark them false as the - // verifier checks regular account signatures as well. - pre.is_authorized = false; - } self.pre_states.push(pre); } } - // If an account it authorized, push it to the autorized set. - if pre_is_authorized { - authorized_output_accounts.push(pre_account_id); - } - if let Some(claim) = post.required_claim() { // The invoked program can only claim accounts with default program id. assert_eq!( @@ -495,10 +460,6 @@ impl ExecutionState { post_states_entry.insert_entry(post.into_account()); } - - let mut authorized_accounts = caller.authorized_accounts; - authorized_accounts.extend(authorized_output_accounts); - authorized_accounts } /// Consume self and yield the validity windows, the per-position PDA seed/program map @@ -582,131 +543,65 @@ fn bind_private_pda_position( } } -/// Match `account_id` against the caller's seeds under the public-PDA derivation. `None` -/// if no appropriate authorization given. -fn match_caller_seed_as_public_pda( - caller: &CallerData, - caller_pda_seeds: &[PdaSeed], - account_id: AccountId, -) -> Option<(PdaSeed, ProgramId)> { - // Recover the real `ProgramId` (RISC0 image id): on this branch every program account lives - // at the direct `AccountId::from(program_id)` bijection, so this round-trip is exact. - // `for_public_pda`'s derivation formula is pinned to the caller's actual image id, not its - // dispatch-facing `AccountId`. - let caller_program_id = ProgramId::from(caller.caller_account_id?); - // Costy for calls with multiple seeds in one call. - caller_pda_seeds.iter().find_map(|seed| { - if AccountId::for_public_pda(&caller_program_id, seed) == account_id { - return Some((*seed, caller_program_id)); - } - None - }) -} - -/// Match `account_id` against the caller's seeds interpreted as private-PDA derivations, using the -/// (npk, vpk, identifier) supplied for this position. `None` when the position carries no -/// private-PDA witness. -fn match_caller_seed_as_private_pda( - private_pda_by_position: &HashMap, - caller: &CallerData, - caller_pda_seeds: &[PdaSeed], - account_id: AccountId, - pre_state_position: usize, -) -> Option<(PdaSeed, ProgramId)> { - let (npk, vpk, identifier) = private_pda_by_position.get(&pre_state_position)?; - let caller_program_id = ProgramId::from(caller.caller_account_id?); - // Costy for calls with multiple seeds in one call. - caller_pda_seeds.iter().find_map(|seed| { - if AccountId::for_private_pda(&caller_program_id, seed, npk, vpk, *identifier) == account_id - { - return Some((*seed, caller_program_id)); - } - None - }) -} - -/// Judge a non-private-PDA `pre_state` at its first sighting and resolve its journal mask. -/// -/// Either the account is a public PDA in which case the public mask should be changed, or -/// it is a regular account. For PDAs, we assert the family bindings. For regular accounts, -/// add to global authorization set. -fn authorize_first_sight_without_pda_witness( - pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>, - globally_authorized: &mut HashSet, - caller: &CallerData, - caller_pda_seeds: &[PdaSeed], - pre_account_id: AccountId, - pre_is_authorized: bool, -) -> bool { - if let Some((seed, caller_program_id)) = - match_caller_seed_as_public_pda(caller, caller_pda_seeds, pre_account_id) - { - assert!( - pre_is_authorized, - "Caller-seeded public PDA must be declared authorized at first sight: {pre_account_id}" - ); - assert_family_binding(pda_family_binding, caller_program_id, seed, pre_account_id); - true - } else { - // If an authorized account is a non-PDA one, it is globally authorized. - if pre_is_authorized { - globally_authorized.insert(pre_account_id); - } - false - } -} - -/// When a caller seed matches, also records the `(caller, seed) → account_id` family binding -/// and, for the private form, marks the position in `private_pda_bound_positions`. Free -/// function so callers can pass individual `&mut self.*` field borrows without holding a borrow -/// on the surrounding struct's other fields. +/// Resolve the authorization state of a `pre_state` seen again in a chained call and record +/// any resulting bindings. Returns `true` if the `pre_state` is authorized through either a +/// previously-seen authorization or a matching caller seed (under the public or private +/// derivation). When a caller seed matches, also records the `(caller, seed) → account_id` +/// family binding and, for the private form, marks the position in +/// `private_pda_bound_positions`. Only reachable when `caller_image_id.is_some()`, +/// top-level flows have no caller-emitted seeds, so binding at top level must come from the +/// claim path. Free function so callers can pass individual `&mut self.*` field borrows +/// without holding a borrow on the surrounding struct's other fields. #[expect( clippy::too_many_arguments, reason = "breaking out a context struct does not buy us anything here" )] -fn assert_authorization_and_record_bindings( +fn resolve_authorization_and_record_bindings( pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>, private_pda_bound_positions: &mut HashMap, private_pda_by_position: &HashMap, - globally_authorized: &HashSet, - caller: &CallerData, - caller_pda_seeds: &[PdaSeed], + authorized_accounts: &mut HashSet, pre_account_id: AccountId, pre_state_position: usize, - pre_is_authorized: bool, -) { + caller_image_id: Option, + caller_pda_seeds: &[PdaSeed], + previous_is_authorized: bool, +) -> bool { + // `for_public_pda`/`for_private_pda`'s derivation formula is pinned to the caller's real + // image id, not its dispatch-facing `AccountId` — a `Deploy`-created caller's address doesn't + // encode it, so `caller_image_id` must be the recovered real image id (see + // `derive_from_outputs`'s `current_program_id`), not a bijection round-trip. let matched_caller_seed: Option<(PdaSeed, bool, ProgramId)> = - match_caller_seed_as_public_pda(caller, caller_pda_seeds, pre_account_id) - .map(|(seed, caller_program_id)| (seed, false, caller_program_id)) - .or_else(|| { - match_caller_seed_as_private_pda( - private_pda_by_position, - caller, - caller_pda_seeds, - pre_account_id, - pre_state_position, - ) - .map(|(seed, caller_program_id)| (seed, true, caller_program_id)) - }); + caller_image_id.and_then(|caller| { + caller_pda_seeds.iter().find_map(|seed| { + if AccountId::for_public_pda(&caller, seed) == pre_account_id { + return Some((*seed, false, caller)); + } + if let Some((npk, vpk, identifier)) = + private_pda_by_position.get(&pre_state_position) + && AccountId::for_private_pda(&caller, seed, npk, vpk, *identifier) + == pre_account_id + { + return Some((*seed, true, caller)); + } + None + }) + }); - if let Some((seed, is_private_form, caller_program_id)) = matched_caller_seed { - assert_family_binding(pda_family_binding, caller_program_id, seed, pre_account_id); + if let Some((seed, is_private_form, caller)) = matched_caller_seed { + assert_family_binding(pda_family_binding, caller, seed, pre_account_id); if is_private_form { - bind_private_pda_position( - private_pda_bound_positions, - pre_state_position, - caller_program_id, - seed, - ); + bind_private_pda_position(private_pda_bound_positions, pre_state_position, caller, seed); } } - let is_authorized = matched_caller_seed.is_some() - || globally_authorized.contains(&pre_account_id) - || caller.authorized_accounts.contains(&pre_account_id); + if authorized_accounts.contains(&pre_account_id) { + return true; + } - assert_eq!( - pre_is_authorized, is_authorized, - "Inconsistent authorization for account {pre_account_id}", - ); + let authorized = previous_is_authorized || matched_caller_seed.is_some(); + if authorized { + authorized_accounts.insert(pre_account_id); + } + authorized } diff --git a/lee/privacy_preserving_circuit/src/main.rs b/lee/privacy_preserving_circuit/src/main.rs index 1fc061573..dc41b0cd8 100644 --- a/lee/privacy_preserving_circuit/src/main.rs +++ b/lee/privacy_preserving_circuit/src/main.rs @@ -10,15 +10,19 @@ fn main() { account_identities, program_id, dummy_inputs, + program_image_claims, } = env::read(); let execution_state = execution_state::ExecutionState::derive_from_outputs( &account_identities, program_id, program_outputs, + &program_image_claims, ); - let output = output::compute_circuit_output(execution_state, &account_identities, dummy_inputs); + let mut output = + output::compute_circuit_output(execution_state, &account_identities, dummy_inputs); + output.program_image_claims = program_image_claims; env::commit(&output); } diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index aa6e4dae5..0b7f1069e 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -22,6 +22,8 @@ pub fn compute_circuit_output( private_actions: Vec::new(), block_validity_window, timestamp_validity_window, + // Set by the caller (`main.rs`) — this function has no need to know about it. + program_image_claims: Vec::new(), }; assert_eq!( diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index 8edacf4f6..f44a5ba95 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -4,11 +4,28 @@ use serde::{Deserialize, Serialize}; use crate::{ AuthorizationSecretKey, Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, - account::{Account, AccountWithMetadata}, + account::{Account, AccountId, AccountWithMetadata}, encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey}, program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow}, }; +/// A claim that `account_id`'s program account currently has `image_id`. +/// +/// Supplied by the prover as circuit input (untrusted), used inside the circuit for +/// `env::verify` in place of a `Deploy`-created program's address (which, unlike a legacy +/// program's, doesn't encode its image id), and echoed unchanged into the circuit's output. +/// Anchoring `image_id` to `account_id` is **not** enforced inside the circuit — it's enforced +/// by the sequencer, which independently checks every claim against real chain state +/// (`V03State::get_program`) before accepting the proof. A side effect of this, for now: every +/// program invoked anywhere in a private transaction's call graph is publicly visible via this +/// claim list. +#[derive(Serialize, Deserialize, Clone, Copy, BorshSerialize, BorshDeserialize)] +#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] +pub struct ProgramImageClaim { + pub account_id: AccountId, + pub image_id: ProgramId, +} + #[derive(Serialize, Deserialize)] pub struct PrivacyPreservingCircuitInput { /// Outputs of the program execution. @@ -21,6 +38,9 @@ pub struct PrivacyPreservingCircuitInput { /// Program ID. pub program_id: ProgramId, pub dummy_inputs: Vec, + /// Real `image_id`s for every `Deploy`-created program invoked in the call graph, keyed by + /// account id. See [`ProgramImageClaim`]. + pub program_image_claims: Vec, } #[derive(Serialize, Deserialize, Clone)] @@ -169,6 +189,9 @@ pub struct PrivacyPreservingCircuitOutput { pub private_actions: Vec, pub block_validity_window: BlockValidityWindow, pub timestamp_validity_window: TimestampValidityWindow, + /// Unchanged echo of [`PrivacyPreservingCircuitInput::program_image_claims`] — what the + /// receipt actually commits to, so the sequencer can check it against real chain state. + pub program_image_claims: Vec, } #[cfg(any(feature = "host", test))] @@ -267,6 +290,10 @@ mod tests { }], block_validity_window: (1..).into(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + program_image_claims: vec![ProgramImageClaim { + account_id: AccountId::new([3; 32]), + image_id: [4; 8], + }], }; let bytes = output.to_bytes(); let output_from_slice: PrivacyPreservingCircuitOutput = from_slice(&bytes).unwrap(); diff --git a/lee/state_machine/core/src/lib.rs b/lee/state_machine/core/src/lib.rs index ab7b40f36..caa23639e 100644 --- a/lee/state_machine/core/src/lib.rs +++ b/lee/state_machine/core/src/lib.rs @@ -5,7 +5,8 @@ pub use circuit_io::{ DummyInput, InputAccountIdentity, NullifierWitness, PrivacyPreservingCircuitInput, - PrivacyPreservingCircuitOutput, PrivateAction, PrivateWitness, PublicAction, WitnessKind, + PrivacyPreservingCircuitOutput, PrivateAction, PrivateWitness, ProgramImageClaim, PublicAction, + WitnessKind, }; pub use commitment::{ Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, MembershipProof, diff --git a/lee/state_machine/core/src/program/mod.rs b/lee/state_machine/core/src/program/mod.rs index dd6d46090..c6f07e73d 100644 --- a/lee/state_machine/core/src/program/mod.rs +++ b/lee/state_machine/core/src/program/mod.rs @@ -22,7 +22,19 @@ pub const PROGRAM_STORAGE_OWNER: AccountId = AccountId::new([0xFF; 32]); /// Reserved `AccountId` for the native "Deploy" dispatch shortcut. /// -/// `SHA256("/LEE/v0.3/AccountId/State/" || "DeploymentProgram")`, each padded to 32 bytes. +/// `SHA256(domain_separator || label)`, where `domain_separator` is +/// `/LEE/v0.3/AccountId/State/` and `label` is `DeploymentProgram`, each padded with trailing +/// zero bytes to 32 bytes before concatenation — the same domain-separation construction used +/// throughout this module, just with no variable input, since this is a single fixed address +/// rather than a per-caller derivation. +/// +/// Dispatch recognizes this exact `AccountId` and runs the deploy logic as native Rust instead +/// of interpreting a guest ELF: computing a program's image id inside the zkVM costs roughly +/// 1,400-1,500 cycles per byte of deployed bytecode (measured against every real program in +/// this repo), pushing a real deployment to 500M-900M cycles against the 32M public-execution +/// cap, whereas the equivalent native computation costs low tens of milliseconds. A caller +/// targets this address directly as a `Message`/`ChainedCall`'s `AccountId`, same as any other +/// program. pub const RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID: AccountId = AccountId::new(hex!( "599e2c6c2b89ff39bc3094b3276f1fcaa7173800a71d9896a1ba9bd1458a91c9" )); @@ -31,12 +43,51 @@ pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; pub type ProgramId = [u32; 8]; -/// Derives the `AccountId` under which a program's data is stored, directly from its -/// `ProgramId`, by reinterpreting the 8 little-endian `u32` words as 32 raw bytes. +/// The account-data layout of a program's header account, deployed via the `Deploy` native +/// dispatch shortcut (see [`RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID`]). /// -/// A 1:1, information-preserving mapping (both types are exactly 32 bytes) rather than a -/// hash — `ProgramId` is already content-derived (RISC0's `image_id`), so no extra domain -/// separation is needed just to use it as a `HashMap` key. +/// Deliberately holds only small, fixed-size fields — never the program's bytecode, which lives +/// in a separate account (see `program_loader_core::deploy_segment_account_id`). Keeping the two +/// apart means anything that needs to authenticate a program's *identity* (e.g. the +/// privacy-preserving circuit confirming which `image_id` an `AccountId` currently maps to) only +/// ever has to read this handful of bytes, not the full program — the only account-authentication +/// primitive available today is whole-account equality, so what's bundled into one account sets +/// the floor for how cheap that authentication can be. +/// +/// `image_id` is read fresh from this account on every dispatch/verification rather than +/// re-derived from the account's address, specifically so that upgrading a program (writing a +/// new `image_id` into the same, stable `AccountId`) is the entire upgrade mechanism — no +/// separate "which version" bookkeeping needed. +/// +/// Lives here rather than in `program_loader_core` so that `V03State::get_program` — a generic, +/// program-agnostic lookup — can decode it without depending on a specific program's crate. +/// `program_loader_core` re-exports this type. +#[derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct ProgramData { + pub image_id: ProgramId, + pub segment_number: u32, + pub update_auth: AccountId, +} + +impl TryFrom<&crate::account::Data> for ProgramData { + type Error = std::io::Error; + + fn try_from(data: &crate::account::Data) -> Result { + BorshDeserialize::try_from_slice(data.as_ref()) + } +} + +impl From<&ProgramData> for crate::account::Data { + fn from(program_data: &ProgramData) -> Self { + let mut data = Vec::with_capacity(std::mem::size_of_val(program_data)); + BorshSerialize::serialize(program_data, &mut data) + .expect("borsh serialization should not fail"); + Self::try_from(data).expect("elf must fit under DATA_MAX_LENGTH") + } +} + +/// TODO: This is a temporary conversion; will be removed once `Program` to `Account` +/// migration is complete. impl From for AccountId { fn from(program_id: ProgramId) -> Self { let bytes: Vec = program_id @@ -244,12 +295,6 @@ impl AccountId { } } -#[derive(Debug)] -pub struct CallerData { - pub caller_account_id: Option, - pub authorized_accounts: HashSet, -} - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct ChainedCall { /// The `AccountId` of the program to execute. @@ -682,17 +727,12 @@ pub enum ExecutionValidationError { /// `pre_state`. #[must_use] pub fn compute_public_authorized_pdas( - caller_account_id: Option, + caller_image_id: Option, pda_seeds: &[PdaSeed], ) -> HashSet { - let Some(caller) = caller_account_id else { + let Some(caller) = caller_image_id else { return HashSet::new(); }; - // Recover the real `ProgramId` (RISC0 image id): on this branch every program account lives - // at the direct `AccountId::from(program_id)` bijection, so this round-trip is exact. - // `for_public_pda`'s derivation formula is pinned to the caller's actual image id, not its - // dispatch-facing `AccountId`. - let caller = ProgramId::from(caller); pda_seeds .iter() .map(|seed| AccountId::for_public_pda(&caller, seed)) diff --git a/lee/state_machine/core/src/program/tests.rs b/lee/state_machine/core/src/program/tests.rs index 69f036721..4a0b1104b 100644 --- a/lee/state_machine/core/src/program/tests.rs +++ b/lee/state_machine/core/src/program/tests.rs @@ -326,7 +326,7 @@ fn for_private_account_dispatches_correctly() { fn compute_public_authorized_pdas_with_seeds() { let caller: ProgramId = [1; 8]; let seed = PdaSeed::new([2; 32]); - let result = compute_public_authorized_pdas(Some(caller.into()), &[seed]); + let result = compute_public_authorized_pdas(Some(caller), &[seed]); let expected = AccountId::for_public_pda(&caller, &seed); assert!(result.contains(&expected)); assert_eq!(result.len(), 1); diff --git a/lee/state_machine/src/error.rs b/lee/state_machine/src/error.rs index 235f6e441..006f0cfe7 100644 --- a/lee/state_machine/src/error.rs +++ b/lee/state_machine/src/error.rs @@ -1,9 +1,6 @@ use std::io; -use lee_core::{ - account::{Account, AccountId}, - program::ProgramId, -}; +use lee_core::account::{Account, AccountId}; use thiserror::Error; #[macro_export] @@ -68,9 +65,6 @@ pub enum LeeError { #[error("Invalid program bytecode")] InvalidProgramBytecode(#[source] anyhow::Error), - #[error("Program already exists")] - ProgramAlreadyExists, - #[error("Chain of calls is too long")] MaxChainedCallsDepthExceeded, @@ -129,8 +123,8 @@ pub enum InvalidProgramBehaviorError { #[error("Default account {account_id} was modified without being claimed")] DefaultAccountModifiedWithoutClaim { account_id: AccountId }, - #[error("Called program {program_id:?} which is not listed in dependencies")] - UndeclaredProgramDependency { program_id: ProgramId }, + #[error("Called program {account_id} which is not listed in dependencies")] + UndeclaredProgramDependency { account_id: AccountId }, #[error( "Account {account_id} was declared in the transaction but is missing from the program output" diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs index 1d97b46c3..15d14cd22 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs @@ -3,9 +3,9 @@ use std::collections::{HashMap, VecDeque}; use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ DummyInput, InputAccountIdentity, PrivacyPreservingCircuitInput, - PrivacyPreservingCircuitOutput, + PrivacyPreservingCircuitOutput, ProgramImageClaim, account::{AccountId, AccountWithMetadata}, - program::{ChainedCall, InstructionData, ProgramId, ProgramOutput}, + program::{ChainedCall, InstructionData, ProgramOutput}, }; use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; @@ -43,18 +43,32 @@ impl Proof { #[derive(Clone)] pub struct ProgramWithDependencies { pub program: Program, + /// Where `program` is dispatched at. Defaults to `AccountId::from(program.id())` (correct + /// for a legacy, bijection-addressed program); override via + /// [`Self::with_program_account_id`] for a program deployed to a PDA (e.g. via `Deploy`). + pub program_account_id: AccountId, // TODO: avoid having a copy of the bytecode of each dependency. - pub dependencies: HashMap, + pub dependencies: HashMap, } impl ProgramWithDependencies { #[must_use] - pub const fn new(program: Program, dependencies: HashMap) -> Self { + pub fn new(program: Program, dependencies: HashMap) -> Self { + let program_account_id = AccountId::from(program.id()); Self { program, + program_account_id, dependencies, } } + + /// Overrides the address `program` is dispatched at, for a program whose address isn't + /// derived from its own image id (e.g. deployed via `Deploy` to a PDA). + #[must_use] + pub const fn with_program_account_id(mut self, program_account_id: AccountId) -> Self { + self.program_account_id = program_account_id; + self + } } impl From for ProgramWithDependencies { @@ -89,13 +103,33 @@ pub fn execute_and_prove_with_padded_inputs( ) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> { let ProgramWithDependencies { program: initial_program, + program_account_id: initial_program_account_id, dependencies, } = program_with_dependencies; let mut env_builder = ExecutorEnv::builder(); let mut program_outputs = Vec::new(); + // Real `image_id`s for every program in this call graph whose address doesn't already + // determine it — i.e. every `Deploy`-created program, PDA-addressed rather than + // bijection-addressed. A legacy program needs no claim at all: `ProgramId::from(account_id)` + // is already exact for it, by construction, with nothing to authenticate. See + // `ProgramImageClaim` and `execution_state.rs`'s matching bijection fallback. + let program_image_claims: Vec = + std::iter::once((*initial_program_account_id, initial_program.id())) + .chain( + dependencies + .iter() + .map(|(account_id, program)| (*account_id, program.id())), + ) + .filter(|(account_id, image_id)| *account_id != AccountId::from(*image_id)) + .map(|(account_id, image_id)| ProgramImageClaim { + account_id, + image_id, + }) + .collect(); + let initial_call = ChainedCall { - program_account_id: AccountId::from(initial_program.id()), + program_account_id: *initial_program_account_id, instruction_data, pre_states, pda_seeds: vec![], @@ -110,6 +144,7 @@ pub fn execute_and_prove_with_padded_inputs( let inner_receipt = execute_and_prove_program( program, + chained_call.program_account_id, caller_account_id, &chained_call.pre_states, &chained_call.instruction_data, @@ -127,10 +162,9 @@ pub fn execute_and_prove_with_padded_inputs( env_builder.add_assumption(inner_receipt); for new_call in program_output.chained_calls.into_iter().rev() { - let new_call_program_id = ProgramId::from(new_call.program_account_id); - let next_program = dependencies.get(&new_call_program_id).ok_or( + let next_program = dependencies.get(&new_call.program_account_id).ok_or( InvalidProgramBehaviorError::UndeclaredProgramDependency { - program_id: new_call_program_id, + account_id: new_call.program_account_id, }, )?; chained_calls.push_front(( @@ -150,6 +184,7 @@ pub fn execute_and_prove_with_padded_inputs( account_identities, program_id: program_with_dependencies.program.id(), dummy_inputs, + program_image_claims, }; env_builder.write(&circuit_input).unwrap(); @@ -173,6 +208,7 @@ pub fn execute_and_prove_with_padded_inputs( fn execute_and_prove_program( program: &Program, + self_account_id: AccountId, caller_account_id: Option, pre_states: &[AccountWithMetadata], instruction_data: &InstructionData, @@ -180,7 +216,7 @@ fn execute_and_prove_program( // Write inputs to the program let mut env_builder = ExecutorEnv::builder(); Program::write_inputs( - AccountId::from(program.id()), + self_account_id, caller_account_id, pre_states, instruction_data, diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs index 80a415e9c..386adbddb 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -381,7 +381,7 @@ fn circuit_fails_when_chained_validity_windows_have_empty_intersection() { let program_with_deps = ProgramWithDependencies::new( validity_window_chain_caller, - [(validity_window.id(), validity_window)].into(), + [(validity_window.id().into(), validity_window)].into(), ); let result = execute_and_prove( @@ -465,7 +465,7 @@ fn private_pda_init() { let auth_id = simple_transfer.id(); let program_with_deps = - ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into()); + ProgramWithDependencies::new(program, [(auth_id.into(), simple_transfer)].into()); // is_withdraw=false triggers init path (1 pre-state) let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, false)).unwrap(); @@ -509,7 +509,7 @@ fn private_pda_withdraw() { let auth_id = simple_transfer.id(); let program_with_deps = - ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into()); + ProgramWithDependencies::new(program, [(auth_id.into(), simple_transfer)].into()); // is_withdraw=true, amount=0 (PDA has no balance yet) let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, true)).unwrap(); @@ -960,8 +960,10 @@ fn pda_update_attempt( let pda_pre = AccountWithMetadata::new(pda_account, declare_authorized, pda_id); let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); - let program_with_deps = - ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into()); + let program_with_deps = ProgramWithDependencies::new( + program, + [(simple_transfer_id.into(), simple_transfer)].into(), + ); execute_and_prove( vec![pda_pre, recipient_pre], diff --git a/lee/state_machine/src/privacy_preserving_transaction/message.rs b/lee/state_machine/src/privacy_preserving_transaction/message.rs index 9df452366..e907e4645 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/message.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/message.rs @@ -1,6 +1,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ Commitment, CommitmentSetDigest, Nullifier, PrivacyPreservingCircuitOutput, PrivateAction, + ProgramImageClaim, account::{Account, Nonce}, program::{BlockValidityWindow, TimestampValidityWindow}, }; @@ -24,6 +25,10 @@ pub struct Message { pub private_actions: Vec, pub block_validity_window: BlockValidityWindow, pub timestamp_validity_window: TimestampValidityWindow, + /// Real `image_id`s claimed for every `Deploy`-created program invoked in this transaction's + /// call graph — see [`ProgramImageClaim`]. The sequencer checks each against real chain + /// state before accepting the transaction. + pub program_image_claims: Vec, } impl std::fmt::Debug for Message { @@ -52,6 +57,7 @@ impl std::fmt::Debug for Message { .field("private_actions", &private_actions) .field("block_validity_window", &self.block_validity_window) .field("timestamp_validity_window", &self.timestamp_validity_window) + .field("program_image_claims", &self.program_image_claims) .finish() } } @@ -73,6 +79,7 @@ impl Message { private_actions: output.private_actions, block_validity_window: output.block_validity_window, timestamp_validity_window: output.timestamp_validity_window, + program_image_claims: output.program_image_claims, } } @@ -168,6 +175,7 @@ pub mod tests { }], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + program_image_claims: vec![], } } @@ -179,6 +187,7 @@ pub mod tests { private_actions: vec![], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + program_image_claims: vec![], }; // empty vec fields: u32 len=0 @@ -187,6 +196,7 @@ pub mod tests { let private_actions_bytes: &[u8] = &[0, 0, 0, 0]; // validity windows: unbounded = {from: None (0_u8), to: None (0_u8)} let unbounded_window_bytes: &[u8] = &[0, 0]; + let program_image_claims_bytes: &[u8] = &[0, 0, 0, 0]; let expected_borsh_vec: Vec = [ public_actions_bytes, @@ -194,6 +204,7 @@ pub mod tests { private_actions_bytes, unbounded_window_bytes, // block_validity_window unbounded_window_bytes, // timestamp_validity_window + program_image_claims_bytes, ] .concat(); let expected_borsh: &[u8] = &expected_borsh_vec; diff --git a/lee/state_machine/src/program/mod.rs b/lee/state_machine/src/program/mod.rs index ab3cf69cf..617721d93 100644 --- a/lee/state_machine/src/program/mod.rs +++ b/lee/state_machine/src/program/mod.rs @@ -54,6 +54,7 @@ impl Program { pub(crate) fn execute( &self, + self_account_id: AccountId, caller_account_id: Option, pre_states: &[AccountWithMetadata], instruction_data: &InstructionData, @@ -62,7 +63,7 @@ impl Program { let mut env_builder = ExecutorEnv::builder(); env_builder.session_limit(Some(MAX_NUM_CYCLES_PUBLIC_EXECUTION)); Self::write_inputs( - AccountId::from(self.id), + self_account_id, caller_account_id, pre_states, instruction_data, diff --git a/lee/state_machine/src/program/tests.rs b/lee/state_machine/src/program/tests.rs index 330bd0d6b..b1bf4f618 100644 --- a/lee/state_machine/src/program/tests.rs +++ b/lee/state_machine/src/program/tests.rs @@ -26,7 +26,12 @@ fn program_execution() { ..Account::default() }; let program_output = program - .execute(None, &[sender, recipient], &instruction_data) + .execute( + AccountId::from(program.id()), + None, + &[sender, recipient], + &instruction_data, + ) .unwrap(); let [sender_post, recipient_post] = program_output.post_states.try_into().unwrap(); diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index 814315efa..d4063acdb 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -5,7 +5,9 @@ use lee_core::{ BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier, Timestamp, account::{Account, AccountId, Data}, - program::PROGRAM_STORAGE_OWNER, + program::{ + PROGRAM_STORAGE_OWNER, ProgramData, ProgramId, RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID, + }, }; use crate::{ @@ -112,6 +114,14 @@ impl BorshDeserialize for NullifierSet { #[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] #[cfg_attr(test, derive(Debug))] pub struct V03State { + /// Deployed programs live here too, as `Account`s findable via [`Self::get_program`], which + /// recognizes two shapes: legacy `ProgramDeploymentTransaction`-deployed programs, keyed by + /// `AccountId::from(program_id)` (see that impl's doc comment) with the raw elf in + /// `Account.data` and `program_owner` set to [`PROGRAM_STORAGE_OWNER`]; and `Deploy`-created + /// programs (including every genesis-seeded builtin, via [`Self::insert_program`]), which + /// live across two accounts owned by [`RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID`] — a header + /// account whose `Account.data` is a borsh-encoded [`ProgramData`] (the current `image_id`, + /// small and fixed-size), and a separate segment PDA holding the raw elf. public_state: HashMap, private_state: (CommitmentSet, NullifierSet), } @@ -193,15 +203,46 @@ impl V03State { self } + /// Seeds a program directly into state in the same two-account shape a `Deploy` dispatch + /// produces (see [`Self::get_program`]), skipping the dispatch/proving machinery genesis has + /// no signer to drive. The header account is placed at `AccountId::from(image_id)` rather + /// than the loader-PDA address a live `Deploy` would use for it — deliberately, so a + /// genesis-seeded program keeps its well-known dispatch address — while the segment account + /// still lives at the exact PDA [`Self::get_program`] derives from the header's content, + /// since that address is never a caller-facing well-known address to begin with. pub(crate) fn insert_program(&mut self, program: &Program) { - let account_id = AccountId::from(program.id()); - let account = Account { - program_owner: PROGRAM_STORAGE_OWNER, + let image_id = program.id(); + let segment_number = 0; + let update_auth = AccountId::default(); + + let header_account_id = AccountId::from(image_id); + let header_account = Account { + program_owner: RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID, + data: Data::from(&ProgramData { + image_id, + segment_number, + update_auth, + }), + ..Account::default() + }; + + let loader_id = ProgramId::from(RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID); + let segment_account_id = loader_core::deploy_segment_account_id( + loader_id, + image_id, + segment_number, + update_auth, + ); + let segment_account = Account { + program_owner: RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID, data: Data::try_from(program.elf().to_vec()) .expect("elf must fit under DATA_MAX_LENGTH"), ..Account::default() }; - self.public_state.insert(account_id, account); + + self.public_state.insert(header_account_id, header_account); + self.public_state + .insert(segment_account_id, segment_account); } pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) { @@ -258,7 +299,7 @@ impl V03State { &mut self, tx: &ProgramDeploymentTransaction, ) -> Result<(), LeeError> { - let diff = ValidatedStateDiff::from_program_deployment_transaction(tx, self)?; + let diff = ValidatedStateDiff::from_program_deployment_transaction(tx)?; self.apply_state_diff(diff); Ok(()) } @@ -281,16 +322,45 @@ impl V03State { self.public_state.get(&account_id) } - /// Looks up a deployed program's storage account by its `AccountId`, verifying it is - /// actually owned by [`PROGRAM_STORAGE_OWNER`]. + /// Looks up a deployed program's real `image_id` and bytecode by its `AccountId`, + /// recognizing both ways a program can come to exist: /// - /// An account that lacks this ownership isn't a deployed program, whatever its contents — + /// - Owned by [`PROGRAM_STORAGE_OWNER`]: the legacy `ProgramDeploymentTransaction` path, where + /// `account.data` is the raw ELF directly and `AccountId::from(image_id)` is the account's + /// address by construction. + /// - Owned by [`RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID`]: deployed via the native `Deploy` + /// dispatch shortcut. `account.data` decodes as a [`ProgramData`] header holding the real + /// `image_id`; the bytecode itself lives in a second, separately-addressed segment account + /// derived from that header (see `loader_core::deploy_segment_account_id`). + /// + /// Returning the real `image_id` — rather than callers deriving one from the address, which + /// is only valid for the legacy path — is what makes upgrading a `Deploy`-created program + /// possible: the address never changes, only the `image_id` written into its header account + /// does. + /// + /// An account that matches neither owner isn't a deployed program, whatever its contents — /// this is the single place that distinction is enforced, so callers never have to remember /// to re-check it themselves. #[must_use] - pub fn get_program(&self, program_account_id: AccountId) -> Option<&Account> { + pub fn get_program(&self, program_account_id: AccountId) -> Option<(ProgramId, Vec)> { let account = self.get_account_by_id_ref(program_account_id)?; - (account.program_owner == PROGRAM_STORAGE_OWNER).then_some(account) + if account.program_owner == PROGRAM_STORAGE_OWNER { + return Some((ProgramId::from(program_account_id), account.data.to_vec())); + } + if account.program_owner == RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID { + let header = ProgramData::try_from(&account.data).ok()?; + let loader_id = ProgramId::from(RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID); + let segment_account_id = loader_core::deploy_segment_account_id( + loader_id, + header.image_id, + header.segment_number, + header.update_auth, + ); + let segment = self.get_account_by_id_ref(segment_account_id)?; + return (segment.program_owner == RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID) + .then(|| (header.image_id, segment.data.to_vec())); + } + None } #[must_use] diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs index 7ba63f7b3..8f4d13e06 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -498,7 +498,8 @@ fn caller_pda_seeds_authorize_private_pda_for_callee() { let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); let callee_id = callee.id(); - let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); + let program_with_deps = + ProgramWithDependencies::new(delegator, [(callee_id.into(), callee)].into()); let result = execute_and_prove( vec![pre_state], @@ -530,7 +531,8 @@ fn caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee() { let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); let callee_id = callee.id(); - let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); + let program_with_deps = + ProgramWithDependencies::new(delegator, [(callee_id.into(), callee)].into()); let result = execute_and_prove( vec![pre_state], @@ -557,7 +559,7 @@ fn sibling_declaring_delegated_pda(pda_is_authorized: bool) -> Result<(), LeeErr let sibling_id = sibling.id(); let program_with_deps = ProgramWithDependencies::new( delegator, - [(callee_id, callee), (sibling_id, sibling)].into(), + [(callee_id.into(), callee), (sibling_id.into(), sibling)].into(), ); execute_and_prove( @@ -605,7 +607,7 @@ fn delegated_pda_stays_authorized_in_delegated_subtree() { let callee_id = callee.id(); let program_with_deps = ProgramWithDependencies::new( delegator, - [(forwarder_id, forwarder), (callee_id, callee)].into(), + [(forwarder_id.into(), forwarder), (callee_id.into(), callee)].into(), ); let no_sibling: Option<(ProgramId, Option)> = None; @@ -650,7 +652,7 @@ fn holder_authorization_survives_across_sibling_calls() { let sibling_id = sibling.id(); let program_with_deps = ProgramWithDependencies::new( delegator, - [(callee_id, callee), (sibling_id, sibling)].into(), + [(callee_id.into(), callee), (sibling_id.into(), sibling)].into(), ); execute_and_prove( @@ -699,7 +701,7 @@ fn inherited_scope_passes_through_intermediate_calls() { let callee_id = callee.id(); let program_with_deps = ProgramWithDependencies::new( delegator, - [(forwarder_id, forwarder), (callee_id, callee)].into(), + [(forwarder_id.into(), forwarder), (callee_id.into(), callee)].into(), ); let no_sibling: Option<(ProgramId, Option)> = None; let forward_through_undeclaring_call = Program::serialize_instruction(( @@ -748,7 +750,7 @@ fn undeclaring_private_delegation( let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); let callee_id = callee.id(); - let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); + let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id.into(), callee)].into()); execute_and_prove( vec![pre_state], @@ -831,7 +833,7 @@ fn undeclaring_public_delegation( let sibling_id = sibling.id(); let program_with_deps = ProgramWithDependencies::new( delegator, - [(callee_id, callee), (sibling_id, sibling)].into(), + [(callee_id.into(), callee), (sibling_id.into(), sibling)].into(), ); execute_and_prove( @@ -1334,7 +1336,7 @@ fn two_private_pda_family_members_receive_and_spend() { let spend_with_deps = ProgramWithDependencies::new( proxy, - [(simple_transfer_id, simple_transfer.clone())].into(), + [(simple_transfer_id.into(), simple_transfer.clone())].into(), ); let funder_id = funder_keys.account_id(); diff --git a/lee/state_machine/src/state/tests/claiming.rs b/lee/state_machine/src/state/tests/claiming.rs index ef8c762f1..88334e66e 100644 --- a/lee/state_machine/src/state/tests/claiming.rs +++ b/lee/state_machine/src/state/tests/claiming.rs @@ -419,7 +419,7 @@ fn private_chained_call(number_of_calls: u32) { let mut dependencies = HashMap::new(); - dependencies.insert(simple_transfers.id(), simple_transfers); + dependencies.insert(simple_transfers.id().into(), simple_transfers); let program_with_deps = ProgramWithDependencies::new(chain_caller, dependencies); let from_new_nonce = Nonce::default().private_account_nonce_increment(&from_keys.nsk()); diff --git a/lee/state_machine/src/state/tests/privacy_preserving.rs b/lee/state_machine/src/state/tests/privacy_preserving.rs index e7a8605ab..d1a18d77d 100644 --- a/lee/state_machine/src/state/tests/privacy_preserving.rs +++ b/lee/state_machine/src/state/tests/privacy_preserving.rs @@ -506,7 +506,7 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() { let instruction = (balance_to_transfer, simple_transfers.id()); let mut dependencies = HashMap::new(); - dependencies.insert(simple_transfers.id(), simple_transfers); + dependencies.insert(simple_transfers.id().into(), simple_transfers); let program_with_deps = ProgramWithDependencies::new(malicious_program, dependencies); // Act - execute the malicious program - this should fail during proving diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs index 528253786..7729bd37e 100644 --- a/lee/state_machine/src/validated_state_diff/mod.rs +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -5,10 +5,11 @@ use std::{ }; use lee_core::{ - BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, PublicAction, Timestamp, + BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, ProgramImageClaim, + PublicAction, Timestamp, account::{Account, AccountId, AccountWithMetadata}, program::{ - CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, ProgramId, ProgramOutput, + ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, ProgramId, ProgramOutput, RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID, compute_public_authorized_pdas, validate_execution, }, }; @@ -99,6 +100,7 @@ impl ValidatedStateDiff { let initial_caller_data = CallerData { caller_account_id: None, + caller_image_id: None, authorized_accounts: signer_account_ids.iter().copied().collect(), }; @@ -112,22 +114,25 @@ impl ValidatedStateDiff { LeeError::MaxChainedCallsDepthExceeded ); - // Recover the real `ProgramId` (RISC0 image id) from the account's address: on this - // branch every program account lives at the direct `AccountId::from(program_id)` - // bijection, so this round-trip is exact. Needed wherever execution/PDA derivation - // requires the underlying image id rather than the dispatch-facing `AccountId`. - let program_id = ProgramId::from(chained_call.program_account_id); - debug!( "Program {:?} pre_states: {:?}, instruction_data: {:?}", chained_call.program_account_id, chained_call.pre_states, chained_call.instruction_data ); - let mut program_output = if chained_call.program_account_id + let (program_id, mut program_output) = if chained_call.program_account_id == RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID { - // Runs `Deploy` as native Rust instead of interpreting a guest ELF. + // Runs `Deploy` as native Rust instead of interpreting a guest ELF — see + // `RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID`'s doc comment for why. The + // loader's own identity is this fixed reserved `AccountId`, unlike an + // ordinary program's, so recovering it via the bijection is exact — there's + // no separate "real image id" to look up, Deploy isn't itself upgradeable. + let program_id = ProgramId::from(RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID); + // `execute_deploy` validates its input via `assert!`/`.expect(...)`, exactly + // like every guest program in this codebase, relying here on `catch_unwind` to + // play the same role the zkVM executor plays for a real guest: converting a + // rejected input into a graceful `Err` instead of unwinding past this call. let program_loader_core::Instruction::Deploy { bytecode } = risc0_zkvm::serde::from_slice(&chained_call.instruction_data).map_err(|e| { LeeError::InvalidInput(format!("invalid Deploy instruction: {e}")) @@ -139,25 +144,35 @@ impl ValidatedStateDiff { .map_err(|_panic_payload| { LeeError::ProgramExecutionFailed("Deploy rejected the given input".into()) })?; - ProgramOutput::new( - chained_call.program_account_id, - caller_data.caller_account_id, - chained_call.instruction_data.clone(), - chained_call.pre_states.clone(), - post_states, + ( + program_id, + ProgramOutput::new( + chained_call.program_account_id, + caller_data.caller_account_id, + chained_call.instruction_data.clone(), + chained_call.pre_states.clone(), + post_states, + ), ) } else { - let Some(program_account) = state.get_program(chained_call.program_account_id) + // The real `image_id`, sourced from the program's own account rather than + // guessed from its address — see `V03State::get_program`'s doc comment for + // why that distinction matters once a program's address can outlive its + // current bytecode (upgrades). + let Some((program_id, elf)) = state.get_program(chained_call.program_account_id) else { return Err(LeeError::InvalidInput("Unknown program".into())); }; - let program = - Program::new_unchecked(program_id, Cow::Owned(program_account.data.to_vec())); - program.execute( - caller_data.caller_account_id, - &chained_call.pre_states, - &chained_call.instruction_data, - )? + let program = Program::new_unchecked(program_id, Cow::Owned(elf)); + ( + program_id, + program.execute( + chained_call.program_account_id, + caller_data.caller_account_id, + &chained_call.pre_states, + &chained_call.instruction_data, + )?, + ) }; debug!( "Program {:?} output: {:?}", @@ -165,7 +180,7 @@ impl ValidatedStateDiff { ); let authorized_pdas = compute_public_authorized_pdas( - caller_data.caller_account_id, + caller_data.caller_image_id, &chained_call.pda_seeds, ); @@ -313,6 +328,7 @@ impl ValidatedStateDiff { new_call, CallerData { caller_account_id: Some(chained_call.program_account_id), + caller_image_id: Some(program_id), authorized_accounts: authorized_accounts.clone(), }, )); @@ -444,6 +460,7 @@ impl ValidatedStateDiff { // 4. Proof verification check_privacy_preserving_circuit_proof_is_valid( + state, &witness_set.proof, &public_pre_states, message, @@ -473,13 +490,9 @@ impl ValidatedStateDiff { pub fn from_program_deployment_transaction( tx: &ProgramDeploymentTransaction, - state: &V03State, ) -> Result { // TODO: remove clone let program = Program::new(tx.message.bytecode.clone().into())?; - if state.get_program(AccountId::from(program.id())).is_some() { - return Err(LeeError::ProgramAlreadyExists); - } Ok(Self(StateDiff { signer_account_ids: vec![], public_diff: HashMap::new(), @@ -503,6 +516,17 @@ impl ValidatedStateDiff { } } +#[derive(Debug)] +struct CallerData { + caller_account_id: Option, + /// The caller's real `image_id`, recovered when the caller itself was dispatched (see + /// `V03State::get_program`) rather than guessed from `caller_account_id` via the bijection — + /// needed wherever PDA derivation requires the caller's actual identity, since a + /// `Deploy`-created caller's address doesn't encode it. + caller_image_id: Option, + authorized_accounts: HashSet, +} + fn authenticate_public_transaction_signers( tx: &PublicTransaction, state: &V03State, @@ -535,10 +559,30 @@ fn authenticate_public_transaction_signers( } fn check_privacy_preserving_circuit_proof_is_valid( + state: &V03State, proof: &Proof, public_pre_states: &[AccountWithMetadata], message: &Message, ) -> Result<(), LeeError> { + // Anchor each claimed image_id to real chain state: reconstruct the claims using the + // program's *actual* current image_id (via `get_program`), not the message's own claim. If + // the claim was wrong, the reconstructed journal won't match what the receipt actually + // committed to, and `proof.is_valid_for` below fails — the same mechanism `public_actions` + // already relies on for authenticating account content against real state. + let program_image_claims = message + .program_image_claims + .iter() + .map(|claim| { + let (image_id, _elf) = state.get_program(claim.account_id).ok_or_else(|| { + LeeError::InvalidInput(format!("Unknown program {}", claim.account_id)) + })?; + Ok(ProgramImageClaim { + account_id: claim.account_id, + image_id, + }) + }) + .collect::, LeeError>>()?; + let output = PrivacyPreservingCircuitOutput { public_actions: public_pre_states .iter() @@ -552,6 +596,7 @@ fn check_privacy_preserving_circuit_proof_is_valid( private_actions: message.private_actions.clone(), block_validity_window: message.block_validity_window, timestamp_validity_window: message.timestamp_validity_window, + program_image_claims, }; proof .is_valid_for(&output) diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs index dd113545d..107808b64 100644 --- a/lee/state_machine/src/validated_state_diff/tests.rs +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -161,7 +161,7 @@ fn privacy_malicious_programs_cannot_drain_public_victim() { let at = crate::test_methods::simple_balance_transfer(); let program_with_deps = ProgramWithDependencies::new( crate::test_methods::malicious_injector(), - [(p2.id(), p2), (at.id(), at)].into(), + [(p2.id().into(), p2), (at.id().into(), at)].into(), ); // account_identities order must match self.pre_states as built by the circuit: @@ -322,7 +322,7 @@ fn privacy_malicious_programs_cannot_drain_private_victim() { let at = crate::test_methods::simple_balance_transfer(); let program_with_deps = ProgramWithDependencies::new( crate::test_methods::malicious_injector(), - [(p2.id(), p2), (at.id(), at)].into(), + [(p2.id().into(), p2), (at.id().into(), at)].into(), ); // account_identities order must match self.pre_states as built by the circuit: @@ -529,6 +529,7 @@ fn privacy_garbage_proof_is_rejected() { }], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + program_image_claims: vec![], }; // Garbage proof bytes: not a valid borsh-encoded `InnerReceipt`. diff --git a/lez/common/src/transaction.rs b/lez/common/src/transaction.rs index 8a8bd02a7..536f63742 100644 --- a/lez/common/src/transaction.rs +++ b/lez/common/src/transaction.rs @@ -117,7 +117,7 @@ impl LeeTransaction { tx, state, block_id, timestamp, ), Self::ProgramDeployment(tx) => { - ValidatedStateDiff::from_program_deployment_transaction(tx, state) + ValidatedStateDiff::from_program_deployment_transaction(tx) } } } diff --git a/lez/explorer_service/src/components/transaction_details.rs b/lez/explorer_service/src/components/transaction_details.rs index 3fe780967..f6c7387c4 100644 --- a/lez/explorer_service/src/components/transaction_details.rs +++ b/lez/explorer_service/src/components/transaction_details.rs @@ -73,6 +73,7 @@ pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl Into private_actions, block_validity_window, timestamp_validity_window, + program_image_claims, } = message; let private_action_count = private_actions.len(); let public_account_ids: Vec<_> = public_actions @@ -80,6 +81,9 @@ pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl Into .map(|action| action.account_id) .collect(); let public_account_count = public_account_ids.len(); + // Every program invoked in a private transaction's call graph is publicly visible via this + // claim list — see `ProgramImageClaim`. + let programs_invoked_count = program_image_claims.len(); let WitnessSet { signatures_and_public_keys: _, proof, @@ -112,6 +116,10 @@ pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl Into "Timestamp Validity Window:" {timestamp_validity_window.to_string()} +
+ "Programs Invoked:" + {programs_invoked_count.to_string()} +

"Public Accounts"

diff --git a/lez/indexer/ffi/indexer_ffi.h b/lez/indexer/ffi/indexer_ffi.h index 417ca73cf..7be1d4ec7 100644 --- a/lez/indexer/ffi/indexer_ffi.h +++ b/lez/indexer/ffi/indexer_ffi.h @@ -260,12 +260,33 @@ typedef struct FfiVec_FfiPrivateAction { typedef struct FfiVec_FfiPrivateAction FfiPrivateActionList; +/** + * Program ID - 8 u32 values (32 bytes total). + */ +typedef struct FfiProgramId { + uint32_t data[8]; +} FfiProgramId; + +typedef struct FfiProgramImageClaim { + FfiAccountId account_id; + struct FfiProgramId image_id; +} FfiProgramImageClaim; + +typedef struct FfiVec_FfiProgramImageClaim { + struct FfiProgramImageClaim *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiProgramImageClaim; + +typedef struct FfiVec_FfiProgramImageClaim FfiProgramImageClaimList; + typedef struct FfiPrivacyPreservingMessage { FfiPublicActionList public_actions; FfiNonceList nonces; FfiPrivateActionList private_actions; uint64_t block_validity_window[2]; uint64_t timestamp_validity_window[2]; + FfiProgramImageClaimList program_image_claims; } FfiPrivacyPreservingMessage; typedef FfiVecU8 FfiProof; diff --git a/lez/indexer/ffi/src/api/types/transaction.rs b/lez/indexer/ffi/src/api/types/transaction.rs index 1dcf826bd..75a1c4c3d 100644 --- a/lez/indexer/ffi/src/api/types/transaction.rs +++ b/lez/indexer/ffi/src/api/types/transaction.rs @@ -2,17 +2,18 @@ use indexer_service_protocol::{ AccountId, Ciphertext, Commitment, CommitmentSetDigest, EncryptedAccountData, EphemeralPublicKey, HashType, Nullifier, PrivacyPreservingMessage, PrivacyPreservingTransaction, PrivateAction, ProgramDeploymentMessage, - ProgramDeploymentTransaction, Proof, PublicActionWithID, PublicKey, PublicMessage, - PublicTransaction, Signature, Transaction, ValidityWindow, WitnessSet, + ProgramDeploymentTransaction, ProgramImageClaim, Proof, PublicActionWithID, PublicKey, + PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow, WitnessSet, }; use crate::api::types::{ - FfiAccountId, FfiBytes32, FfiHashType, FfiOption, FfiPublicKey, FfiSignature, FfiVec, + FfiAccountId, FfiBytes32, FfiHashType, FfiOption, FfiProgramId, FfiPublicKey, FfiSignature, + FfiVec, account::FfiAccount, vectors::{ FfiAccountIdList, FfiInstructionDataList, FfiNonceList, FfiPrivateActionList, - FfiProgramDeploymentMessage, FfiProof, FfiPublicActionList, FfiSignaturePubKeyList, - FfiVecU8, + FfiProgramDeploymentMessage, FfiProgramImageClaimList, FfiProof, FfiPublicActionList, + FfiSignaturePubKeyList, FfiVecU8, }, }; @@ -193,6 +194,18 @@ impl From> for PrivacyPreservingTransaction { }) .collect() }, + program_image_claims: { + let std_vec: Vec<_> = value.message.program_image_claims.into(); + std_vec + .into_iter() + .map(|ffi_val| ProgramImageClaim { + account_id: AccountId { + value: ffi_val.account_id.data, + }, + image_id: ffi_val.image_id.data.into(), + }) + .collect() + }, block_validity_window: cast_ffi_validity_window( value.message.block_validity_window, ), @@ -238,6 +251,21 @@ impl From for FfiPublicAction { } } +#[repr(C)] +pub struct FfiProgramImageClaim { + pub account_id: FfiAccountId, + pub image_id: FfiProgramId, +} + +impl From for FfiProgramImageClaim { + fn from(value: ProgramImageClaim) -> Self { + Self { + account_id: value.account_id.into(), + image_id: value.image_id.into(), + } + } +} + #[repr(C)] pub struct FfiPrivateAction { pub nullifier: FfiBytes32, @@ -268,6 +296,7 @@ pub struct FfiPrivacyPreservingMessage { pub private_actions: FfiPrivateActionList, pub block_validity_window: [u64; 2], pub timestamp_validity_window: [u64; 2], + pub program_image_claims: FfiProgramImageClaimList, } impl From for FfiPrivacyPreservingMessage { @@ -278,6 +307,7 @@ impl From for FfiPrivacyPreservingMessage { private_actions, block_validity_window, timestamp_validity_window, + program_image_claims, } = value; Self { @@ -298,6 +328,11 @@ impl From for FfiPrivacyPreservingMessage { .into(), block_validity_window: cast_validity_window(block_validity_window), timestamp_validity_window: cast_validity_window(timestamp_validity_window), + program_image_claims: program_image_claims + .into_iter() + .map(Into::into) + .collect::>() + .into(), } } } diff --git a/lez/indexer/ffi/src/api/types/vectors.rs b/lez/indexer/ffi/src/api/types/vectors.rs index 4cccb949f..f0b46ebee 100644 --- a/lez/indexer/ffi/src/api/types/vectors.rs +++ b/lez/indexer/ffi/src/api/types/vectors.rs @@ -1,6 +1,9 @@ use crate::api::types::{ FfiAccountId, FfiNonce, FfiVec, - transaction::{FfiPrivateAction, FfiPublicAction, FfiSignaturePubKeyEntry, FfiTransaction}, + transaction::{ + FfiPrivateAction, FfiProgramImageClaim, FfiPublicAction, FfiSignaturePubKeyEntry, + FfiTransaction, + }, }; pub type FfiVecU8 = FfiVec; @@ -22,3 +25,5 @@ pub type FfiProgramDeploymentMessage = FfiVecU8; pub type FfiPublicActionList = FfiVec; pub type FfiPrivateActionList = FfiVec; + +pub type FfiProgramImageClaimList = FfiVec; diff --git a/lez/indexer/service/protocol/src/convert.rs b/lez/indexer/service/protocol/src/convert.rs index c4a3674f1..6d2963aaf 100644 --- a/lez/indexer/service/protocol/src/convert.rs +++ b/lez/indexer/service/protocol/src/convert.rs @@ -7,7 +7,8 @@ use crate::{ Commitment, CommitmentSetDigest, CrossZoneHalt, Data, EncryptedAccountData, EphemeralPublicKey, HashType, IndexerStatus, IndexerSyncState, Nullifier, PeerHealth, PeerStatus, PrivacyPreservingMessage, PrivacyPreservingTransaction, PrivateAction, - ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, Proof, PublicActionWithID, + ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, ProgramImageClaim, Proof, + PublicActionWithID, PublicKey, PublicMessage, PublicTransaction, Signature, StallReason, Transaction, ValidityWindow, WitnessSet, }; @@ -28,6 +29,24 @@ impl From for [u32; 8] { } } +impl From for ProgramImageClaim { + fn from(value: lee_core::ProgramImageClaim) -> Self { + Self { + account_id: value.account_id.into(), + image_id: value.image_id.into(), + } + } +} + +impl From for lee_core::ProgramImageClaim { + fn from(value: ProgramImageClaim) -> Self { + Self { + account_id: value.account_id.into(), + image_id: value.image_id.into(), + } + } +} + impl From for AccountId { fn from(value: lee_core::account::AccountId) -> Self { Self { @@ -308,6 +327,7 @@ impl From for PrivacyPres private_actions, block_validity_window, timestamp_validity_window, + program_image_claims, } = value; Self { public_actions: public_actions.into_iter().map(Into::into).collect(), @@ -315,6 +335,7 @@ impl From for PrivacyPres private_actions: private_actions.into_iter().map(Into::into).collect(), block_validity_window: block_validity_window.into(), timestamp_validity_window: timestamp_validity_window.into(), + program_image_claims: program_image_claims.into_iter().map(Into::into).collect(), } } } @@ -356,6 +377,7 @@ impl TryFrom for lee::privacy_preserving_transaction:: private_actions, block_validity_window, timestamp_validity_window, + program_image_claims, } = value; let public_actions = public_actions @@ -377,6 +399,7 @@ impl TryFrom for lee::privacy_preserving_transaction:: timestamp_validity_window: timestamp_validity_window .try_into() .map_err(|e| lee::error::LeeError::InvalidInput(format!("{e}")))?, + program_image_claims: program_image_claims.into_iter().map(Into::into).collect(), }) } } diff --git a/lez/indexer/service/protocol/src/lib.rs b/lez/indexer/service/protocol/src/lib.rs index 28bc84051..da9a6f8c1 100644 --- a/lez/indexer/service/protocol/src/lib.rs +++ b/lez/indexer/service/protocol/src/lib.rs @@ -250,6 +250,13 @@ pub struct PrivacyPreservingMessage { pub private_actions: Vec, pub block_validity_window: ValidityWindow, pub timestamp_validity_window: ValidityWindow, + pub program_image_claims: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct ProgramImageClaim { + pub account_id: AccountId, + pub image_id: ProgramId, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] diff --git a/lez/indexer/service/src/mock_service.rs b/lez/indexer/service/src/mock_service.rs index 9f24599e1..d8160ac45 100644 --- a/lez/indexer/service/src/mock_service.rs +++ b/lez/indexer/service/src/mock_service.rs @@ -409,6 +409,7 @@ fn mock_privacy_preserving_tx( }], block_validity_window: ValidityWindow((None, None)), timestamp_validity_window: ValidityWindow((None, None)), + program_image_claims: vec![], }, witness_set: WitnessSet { signatures_and_public_keys: vec![], diff --git a/lez/programs/program_loader/core/src/lib.rs b/lez/programs/program_loader/core/src/lib.rs index db3926a14..b28582d31 100644 --- a/lez/programs/program_loader/core/src/lib.rs +++ b/lez/programs/program_loader/core/src/lib.rs @@ -1,54 +1,71 @@ -pub use lee_core::program::PdaSeed; +pub use lee_core::program::{PdaSeed, ProgramData}; use lee_core::{ account::{Account, AccountId, AccountWithMetadata, Data}, program::{AccountPostState, Claim, ProgramId}, }; use serde::{Deserialize, Serialize}; -const DEPLOY_SEED_DOMAIN_SEPARATOR: AccountId = - AccountId::new(*b"/LEZ/v0.3/LoaderDeploySeed/00000"); +const DEPLOY_HEADER_SEED_DOMAIN_SEPARATOR: AccountId = + AccountId::new(*b"/LEZ/v0.3/LoaderDeployHeaderSeed"); +const DEPLOY_SEGMENT_SEED_DOMAIN_SEPARATOR: AccountId = + AccountId::new(*b"/LEZ/v0.3/LoaderDeploySegmentSee"); #[derive(Serialize, Deserialize)] pub enum Instruction { - /// Deploys a new program, claiming its `ProgramData` account as a PDA of the loader. + /// Deploys a new program: writes its `ProgramData` header and one bytecode segment, each + /// claimed as a PDA of the loader. /// - /// Required accounts (1): - /// - The target `ProgramData` PDA account (must be `Account::default()`) + /// Required accounts (2), in order: + /// - The target `ProgramData` header PDA account (must be `Account::default()`) + /// - The target segment PDA account holding the raw bytecode (must be `Account::default()`) Deploy { bytecode: Vec }, } -#[derive(Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)] -pub struct ProgramData { - pub image_id: ProgramId, - pub segment_number: u32, - pub update_auth: AccountId, - pub elf_segment: Vec, -} - -impl TryFrom<&Data> for ProgramData { - type Error = std::io::Error; - - fn try_from(data: &Data) -> Result { - borsh::BorshDeserialize::try_from_slice(data.as_ref()) - } -} - -impl From<&ProgramData> for Data { - fn from(program_data: &ProgramData) -> Self { - let mut data = Vec::with_capacity(std::mem::size_of_val(program_data)); - borsh::BorshSerialize::serialize(program_data, &mut data) - .expect("borsh serialization should not fail"); - Self::try_from(data).expect("elf must fit under DATA_MAX_LENGTH") - } -} - -/// Derives the PDA seed for a deployed program's `ProgramData` account. +/// Derives the PDA seed for a deployed program's `ProgramData` header account. /// -/// Domain-separated from other PDA-seed derivations in the codebase so that a `deploy_pda_seed` -/// output can never collide with a seed meant for a different purpose, even if the input triple -/// happened to coincide. +/// Combines the program's content-derived identity (`image_id`), its position in a (currently +/// always single-segment) split (`segment_number`), and who may redeploy it (`update_auth`). +/// +/// Domain-separated from other PDA-seed derivations in the codebase, including +/// [`deploy_segment_pda_seed`], so a header seed can never collide with a segment seed (or +/// anything else) even when the input triple coincides. #[must_use] -pub fn deploy_pda_seed( +pub fn deploy_header_pda_seed( + image_id: ProgramId, + segment_number: u32, + update_auth: AccountId, +) -> PdaSeed { + deploy_seed( + DEPLOY_HEADER_SEED_DOMAIN_SEPARATOR, + image_id, + segment_number, + update_auth, + ) +} + +/// Derives the PDA seed for a deployed program's bytecode segment account. +/// +/// Same inputs as [`deploy_header_pda_seed`], domain-separated so the two never collide. Kept as +/// a distinct account from the header specifically so that authenticating a program's identity +/// (e.g. for privacy-preserving proof verification) never has to touch its bytecode: the only +/// account-authentication primitive available is whole-account equality, so what's bundled into +/// one account sets the floor for how cheap that authentication can be. +#[must_use] +pub fn deploy_segment_pda_seed( + image_id: ProgramId, + segment_number: u32, + update_auth: AccountId, +) -> PdaSeed { + deploy_seed( + DEPLOY_SEGMENT_SEED_DOMAIN_SEPARATOR, + image_id, + segment_number, + update_auth, + ) +} + +fn deploy_seed( + domain_separator: AccountId, image_id: ProgramId, segment_number: u32, update_auth: AccountId, @@ -56,7 +73,7 @@ pub fn deploy_pda_seed( use risc0_zkvm::sha::{Impl, Sha256 as _}; let mut bytes = [0_u8; 32 + 32 + 4 + 32]; - bytes[0..32].copy_from_slice(DEPLOY_SEED_DOMAIN_SEPARATOR.as_ref()); + bytes[0..32].copy_from_slice(domain_separator.as_ref()); let image_id_bytes: &[u8] = bytemuck::try_cast_slice(&image_id).expect("ProgramId should be castable to &[u8]"); bytes[32..64].copy_from_slice(image_id_bytes); @@ -72,7 +89,7 @@ pub fn deploy_pda_seed( } #[must_use] -pub fn deploy_account_id( +pub fn deploy_header_account_id( loader_program_id: ProgramId, image_id: ProgramId, segment_number: u32, @@ -80,12 +97,27 @@ pub fn deploy_account_id( ) -> AccountId { AccountId::for_public_pda( &loader_program_id, - &deploy_pda_seed(image_id, segment_number, update_auth), + &deploy_header_pda_seed(image_id, segment_number, update_auth), + ) +} + +#[must_use] +pub fn deploy_segment_account_id( + loader_program_id: ProgramId, + image_id: ProgramId, + segment_number: u32, + update_auth: AccountId, +) -> AccountId { + AccountId::for_public_pda( + &loader_program_id, + &deploy_segment_pda_seed(image_id, segment_number, update_auth), ) } /// Executes the `Deploy` instruction: verifies `bytecode` decodes as a valid RISC0 program -/// binary, derives its `ProgramData` PDA, and claims it. +/// binary, derives its header and segment PDAs, and claims both. Called natively from +/// dispatch's `RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID` shortcut (see that constant's doc +/// comment in `lee_core::program`). #[must_use] pub fn execute_deploy( self_program_id: ProgramId, @@ -97,32 +129,54 @@ pub fn execute_deploy( .into(); let segment_number = 0_u32; let update_auth = AccountId::default(); - let seed = deploy_pda_seed(image_id, segment_number, update_auth); - let pda = AccountId::for_public_pda(&self_program_id, &seed); + let header_seed = deploy_header_pda_seed(image_id, segment_number, update_auth); + let segment_seed = deploy_segment_pda_seed(image_id, segment_number, update_auth); + let header_pda = AccountId::for_public_pda(&self_program_id, &header_seed); + let segment_pda = AccountId::for_public_pda(&self_program_id, &segment_seed); - let [target] = pre_states + let [header_target, segment_target] = pre_states .try_into() - .expect("Deploy requires exactly 1 account"); + .expect("Deploy requires exactly 2 accounts"); - assert_eq!(target.account_id, pda, "wrong deployment target account"); assert_eq!( - target.account, + header_target.account_id, header_pda, + "wrong deployment header target account" + ); + assert_eq!( + header_target.account, Account::default(), - "program already deployed" + "program header already deployed" + ); + assert_eq!( + segment_target.account_id, segment_pda, + "wrong deployment segment target account" + ); + assert_eq!( + segment_target.account, + Account::default(), + "program segment already deployed" ); let program_data = ProgramData { image_id, segment_number, update_auth, - elf_segment: bytecode, }; - vec![AccountPostState::new_claimed( - Account { - data: Data::from(&program_data), - ..Account::default() - }, - Claim::Pda(seed), - )] + vec![ + AccountPostState::new_claimed( + Account { + data: Data::from(&program_data), + ..Account::default() + }, + Claim::Pda(header_seed), + ), + AccountPostState::new_claimed( + Account { + data: Data::try_from(bytecode).expect("elf must fit under DATA_MAX_LENGTH"), + ..Account::default() + }, + Claim::Pda(segment_seed), + ), + ] } diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 8f33ad52a..166486eb7 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -1672,8 +1672,12 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { let clock_chain_caller = test_programs::clock_chain_caller(); // Deploy the clock_chain_caller test program. - let deploy_tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(clock_chain_caller.elf().to_owned()), + let bytecode = clock_chain_caller.elf().to_vec(); + let (clock_chain_caller_header, clock_chain_caller_segment) = deploy_targets(&bytecode); + let deploy_tx = LeeTransaction::Public(deploy_transaction( + clock_chain_caller_header, + clock_chain_caller_segment, + bytecode, )); mempool_handle .push((TransactionOrigin::User, deploy_tx)) @@ -1684,12 +1688,11 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { // Build a user transaction that invokes clock_chain_caller, which in turn chain-calls the // clock program with the clock accounts. The sequencer should detect that the resulting // state diff modifies clock accounts and drop the transaction. - let clock_chain_caller_id = test_programs::clock_chain_caller().id(); let clock_program_id = programs::clock().id(); let timestamp: u64 = 0; let message = lee::public_transaction::Message::try_new( - clock_chain_caller_id.into(), + clock_chain_caller_header, system_accounts::clock_account_ids().to_vec(), vec![], // no signers (clock_program_id, timestamp), @@ -3666,11 +3669,26 @@ fn the_bootstrap_sequencer_can_request_an_unstake_of_its_genesis_stake() { ); } -fn deploy_transaction(target: AccountId, bytecode: Vec) -> PublicTransaction { +/// Derives the `(header, segment)` account pair `bytecode` would deploy to. +fn deploy_targets(bytecode: &[u8]) -> (AccountId, AccountId) { + let loader_id: ProgramId = RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID.into(); + let image_id: ProgramId = risc0_binfmt::compute_image_id(bytecode).unwrap().into(); + let header = + program_loader_core::deploy_header_account_id(loader_id, image_id, 0, AccountId::default()); + let segment = + program_loader_core::deploy_segment_account_id(loader_id, image_id, 0, AccountId::default()); + (header, segment) +} + +fn deploy_transaction( + header: AccountId, + segment: AccountId, + bytecode: Vec, +) -> PublicTransaction { let loader_id: ProgramId = RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID.into(); let message = lee::public_transaction::Message::try_new( loader_id.into(), - vec![target], + vec![header, segment], vec![], program_loader_core::Instruction::Deploy { bytecode }, ) @@ -3681,51 +3699,86 @@ fn deploy_transaction(target: AccountId, bytecode: Vec) -> PublicTransaction #[test] fn loader_deploys_program() { - let loader_id: ProgramId = RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID.into(); let mut state = V03State::new(); let bytecode = test_programs::claimer().elf().to_vec(); let image_id: ProgramId = risc0_binfmt::compute_image_id(&bytecode).unwrap().into(); - let target = - program_loader_core::deploy_account_id(loader_id, image_id, 0, AccountId::default()); + let (header, segment) = deploy_targets(&bytecode); - assert_eq!(state.get_account_by_id(target), Account::default()); + assert_eq!(state.get_account_by_id(header), Account::default()); + assert_eq!(state.get_account_by_id(segment), Account::default()); - let tx = deploy_transaction(target, bytecode.clone()); + let tx = deploy_transaction(header, segment, bytecode.clone()); state .transition_from_public_transaction(&tx, 1, 0) - .expect("Deploy should succeed against an unclaimed target"); + .expect("Deploy should succeed against unclaimed targets"); - let deployed = state.get_account_by_id(target); + let deployed_header = state.get_account_by_id(header); assert_eq!( - deployed.program_owner, + deployed_header.program_owner, RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID ); - - let program_data = program_loader_core::ProgramData::try_from(&deployed.data) - .expect("deployed account data should decode as ProgramData"); + let program_data = program_loader_core::ProgramData::try_from(&deployed_header.data) + .expect("deployed header account data should decode as ProgramData"); assert_eq!(program_data.image_id, image_id); assert_eq!(program_data.segment_number, 0); assert_eq!(program_data.update_auth, AccountId::default()); - assert_eq!(program_data.elf_segment, bytecode); + + let deployed_segment = state.get_account_by_id(segment); + assert_eq!( + deployed_segment.program_owner, + RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID + ); + assert_eq!(deployed_segment.data.to_vec(), bytecode); +} + +/// A `Deploy`-created program must be a fully ordinary dispatch target afterward: `get_program` +/// has to find it by decoding the `ProgramData` header and locating its separate segment account, +/// and dispatch has to actually execute it. +#[test] +fn loader_deployed_program_is_invocable_via_dispatch() { + let mut state = V03State::new(); + + let bytecode = test_programs::claimer().elf().to_vec(); + let (header, segment) = deploy_targets(&bytecode); + + let tx = deploy_transaction(header, segment, bytecode); + state + .transition_from_public_transaction(&tx, 1, 0) + .expect("Deploy should succeed against unclaimed targets"); + + // `claimer` claims its one pre_state account with `Claim::Authorized`, which requires the + // account to be signed for and to start out default-owned. + let key = PrivateKey::try_new([7; 32]).unwrap(); + let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); + state.force_insert_account(account_id, Account::default()); + + let message = + lee::public_transaction::Message::try_new(header, vec![account_id], vec![Nonce(0)], ()) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[&key]); + let invoke_tx = PublicTransaction::new(message, witness_set); + + state + .transition_from_public_transaction(&invoke_tx, 2, 0) + .expect("a Deploy-created program must be dispatchable and executable"); + + assert_eq!(state.get_account_by_id(account_id).program_owner, header); } #[test] fn loader_rejects_redeploying_an_already_deployed_program() { - let loader_id: ProgramId = RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID.into(); let mut state = V03State::new(); let bytecode = test_programs::claimer().elf().to_vec(); - let image_id: ProgramId = risc0_binfmt::compute_image_id(&bytecode).unwrap().into(); - let target = - program_loader_core::deploy_account_id(loader_id, image_id, 0, AccountId::default()); + let (header, segment) = deploy_targets(&bytecode); - let tx = deploy_transaction(target, bytecode.clone()); + let tx = deploy_transaction(header, segment, bytecode.clone()); state .transition_from_public_transaction(&tx, 1, 0) .expect("First deploy should succeed"); - let tx = deploy_transaction(target, bytecode); + let tx = deploy_transaction(header, segment, bytecode); let result = state.transition_from_public_transaction(&tx, 2, 0); assert!( @@ -3738,12 +3791,13 @@ fn loader_rejects_redeploying_an_already_deployed_program() { fn loader_rejects_invalid_bytecode() { let mut state = V03State::new(); - // execute_deploy panics on compute_image_id before it ever looks at the target account, so - // any account works here. + // execute_deploy panics on compute_image_id before it ever looks at the target accounts, so + // any accounts work here. let bytecode = b"this is not a valid RISC0 program binary".to_vec(); - let target = AccountId::new([7; 32]); + let header = AccountId::new([7; 32]); + let segment = AccountId::new([8; 32]); - let tx = deploy_transaction(target, bytecode); + let tx = deploy_transaction(header, segment, bytecode); let result = state.transition_from_public_transaction(&tx, 1, 0); assert!( @@ -3757,10 +3811,11 @@ fn loader_rejects_wrong_target_account() { let mut state = V03State::new(); let bytecode = test_programs::claimer().elf().to_vec(); + let (_correct_header, segment) = deploy_targets(&bytecode); // Deliberately not the PDA this bytecode's image_id would derive to. - let wrong_target = AccountId::new([7; 32]); + let wrong_header = AccountId::new([7; 32]); - let tx = deploy_transaction(wrong_target, bytecode); + let tx = deploy_transaction(wrong_header, segment, bytecode); let result = state.transition_from_public_transaction(&tx, 1, 0); assert!( @@ -3775,14 +3830,12 @@ fn loader_rejects_wrong_number_of_accounts() { let mut state = V03State::new(); let bytecode = test_programs::claimer().elf().to_vec(); - let image_id: ProgramId = risc0_binfmt::compute_image_id(&bytecode).unwrap().into(); - let target = - program_loader_core::deploy_account_id(loader_id, image_id, 0, AccountId::default()); + let (header, segment) = deploy_targets(&bytecode); let extra = AccountId::new([9; 32]); let message = lee::public_transaction::Message::try_new( loader_id.into(), - vec![target, extra], + vec![header, segment, extra], vec![], program_loader_core::Instruction::Deploy { bytecode }, ) @@ -3821,8 +3874,7 @@ fn loader_deploys_program_via_chained_call() { let bytecode = test_programs::claimer().elf().to_vec(); let image_id: ProgramId = risc0_binfmt::compute_image_id(&bytecode).unwrap().into(); - let target = - program_loader_core::deploy_account_id(loader_id, image_id, 0, AccountId::default()); + let (header, segment) = deploy_targets(&bytecode); let inner_instruction_data = lee::program::Program::serialize_instruction(program_loader_core::Instruction::Deploy { @@ -3832,7 +3884,7 @@ fn loader_deploys_program_via_chained_call() { let message = lee::public_transaction::Message::try_new( forwarder.id().into(), - vec![target], + vec![header, segment], vec![], (loader_id, inner_instruction_data), ) @@ -3844,14 +3896,16 @@ fn loader_deploys_program_via_chained_call() { .transition_from_public_transaction(&tx, 1, 0) .expect("Deploy via chained call should succeed"); - let deployed = state.get_account_by_id(target); + let deployed_header = state.get_account_by_id(header); assert_eq!( - deployed.program_owner, + deployed_header.program_owner, RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID ); - let program_data = program_loader_core::ProgramData::try_from(&deployed.data) - .expect("deployed account data should decode as ProgramData"); + let program_data = program_loader_core::ProgramData::try_from(&deployed_header.data) + .expect("deployed header account data should decode as ProgramData"); assert_eq!(program_data.image_id, image_id); - assert_eq!(program_data.elf_segment, bytecode); + + let deployed_segment = state.get_account_by_id(segment); + assert_eq!(deployed_segment.data.to_vec(), bytecode); } diff --git a/lez/wallet-ffi/src/generic_transaction.rs b/lez/wallet-ffi/src/generic_transaction.rs index 6420e5e81..61c0e3347 100644 --- a/lez/wallet-ffi/src/generic_transaction.rs +++ b/lez/wallet-ffi/src/generic_transaction.rs @@ -88,13 +88,10 @@ impl TryFrom<&FfiProgramWithDependencies> for ProgramWithDependencies { .ok_or(WalletFfiError::NullPointer)? .try_into()?; - program_map.insert(program_dep.id(), program_dep); + program_map.insert(program_dep.id().into(), program_dep); } - Ok(Self { - program: orig_program, - dependencies: program_map, - }) + Ok(Self::new(orig_program, program_map)) } } diff --git a/lez/wallet/src/program_facades/ata.rs b/lez/wallet/src/program_facades/ata.rs index 6c9ec5688..c9b66feca 100644 --- a/lez/wallet/src/program_facades/ata.rs +++ b/lez/wallet/src/program_facades/ata.rs @@ -223,6 +223,6 @@ impl Ata<'_> { fn ata_with_token_dependency() -> ProgramWithDependencies { let token = programs::token(); let mut deps = HashMap::new(); - deps.insert(token.id(), token); + deps.insert(token.id().into(), token); ProgramWithDependencies::new(programs::ata(), deps) } diff --git a/lez/wallet/src/program_facades/vault.rs b/lez/wallet/src/program_facades/vault.rs index dcb5c5e03..d809db60f 100644 --- a/lez/wallet/src/program_facades/vault.rs +++ b/lez/wallet/src/program_facades/vault.rs @@ -132,6 +132,6 @@ impl Vault<'_> { fn vault_with_auth_dependency() -> ProgramWithDependencies { let auth_transfer = programs::authenticated_transfer(); let mut deps = HashMap::new(); - deps.insert(auth_transfer.id(), auth_transfer); + deps.insert(auth_transfer.id().into(), auth_transfer); ProgramWithDependencies::new(programs::vault(), deps) } diff --git a/tools/cycle_bench/src/ppe/ppe_impl.rs b/tools/cycle_bench/src/ppe/ppe_impl.rs index 8023e21bd..ea39d7f79 100644 --- a/tools/cycle_bench/src/ppe/ppe_impl.rs +++ b/tools/cycle_bench/src/ppe/ppe_impl.rs @@ -109,7 +109,7 @@ fn prove_chain_caller( let auth_transfer = programs::authenticated_transfer(); let auth_transfer_id = auth_transfer.id(); let mut deps = HashMap::new(); - deps.insert(auth_transfer.id(), auth_transfer); + deps.insert(auth_transfer.id().into(), auth_transfer); let pwd = ProgramWithDependencies::new(chain_caller, deps); // Both accounts pre-claimed by auth_transfer. chain_caller doesn't