feat(lee): add loader program with native Deploy dispatch fast-path

Introduces a new loader_program/loader_core crate pair implementing a
Deploy instruction that claims a program's ProgramData PDA account
(image_id, segment_number, update_auth, elf_segment), unifying
deployment with ordinary PublicTransaction dispatch instead of the
separate ProgramDeploymentTransaction path.

Measured against every real program in this repo, computing a
program's image_id inside the zkVM costs ~1,400-1,500 cycles per byte
of deployed bytecode, pushing real deployments to 500M-900M cycles
against the 32M public-execution cap (vs. ~27ms natively, since
ProgramDeploymentTransaction's equivalent check runs as a plain host
function today). To keep the unified dispatch path viable, Deploy is
special-cased in from_public_transaction: calls targeting the reserved
RESERVED_DEPLOYMENT_PROGRAM_ACCOUNT_ID run loader_core::execute_deploy
natively instead of through the interpreted guest executor, wrapped in
catch_unwind since the shared execute_deploy logic validates via
assert!/expect() like every other guest program, relying on that
boundary instead of the zkVM's own panic-to-Result conversion.

The loader guest binary is kept buildable and covered by a test that
runs it for real and asserts its output matches the native path
exactly, so the two can't silently drift apart.
This commit is contained in:
Marvin Jones
2026-08-22 17:39:13 -04:00
parent 912c0982ce
commit dcc0c4b950
15 changed files with 458 additions and 17 deletions
Generated
+24
View File
@@ -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",
+5
View File
@@ -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"
Binary file not shown.
+1
View File
@@ -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
+20
View File
@@ -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<AccountId> 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];
+40 -1
View File
@@ -57,10 +57,25 @@ impl Program {
caller_account_id: Option<AccountId>,
pre_states: &[AccountWithMetadata],
instruction_data: &InstructionData,
) -> Result<ProgramOutput, LeeError> {
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<ProgramId>,
pre_states: &[AccountWithMetadata],
instruction_data: &InstructionData,
session_limit: u64,
) -> Result<ProgramOutput, LeeError> {
// 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<ProgramId>,
pre_states: &[AccountWithMetadata],
instruction_data: &InstructionData,
) -> Result<ProgramOutput, LeeError> {
self.execute_with_session_limit(
caller_program_id,
pre_states,
instruction_data,
MAX_NUM_CYCLES_PUBLIC_EXECUTION * 64,
)
}
}
#[cfg(test)]
mod tests;
@@ -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
+7
View File
@@ -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 }
+9
View File
@@ -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
+16
View File
@@ -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
+135
View File
@@ -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<u8> },
}
#[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<u8>,
}
impl TryFrom<&Data> for ProgramData {
type Error = std::io::Error;
fn try_from(data: &Data) -> Result<Self, Self::Error> {
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<AccountWithMetadata>,
bytecode: Vec<u8>,
) -> Vec<AccountPostState> {
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),
)]
}
+31
View File
@@ -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::<Instruction>();
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();
}
+11 -3
View File
@@ -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();
+2
View File
@@ -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
+111 -1
View File
@@ -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<u8>) -> 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);
}