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.
This commit is contained in:
Marvin Jones
2026-08-22 19:38:51 -04:00
parent 3c1e30f2cf
commit 152152ccca
42 changed files with 954 additions and 495 deletions
Generated
+3
View File
@@ -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",
@@ -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<u8> = std::fs::read(hello_world_path).unwrap();
let hello_world = Program::new(hello_world_bytecode.into()).unwrap();
let dependencies: HashMap<ProgramId, Program> =
std::iter::once((hello_world.id(), hello_world)).collect();
let dependencies: HashMap<AccountId, Program> =
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)];
+3
View File
@@ -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
+56 -1
View File
@@ -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<u
)
})?
}
/// Derives the `(header, segment)` account pair `bytecode` would deploy to via `Deploy`, mirroring
/// `sequencer_core`'s private test helper of the same name.
#[must_use]
pub fn deploy_targets(bytecode: &[u8]) -> (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<u8>,
) -> 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<u32>`), and RISC0's
/// word-oriented serde doesn't pack `Vec<u8>` 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")
}
@@ -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))?;
@@ -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),
+55 -48
View File
@@ -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<lee::ProgramId> {
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::<loader_core::Instruction, u32>(
&public_tx.message.instruction_data,
)
.ok()?;
Program::new(bytecode.into()).ok().map(|p| p.id())
})
.collect()
};
+2 -2
View File
@@ -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(),
),
]
+1 -1
View File
@@ -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);
@@ -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<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
/// The set containing non-PDA accounts authorized at their first sight, anywhere in the
/// call tree, remaining authorized throughout all calls.
globally_authorized: HashSet<AccountId>,
authorized_accounts: HashSet<AccountId>,
}
impl ExecutionState {
@@ -62,6 +61,7 @@ impl ExecutionState {
account_identities: &[InputAccountIdentity],
program_id: ProgramId,
program_outputs: Vec<ProgramOutput>,
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<ProgramId>,
caller_pda_seeds: &[PdaSeed],
output_pre_states: Vec<AccountWithMetadata>,
output_post_states: Vec<AccountPostState>,
) -> HashSet<AccountId> {
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<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
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<AccountId>,
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<usize, (ProgramId, PdaSeed)>,
private_pda_by_position: &HashMap<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
globally_authorized: &HashSet<AccountId>,
caller: &CallerData,
caller_pda_seeds: &[PdaSeed],
authorized_accounts: &mut HashSet<AccountId>,
pre_account_id: AccountId,
pre_state_position: usize,
pre_is_authorized: bool,
) {
caller_image_id: Option<ProgramId>,
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
}
+5 -1
View File
@@ -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);
}
@@ -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!(
+28 -1
View File
@@ -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<DummyInput>,
/// 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<ProgramImageClaim>,
}
#[derive(Serialize, Deserialize, Clone)]
@@ -169,6 +189,9 @@ pub struct PrivacyPreservingCircuitOutput {
pub private_actions: Vec<PrivateAction>,
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<ProgramImageClaim>,
}
#[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();
+2 -1
View File
@@ -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,
+59 -19
View File
@@ -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<AccountId, Account>` 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<Self, Self::Error> {
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<ProgramId> for AccountId {
fn from(program_id: ProgramId) -> Self {
let bytes: Vec<u8> = program_id
@@ -244,12 +295,6 @@ impl AccountId {
}
}
#[derive(Debug)]
pub struct CallerData {
pub caller_account_id: Option<AccountId>,
pub authorized_accounts: HashSet<AccountId>,
}
#[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<AccountId>,
caller_image_id: Option<ProgramId>,
pda_seeds: &[PdaSeed],
) -> HashSet<AccountId> {
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))
+1 -1
View File
@@ -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);
+3 -9
View File
@@ -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"
@@ -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<ProgramId, Program>,
pub dependencies: HashMap<AccountId, Program>,
}
impl ProgramWithDependencies {
#[must_use]
pub const fn new(program: Program, dependencies: HashMap<ProgramId, Program>) -> Self {
pub fn new(program: Program, dependencies: HashMap<AccountId, Program>) -> 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<Program> 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<ProgramImageClaim> =
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<AccountId>,
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,
@@ -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],
@@ -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<PrivateAction>,
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<ProgramImageClaim>,
}
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<u8> = [
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;
+2 -1
View File
@@ -54,6 +54,7 @@ impl Program {
pub(crate) fn execute(
&self,
self_account_id: AccountId,
caller_account_id: Option<AccountId>,
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,
+6 -1
View File
@@ -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();
+81 -11
View File
@@ -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<AccountId, Account>,
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<u8>)> {
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]
+11 -9
View File
@@ -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<bool>)> = 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<bool>)> = 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();
@@ -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());
@@ -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
@@ -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<Self, LeeError> {
// 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<AccountId>,
/// 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<ProgramId>,
authorized_accounts: HashSet<AccountId>,
}
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::<Result<Vec<_>, 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)
@@ -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`.
+1 -1
View File
@@ -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)
}
}
}
@@ -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
<span class="info-label">"Timestamp Validity Window:"</span>
<span class="info-value">{timestamp_validity_window.to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Programs Invoked:"</span>
<span class="info-value">{programs_invoked_count.to_string()}</span>
</div>
</div>
<h3>"Public Accounts"</h3>
+21
View File
@@ -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;
+40 -5
View File
@@ -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<Box<FfiPrivateTransactionBody>> 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<PublicActionWithID> for FfiPublicAction {
}
}
#[repr(C)]
pub struct FfiProgramImageClaim {
pub account_id: FfiAccountId,
pub image_id: FfiProgramId,
}
impl From<ProgramImageClaim> 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<PrivacyPreservingMessage> for FfiPrivacyPreservingMessage {
@@ -278,6 +307,7 @@ impl From<PrivacyPreservingMessage> for FfiPrivacyPreservingMessage {
private_actions,
block_validity_window,
timestamp_validity_window,
program_image_claims,
} = value;
Self {
@@ -298,6 +328,11 @@ impl From<PrivacyPreservingMessage> 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::<Vec<_>>()
.into(),
}
}
}
+6 -1
View File
@@ -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<u8>;
@@ -22,3 +25,5 @@ pub type FfiProgramDeploymentMessage = FfiVecU8;
pub type FfiPublicActionList = FfiVec<FfiPublicAction>;
pub type FfiPrivateActionList = FfiVec<FfiPrivateAction>;
pub type FfiProgramImageClaimList = FfiVec<FfiProgramImageClaim>;
+24 -1
View File
@@ -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<ProgramId> for [u32; 8] {
}
}
impl From<lee_core::ProgramImageClaim> for ProgramImageClaim {
fn from(value: lee_core::ProgramImageClaim) -> Self {
Self {
account_id: value.account_id.into(),
image_id: value.image_id.into(),
}
}
}
impl From<ProgramImageClaim> for lee_core::ProgramImageClaim {
fn from(value: ProgramImageClaim) -> Self {
Self {
account_id: value.account_id.into(),
image_id: value.image_id.into(),
}
}
}
impl From<lee_core::account::AccountId> for AccountId {
fn from(value: lee_core::account::AccountId) -> Self {
Self {
@@ -308,6 +327,7 @@ impl From<lee::privacy_preserving_transaction::message::Message> 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<lee::privacy_preserving_transaction::message::Message> 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<PrivacyPreservingMessage> 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<PrivacyPreservingMessage> 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(),
})
}
}
+7
View File
@@ -250,6 +250,13 @@ pub struct PrivacyPreservingMessage {
pub private_actions: Vec<PrivateAction>,
pub block_validity_window: ValidityWindow,
pub timestamp_validity_window: ValidityWindow,
pub program_image_claims: Vec<ProgramImageClaim>,
}
#[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)]
+1
View File
@@ -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![],
+109 -55
View File
@@ -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<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.
/// 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),
),
]
}
+96 -42
View File
@@ -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<u8>) -> 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<u8>,
) -> 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<u8>) -> 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);
}
+2 -5
View File
@@ -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))
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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