diff --git a/Cargo.lock b/Cargo.lock index 30cbd2a02..0ceb14b6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5595,6 +5595,7 @@ dependencies = [ "hex-literal 1.1.0", "k256 0.13.4", "lee_core", + "loader_core", "log", "rand 0.8.6", "risc0-binfmt", @@ -6365,6 +6366,26 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "loader_core" +version = "0.1.0" +dependencies = [ + "borsh", + "bytemuck", + "lee_core", + "risc0-binfmt", + "risc0-zkvm", + "serde", +] + +[[package]] +name = "loader_program" +version = "0.1.0" +dependencies = [ + "lee_core", + "loader_core", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -9421,6 +9442,7 @@ dependencies = [ "faucet_core", "lee", "lee_core", + "loader_core", "ping_core", "risc0-zkvm", "sequencer_stake_core", @@ -10974,6 +10996,7 @@ dependencies = [ "lee", "lee_core", "libp2p", + "loader_core", "log", "logos-blockchain-core", "logos-blockchain-http-api-common", @@ -10984,6 +11007,7 @@ dependencies = [ "ping_core", "programs", "rand 0.8.6", + "risc0-binfmt", "risc0-zkvm", "sequencer_core_metrics", "sequencer_stake_core", diff --git a/Cargo.toml b/Cargo.toml index e6d6c9b6c..7506ed536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,8 @@ members = [ "lez/programs/ping_sender", "lez/programs/ping_receiver", "lez/programs/sequencer_stake", + "lez/programs/loader", + "lez/programs/loader/core", "lez/cross_zone", "test_programs", @@ -124,6 +126,8 @@ bridge_lock_core = { path = "lez/programs/bridge_lock/core" } wrapped_token_core = { path = "lez/programs/wrapped_token/core" } ping_core = { path = "lez/programs/ping_core" } sequencer_stake_core = { path = "lez/programs/sequencer_stake/core" } +loader_core = { path = "lez/programs/loader/core" } +loader_program = { path = "lez/programs/loader" } cross_zone = { path = "lez/cross_zone" } build_utils = { path = "build_utils" } test_programs = { path = "test_programs" } @@ -141,6 +145,7 @@ tokio-util = "0.7.18" async-trait = "0.1" trait-variant = "0.1.2" risc0-zkvm = { version = "3.0.5", default-features = false, features = ['std'] } +risc0-binfmt = "3.0.2" risc0-build = "3.0.5" kameo = "0.22.2" kameo_actors = "0.8.1" diff --git a/artifacts/lez/programs/loader.bin b/artifacts/lez/programs/loader.bin new file mode 100644 index 000000000..5d9679ec5 Binary files /dev/null and b/artifacts/lez/programs/loader.bin differ diff --git a/lee/state_machine/Cargo.toml b/lee/state_machine/Cargo.toml index df43da8df..ac6b4c090 100644 --- a/lee/state_machine/Cargo.toml +++ b/lee/state_machine/Cargo.toml @@ -22,6 +22,7 @@ hex.workspace = true k256.workspace = true risc0-binfmt = "3.0.2" log.workspace = true +loader_core.workspace = true [build-dependencies] build_utils.workspace = true diff --git a/lee/state_machine/core/src/program/mod.rs b/lee/state_machine/core/src/program/mod.rs index 4fbb325e4..2043321a1 100644 --- a/lee/state_machine/core/src/program/mod.rs +++ b/lee/state_machine/core/src/program/mod.rs @@ -19,6 +19,26 @@ pub const DEFAULT_PROGRAM_OWNER: AccountId = AccountId::new([0; 32]); /// `program_owner` for program `Account`s. pub const PROGRAM_STORAGE_OWNER: AccountId = AccountId::new([0xFF; 32]); +/// Reserved `AccountId` for the native "Deploy" dispatch shortcut. +/// +/// `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 +/// targeting this address converts it to the `ProgramId` a `Message`/`ChainedCall` expects via +/// the existing `From for ProgramId` bijection. +pub const RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID: AccountId = AccountId::new([ + 89, 158, 44, 108, 43, 137, 255, 57, 188, 48, 148, 179, 39, 111, 31, 202, 167, 23, 56, 0, 167, + 29, 152, 150, 161, 186, 155, 209, 69, 138, 145, 201, +]); + pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; pub type ProgramId = [u32; 8]; diff --git a/lee/state_machine/src/program/mod.rs b/lee/state_machine/src/program/mod.rs index ab3cf69cf..abc4f79c5 100644 --- a/lee/state_machine/src/program/mod.rs +++ b/lee/state_machine/src/program/mod.rs @@ -57,10 +57,25 @@ impl Program { caller_account_id: Option, pre_states: &[AccountWithMetadata], instruction_data: &InstructionData, + ) -> Result { + self.execute_with_session_limit( + caller_program_id, + pre_states, + instruction_data, + MAX_NUM_CYCLES_PUBLIC_EXECUTION, + ) + } + + fn execute_with_session_limit( + &self, + caller_program_id: Option, + pre_states: &[AccountWithMetadata], + instruction_data: &InstructionData, + session_limit: u64, ) -> Result { // Write inputs to the program let mut env_builder = ExecutorEnv::builder(); - env_builder.session_limit(Some(MAX_NUM_CYCLES_PUBLIC_EXECUTION)); + env_builder.session_limit(Some(session_limit)); Self::write_inputs( AccountId::from(self.id), caller_account_id, @@ -110,5 +125,29 @@ impl Program { } } +#[cfg(feature = "test-utils")] +impl Program { + /// Test-only: like `execute`, but with a session limit far above the production + /// `MAX_NUM_CYCLES_PUBLIC_EXECUTION` cap. + /// + /// Exists so tests can run a real, possibly large guest program to completion — e.g. + /// comparing the loader guest's actual execution against its native dispatch fast-path + /// (see `RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID`) — without hitting the budget that exists + /// specifically to bound production dispatch cost, which this is deliberately not testing. + pub fn execute_for_test( + &self, + caller_program_id: Option, + pre_states: &[AccountWithMetadata], + instruction_data: &InstructionData, + ) -> Result { + self.execute_with_session_limit( + caller_program_id, + pre_states, + instruction_data, + MAX_NUM_CYCLES_PUBLIC_EXECUTION * 64, + ) + } +} + #[cfg(test)] mod tests; diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs index 8c7725b69..b1a6677ab 100644 --- a/lee/state_machine/src/validated_state_diff/mod.rs +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -8,8 +8,8 @@ use lee_core::{ BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, PublicAction, Timestamp, account::{Account, AccountId, AccountWithMetadata}, program::{ - CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, ProgramId, - compute_public_authorized_pdas, validate_execution, + CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, ProgramId, ProgramOutput, + RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID, compute_public_authorized_pdas, validate_execution, }, }; use log::debug; @@ -112,16 +112,11 @@ impl ValidatedStateDiff { LeeError::MaxChainedCallsDepthExceeded ); - let Some(program_account) = state.get_program(chained_call.program_account_id) else { - return Err(LeeError::InvalidInput("Unknown program".into())); - }; // 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); - let program = - Program::new_unchecked(program_id, Cow::Owned(program_account.data.to_vec())); debug!( "Program {:?} pre_states: {:?}, instruction_data: {:?}", @@ -129,11 +124,50 @@ impl ValidatedStateDiff { chained_call.pre_states, chained_call.instruction_data ); - let mut program_output = program.execute( - caller_data.caller_account_id, - &chained_call.pre_states, - &chained_call.instruction_data, - )?; + + let 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 — see + // `RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID`'s doc comment for why. + // + // `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 loader_core::Instruction::Deploy { bytecode } = + risc0_zkvm::serde::from_slice(&chained_call.instruction_data).map_err(|e| { + LeeError::InvalidInput(format!("invalid Deploy instruction: {e}")) + })?; + let deploy_pre_states = chained_call.pre_states.clone(); + let post_states = std::panic::catch_unwind(|| { + loader_core::execute_deploy(program_id, deploy_pre_states, bytecode) + }) + .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, + ) + } else { + let Some(program_account) = 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, + )? + }; debug!( "Program {:?} output: {:?}", chained_call.program_account_id, program_output diff --git a/lez/programs/Cargo.toml b/lez/programs/Cargo.toml index 609e7d7ff..4b3e0e9e9 100644 --- a/lez/programs/Cargo.toml +++ b/lez/programs/Cargo.toml @@ -89,6 +89,11 @@ name = "sequencer_stake" path = "sequencer_stake/src/main.rs" required-features = ["programs"] +[[bin]] +name = "loader" +path = "loader/src/main.rs" +required-features = ["programs"] + [features] # TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved. # default = ["artifacts"] @@ -120,6 +125,7 @@ programs = [ "dep:wrapped_token_core", "dep:ping_core", "dep:sequencer_stake_core", + "dep:loader_core", ] [dependencies] @@ -141,6 +147,7 @@ bridge_lock_core = { workspace = true, optional = true } wrapped_token_core = { workspace = true, optional = true } ping_core = { workspace = true, optional = true } sequencer_stake_core = { workspace = true, optional = true } +loader_core = { workspace = true, optional = true } amm_program = { path = "amm", optional = true } associated_token_account_program = { path = "associated_token_account", optional = true } diff --git a/lez/programs/loader/Cargo.toml b/lez/programs/loader/Cargo.toml new file mode 100644 index 000000000..8b560c0fe --- /dev/null +++ b/lez/programs/loader/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "loader_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[dependencies] +lee_core.workspace = true +loader_core.workspace = true diff --git a/lez/programs/loader/core/Cargo.toml b/lez/programs/loader/core/Cargo.toml new file mode 100644 index 000000000..bb4479997 --- /dev/null +++ b/lez/programs/loader/core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "loader_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, default-features = false } +borsh.workspace = true +bytemuck.workspace = true +risc0-zkvm.workspace = true +risc0-binfmt.workspace = true diff --git a/lez/programs/loader/core/src/lib.rs b/lez/programs/loader/core/src/lib.rs new file mode 100644 index 000000000..daab23fa6 --- /dev/null +++ b/lez/programs/loader/core/src/lib.rs @@ -0,0 +1,135 @@ +pub use lee_core::program::PdaSeed; +use lee_core::{ + account::{Account, AccountId, AccountWithMetadata, Data}, + program::{AccountPostState, Claim, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const DEPLOY_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/LoaderDeploySeed/00000"; + +#[derive(Serialize, Deserialize)] +pub enum Instruction { + /// Deploys a new program, claiming its `ProgramData` account as a PDA of the loader. + /// + /// Required accounts (1): + /// - The target `ProgramData` PDA account (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. +/// +/// 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 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. +#[must_use] +pub fn deploy_pda_seed( + image_id: ProgramId, + segment_number: u32, + update_auth: AccountId, +) -> PdaSeed { + 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); + 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); + bytes[64..68].copy_from_slice(&segment_number.to_le_bytes()); + bytes[68..].copy_from_slice(update_auth.as_ref()); + + PdaSeed::new( + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("Hash output must be exactly 32 bytes long"), + ) +} + +#[must_use] +pub fn deploy_account_id( + loader_program_id: ProgramId, + image_id: ProgramId, + segment_number: u32, + update_auth: AccountId, +) -> AccountId { + AccountId::for_public_pda( + &loader_program_id, + &deploy_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. +/// +/// Shared, target-independent logic: called both from the guest binary (`loader_program`) and, +/// natively, from dispatch's `RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID` shortcut (see that +/// constant's doc comment in `lee_core::program`) — the two must stay identical, so this is the +/// single implementation both wrap around. +#[must_use] +pub fn execute_deploy( + self_program_id: ProgramId, + pre_states: Vec, + bytecode: Vec, +) -> Vec { + let image_id: ProgramId = risc0_binfmt::compute_image_id(&bytecode) + .expect("bytecode must decode as a valid RISC0 program binary") + .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 [target] = pre_states + .try_into() + .expect("Deploy requires exactly 1 account"); + + assert_eq!(target.account_id, pda, "wrong deployment target account"); + assert_eq!( + target.account, + Account::default(), + "program 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), + )] +} diff --git a/lez/programs/loader/src/main.rs b/lez/programs/loader/src/main.rs new file mode 100644 index 000000000..7d3c52ce2 --- /dev/null +++ b/lez/programs/loader/src/main.rs @@ -0,0 +1,31 @@ +use lee_core::program::{ProgramInput, ProgramOutput, read_lee_inputs}; +use loader_core::Instruction; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + let pre_states_clone = pre_states.clone(); + + let post_states = match instruction { + Instruction::Deploy { bytecode } => { + loader_core::execute_deploy(self_program_id, pre_states, bytecode) + } + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states_clone, + post_states, + ) + .write(); +} diff --git a/lez/programs/src/lib.rs b/lez/programs/src/lib.rs index 066c5f812..784b4e6cc 100644 --- a/lez/programs/src/lib.rs +++ b/lez/programs/src/lib.rs @@ -13,9 +13,10 @@ mod inner { AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, CLOCK_ID, CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID, - PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, PING_RECEIVER_ELF, - PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, SEQUENCER_STAKE_ELF, SEQUENCER_STAKE_ID, - TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID, + LOADER_ELF, LOADER_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, + PING_RECEIVER_ELF, PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, SEQUENCER_STAKE_ELF, + SEQUENCER_STAKE_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, WRAPPED_TOKEN_ELF, + WRAPPED_TOKEN_ID, }; use lee::program::Program; @@ -132,6 +133,12 @@ mod inner { Program::new_unchecked(SEQUENCER_STAKE_ID, Cow::Borrowed(SEQUENCER_STAKE_ELF)) } + #[must_use] + #[inline] + pub const fn loader() -> Program { + Program::new_unchecked(LOADER_ID, Cow::Borrowed(LOADER_ELF)) + } + #[cfg(test)] mod tests { use super::*; @@ -182,6 +189,7 @@ mod inner { (BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID), (WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID), (SEQUENCER_STAKE_ELF, SEQUENCER_STAKE_ID), + (LOADER_ELF, LOADER_ID), ]; for (elf, expected_id) in cases { let program = Program::new((*elf).into()).unwrap(); diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index c77592c38..e9350250b 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -70,3 +70,5 @@ lee = { workspace = true, features = ["test-utils"] } key_protocol.workspace = true token_core.workspace = true ping_core.workspace = true +loader_core.workspace = true +risc0-binfmt.workspace = true diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 9dd2aa5c3..da12eeb3e 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -12,7 +12,10 @@ use kameo::actor::Spawn as _; use lee::{ Account, AccountId, Data, PrivateKey, PublicKey, PublicTransaction, V03State, program::Program, }; -use lee_core::{account::Nonce, program::PdaSeed}; +use lee_core::{ + account::Nonce, + program::{PdaSeed, ProgramId, RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID}, +}; use logos_blockchain_core::{ events::DepositRecreatedNotes, mantle::{ @@ -3662,3 +3665,110 @@ fn the_bootstrap_sequencer_can_request_an_unstake_of_its_genesis_stake() { Some(system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE) ); } + +fn deploy_transaction(target: AccountId, bytecode: Vec) -> PublicTransaction { + let loader_id: ProgramId = RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID.into(); + let message = lee::public_transaction::Message::try_new( + loader_id, + vec![target], + vec![], + loader_core::Instruction::Deploy { bytecode }, + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[]); + PublicTransaction::new(message, witness_set) +} + +#[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 = loader_core::deploy_account_id(loader_id, image_id, 0, AccountId::default()); + + assert_eq!(state.get_account_by_id(target), Account::default()); + + let tx = deploy_transaction(target, bytecode.clone()); + state + .transition_from_public_transaction(&tx, 1, 0) + .expect("Deploy should succeed against an unclaimed target"); + + let deployed = state.get_account_by_id(target); + assert_eq!( + deployed.program_owner, + RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID + ); + + let program_data = loader_core::ProgramData::try_from(&deployed.data) + .expect("deployed 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); +} + +#[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 = loader_core::deploy_account_id(loader_id, image_id, 0, AccountId::default()); + + let tx = deploy_transaction(target, bytecode.clone()); + state + .transition_from_public_transaction(&tx, 1, 0) + .expect("First deploy should succeed"); + + let tx = deploy_transaction(target, bytecode); + let result = state.transition_from_public_transaction(&tx, 2, 0); + + assert!( + result.is_err(), + "Redeploying to an already-claimed program account should fail, but got: {result:?}" + ); +} + +/// Runs the real `loader_program` guest ELF end-to-end (via `Program::execute_for_test`, which +/// uses a session limit well above production's `MAX_NUM_CYCLES_PUBLIC_EXECUTION` since running +/// a real guest to completion for this comparison is the whole point) and checks its output +/// against calling `execute_deploy` natively with the same inputs. +/// +/// This doesn't re-verify `execute_deploy`'s own logic — the guest and the native dispatch path +/// both call that exact function, so it can't diverge between them. What this catches is drift +/// in the thin wrapper code on each side: the guest's `read_lee_inputs`/`ProgramOutput::write` +/// glue in `loader_program::main`, versus dispatch's manual `risc0_zkvm::serde::from_slice` and +/// `ProgramOutput::new(..)` construction in `from_public_transaction`. +#[test] +fn loader_native_execution_matches_real_guest_execution() { + let loader = programs::loader(); + let bytecode = test_programs::claimer().elf().to_vec(); + + let image_id: ProgramId = risc0_binfmt::compute_image_id(&bytecode).unwrap().into(); + let target = loader_core::deploy_account_id(loader.id(), image_id, 0, AccountId::default()); + + let pre_states = vec![lee_core::account::AccountWithMetadata::new( + Account::default(), + false, + target, + )]; + let instruction_data = + lee::program::Program::serialize_instruction(loader_core::Instruction::Deploy { + bytecode: bytecode.clone(), + }) + .unwrap(); + + let guest_output = loader + .execute_for_test(None, &pre_states, &instruction_data) + .expect("real guest execution should succeed"); + + let native_post_states = loader_core::execute_deploy(loader.id(), pre_states.clone(), bytecode); + + assert_eq!(guest_output.self_program_id, loader.id()); + assert_eq!(guest_output.caller_program_id, None); + assert_eq!(guest_output.pre_states, pre_states); + assert_eq!(guest_output.post_states, native_post_states); +}