feat(lee): fold program storage into public_state, drop the separate programs map

V03State.programs is gone; deployed programs now live directly in
public_state, keyed by AccountId::from(program_id) same as any other
account. insert_program sets program_owner to a new reserved sentinel,
PROGRAM_STORAGE_OWNER, instead of leaving it at the default.

That ownership choice is load-bearing now in a way it wasn't before:
once program accounts share the same map as everything else, they're
reachable through ordinary dispatch, so program_owner determines
whether they're claimable/writable. Left unclaimed, a program
invocation could legitimately claim a program's storage account via
the normal claim path and then rewrite its elf; self-ownership has
the same flaw, since it authorizes exactly the program whose own
invocation would touch its own storage account. The reserved sentinel
makes every program account unwritable by construction, since no real
chained_call.program_id will ever derive to it.

programs() is removed; dispatch and the deployment-existence check go
through get_account_by_id_ref like any other account lookup.
genesis_fingerprint drops its separate program-hashing loop, since
program accounts now fall out of the existing public_state loop.

Rebuilt all guest artifacts and the test fixture via just
build-artifacts as a precaution, since V03State's Borsh shape changed
even though Account's did not.
This commit is contained in:
Marvin Jones
2026-08-13 21:04:01 -04:00
parent 8bc32eab6b
commit 5894c5d548
4 changed files with 39 additions and 43 deletions
+19
View File
@@ -15,6 +15,25 @@ pub const DEFAULT_PROGRAM_ID: ProgramId = [0; 8];
/// TODO: Placeholder `program_owner` for uninitialized `Account`.
pub const DEFAULT_PROGRAM_OWNER: AccountId = AccountId::new([0; 32]);
/// Sentinel `AccountId` that owns every deployed program's storage account.
///
/// Now that program accounts live directly in `public_state` (Program-as-Account), a real
/// owner value matters: `DEFAULT_PROGRAM_OWNER` would leave them unclaimed, letting an
/// ordinary program invocation "claim" one via the normal claim path and then legitimately
/// rewrite its `data` (the elf) as if it were the program's own account — corrupting deployed
/// bytecode through completely ordinary dispatch. Self-ownership (`program_owner` equal to the
/// account's own id) is just as broken: it authorizes exactly the program whose invocation
/// would touch its own storage account.
///
/// This value is reserved instead — no real `ProgramId`'s image id will ever equal it (it's a
/// fixed, distinctive bit pattern, not a hash, so collision with a real cryptographic digest is
/// as implausible as with `DEFAULT_PROGRAM_ID`), and it is a sentinel `Program::execute`s never
/// executes at, so no ordinary transaction can ever supply a `chained_call.program_id` whose
/// derived owner matches it. That makes every program's storage account unwritable via normal
/// dispatch, by construction, until a real loader program is designed and registered to hold
/// this authority deliberately.
pub const PROGRAM_STORAGE_OWNER: AccountId = AccountId::new([0xFF; 32]);
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
pub type ProgramId = [u32; 8];
+9 -24
View File
@@ -5,6 +5,7 @@ use lee_core::{
BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier,
Timestamp,
account::{Account, AccountId, Data},
program::PROGRAM_STORAGE_OWNER,
};
use crate::{
@@ -111,9 +112,12 @@ 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 keyed by `AccountId::from(program_id)`
/// (see that impl's doc comment), with the elf held in `Account.data` and `program_owner`
/// set to the reserved `PROGRAM_STORAGE_OWNER` (see its doc comment for why that ownership
/// choice is load-bearing now that these accounts are reachable via ordinary dispatch).
public_state: HashMap<AccountId, Account>,
private_state: (CommitmentSet, NullifierSet),
programs: HashMap<AccountId, Account>,
}
impl Default for V03State {
@@ -126,7 +130,6 @@ impl Default for V03State {
Self {
public_state: HashMap::default(),
private_state,
programs: HashMap::default(),
}
}
}
@@ -197,11 +200,12 @@ impl V03State {
pub(crate) fn insert_program(&mut self, program: &Program) {
let account_id = AccountId::from(program.id());
let account = Account {
program_owner: PROGRAM_STORAGE_OWNER,
data: Data::try_from(program.elf().to_vec())
.expect("elf must fit under DATA_MAX_LENGTH"),
..Account::default()
};
self.programs.insert(account_id, account);
self.public_state.insert(account_id, account);
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
@@ -286,17 +290,13 @@ impl V03State {
self.private_state.0.get_proof_for(commitment)
}
pub(crate) const fn programs(&self) -> &HashMap<AccountId, Account> {
&self.programs
}
#[must_use]
pub fn commitment_set_digest(&self) -> CommitmentSetDigest {
self.private_state.0.digest()
}
/// Order-independent fingerprint of the genesis-relevant state: the public
/// account set, the deployed program set, and the commitment-set digest.
/// Order-independent fingerprint of the genesis-relevant state: the public account set
/// (which includes deployed programs' storage accounts) and the commitment-set digest.
///
/// The sequencer and the indexer build the directly-seeded part of genesis
/// (base builtins plus any directly-seeded accounts) separately from their own
@@ -313,18 +313,11 @@ impl V03State {
let Self {
public_state,
private_state,
programs,
} = self;
let mut accounts: Vec<(&AccountId, &Account)> = public_state.iter().collect();
accounts.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()));
let mut program_accounts: Vec<(&AccountId, &Account)> = programs.iter().collect();
program_accounts.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()));
let account_count = u64::try_from(accounts.len()).expect("account count fits in u64");
let program_count =
u64::try_from(program_accounts.len()).expect("program count fits in u64");
let mut hasher = Sha256::new();
hasher.update(account_count.to_le_bytes());
@@ -335,14 +328,6 @@ impl V03State {
hasher.update(len.to_le_bytes());
hasher.update(&bytes);
}
hasher.update(program_count.to_le_bytes());
for (id, account) in program_accounts {
hasher.update(id.as_ref());
let bytes = borsh::to_vec(account).expect("Account is BorshSerialize");
let len = u64::try_from(bytes.len()).expect("program account encoding fits in u64");
hasher.update(len.to_le_bytes());
hasher.update(&bytes);
}
hasher.update(private_state.0.digest());
let mut out = [0_u8; 32];
+2 -14
View File
@@ -24,13 +24,10 @@ fn new_works() {
);
this
};
let expected_builtin_programs = HashMap::new();
let state =
V03State::new().with_public_account_balances([(addr1, 100_u128), (addr2, 151_u128)]);
assert_eq!(state.public_state, expected_public_state);
assert_eq!(state.programs, expected_builtin_programs);
}
#[test]
@@ -68,11 +65,11 @@ fn insert_program() {
let program_to_insert = crate::test_methods::simple_balance_transfer();
let program_id = program_to_insert.id();
let account_id = lee_core::account::AccountId::from(program_id);
assert!(!state.programs.contains_key(&account_id));
assert!(!state.public_state.contains_key(&account_id));
state.insert_program(&program_to_insert);
assert!(state.programs.contains_key(&account_id));
assert!(state.public_state.contains_key(&account_id));
}
#[test]
@@ -106,15 +103,6 @@ fn get_account_by_account_id_default_account() {
assert_eq!(account, expected_account);
}
#[test]
fn builtin_programs_getter() {
let state = V03State::new();
let builtin_programs = state.programs();
assert_eq!(builtin_programs, &state.programs);
}
#[test]
fn state_serialization_roundtrip() {
let account_id_1 = AccountId::new([1; 32]);
@@ -112,9 +112,13 @@ impl ValidatedStateDiff {
LeeError::MaxChainedCallsDepthExceeded
);
let Some(program_account) = state
.programs()
.get(&AccountId::from(chained_call.program_id))
// Check that the `program_id` corresponds to a deployed program. Deployed programs
// live in `public_state`, keyed by `AccountId::from(program_id)` (see that impl's
// doc comment), holding the elf as a plain `Account`; reconstruct a `Program` from
// it via `new_unchecked` to execute, skipping a redundant image-id recomputation
// since the id/elf pairing was already validated once, at deployment time.
let Some(program_account) =
state.get_account_by_id_ref(AccountId::from(chained_call.program_id))
else {
return Err(LeeError::InvalidInput("Unknown program".into()));
};
@@ -452,8 +456,8 @@ impl ValidatedStateDiff {
// TODO: remove clone
let program = Program::new(tx.message.bytecode.clone().into())?;
if state
.programs()
.contains_key(&AccountId::from(program.id()))
.get_account_by_id_ref(AccountId::from(program.id()))
.is_some()
{
return Err(LeeError::ProgramAlreadyExists);
}