Write an LEZ program that updates an account via plan and apply
Walk through the example guest programs to learn how an LEZ program declares effects in plan and writes one shard at a time in apply.
This document is accurate for Testnet v0.3.
When a Logos Execution Zone (LEZ) program executes and consequently modifies an LEZ account, it does not receive full account states or return full post-states. Instead, it has two distinct entry points that have access only to what the execution engine gives them:
plansees the instruction and a list of account handles, but no account data. It turns the instruction into a list of intents: effects on account data shards, chained calls to other programs, and events.applymaterialises one effect against exactly one shard of one account, and sees nothing else.
For more information about this two-step update process, see Introduction to the Logos Execution Zone.
This procedure walks through the example programs in the examples/program_deployment directory of the LEZ repository, from a short program to one that chains calls and emits events. It is intended for developers writing or reviewing LEZ programs who want to understand the interface before writing their own.
- Git and the Rust toolchain.
- Familiarity with Rust and the
borshserialisation format. - To build guest programs yourself, and to run the second command in Step 8: the RISC Zero toolchain (
rzup), as used in Write and deploy an LEZ program withlogos-scaffold.
What to expect
- You can write a program's
planfunction that declares effects on account handles without reading account data. - You can write a program's
applyfunction that keeps, clears, or replaces exactly one shard. - You can compose programs by chaining calls, delegating PDA authorisation, and emitting events from
plan.
Step 1: Get the example programs
-
Clone the LEZ repository at the release the testnet runs and open the example programs:
git clone https://github.com/logos-blockchain/logos-execution-zone.gitcd logos-execution-zonegit checkout v0.3.2ls examples/program_deployment/methods/guest/src/binExpected result:
hello_world.rshello_world_with_authorization.rshello_world_with_move_function.rssimple_tail_call.rstail_call_with_pda.rsEach file is a standalone program. The rest of this procedure reads them in order of complexity.
Step 2: Understand how account data is stored
Before writing a program, you need to know what apply is allowed to change. An account is a nonce plus a map of shards, each keyed by the account ID of the program that owns it (AccountData { shards: BTreeMap<AccountId, ShardData> }). A program may only write the shard keyed by its own account ID. There is no claiming of accounts: any program can write its own shard on any account it is given.
-
Note the types that name a shard, which you pass around instead of account data:
Type Fields Purpose ProgramShardSelectoraccount_id,program_account_idNames one (account, shard) pair. A client uses it to select a program's inputs, and a program uses it to select the inputs of a program it calls. AccountMetaaccount_id,is_authorized,program_account_idThe handle planreceives for each input. It carries no data. The engine computesis_authorized.ShardDataWrapped Vec<u8>, at most 100 KiBThe contents of one shard, as applyreceives and returns it. -
Note where the native balance lives. Balances sit in the native token shard, whose program ID is all zeros (
NATIVE_TOKEN_PROGRAM_ID). Transfers are planned and applied by protocol code, and no guest can write that shard. A program moves native tokens by chaining a call to the native token program, as Step 7 shows.
Step 3: Wire the entry point with run_program
Every guest that writes data starts the same way. run_program reads one invocation from the zkVM, decides whether it is a plan call or an apply call, runs the matching function, and writes a tagged GuestOutput to the journal. Because the journal is tagged, a plan receipt can never satisfy an apply slot.
-
Open
examples/program_deployment/methods/guest/src/bin/hello_world.rs. It appends its instruction bytes to its own shard on a single input account. Itsmainfunction is one line:use lee_core::{account::ShardData,program::{Plan, PlanInput, run_program},};type Instruction = Vec<u8>;fn main() {run_program(plan, apply)}run_program(plan, apply)takes your two entry points and never returns:plan: FnOnce(&PlanInput, I) -> Plan.Iis your instruction type.run_programBorsh-decodes it from the transaction's instruction data before callingplan.apply: FnOnce(E, &P) -> Option<D>.Eis your effect type, Borsh-decoded from the effect thatplanemitted.Pis the shard's current data, as&ShardDataor&[u8].Dis anything that converts intoShardData, such asVec<u8>.- A panic in either function fails the whole transaction.
Step 4: Declare effects in plan
plan is state-blind: it receives account handles but never account data. Its job is to validate the instruction and the shape of the inputs, then declare what should happen.
-
Read the
planfunction inhello_world.rs:fn plan(input: &PlanInput, instruction: Instruction) -> Plan {let mut plan = Plan::new(input);let [account] = input.accounts.as_slice() else {panic!("Input accounts should consist of a single account");};plan.effect(account, &instruction);plan}Plan::new(input)starts a builder that echoes the engine's input back. The engine rejects a plan whose echoed input differs from the one it computed (PlanInputMismatch), so always build from theinputyou were given.plan.effect(account, &instruction)declares one effect on the program's own shard ofaccount. The effect value is Borsh-encoded and handed toapplylater. Here the effect is the raw instruction bytes.
PlanInputcarries:Field Description self_account_idThe account ID of the program being run. caller_account_idThe calling program for a chained call, or Nonefor a top-level transaction.accountsOne AccountMetahandle per input the caller selected, in order.instruction_dataThe raw instruction bytes. run_programhas already decoded these into your instruction type. -
Check authorisation in
planwhen the program must only modify an account that is authorised. Openhello_world_with_authorization.rs. The only difference fromhello_world.rsis one assertion:// Fail if the input account is not authorizedassert!(account.is_authorized, "Missing required authorization");infoThe engine sets
is_authorizedin one of three ways:- For a public account: if it signed the transaction that ran the program
- For a private account: from knowledge of the authorisation secret key
- For a delegated PDA: if the calling program attaches a seed to delegate authorisation
-
Use the other
Planbuilder methods as your program needs them:Method What it declares effect(account, &effect)An effect on this program's own shard of account. Panics if the handle names another program's shard.inspect(account, owner, &guard)A read-only check routed through owner's ownapply, for a shard this program does not own.call(ChainedCall)A call to another program, run after this one. event(ProgramEvent)An event for off-chain consumers to read from an indexer. It does not change the state. block_window(..),timestamp_window(..)Narrows the block ID or timestamp range in which the transaction is valid, for example plan.block_window(..100).An effect may only target a shard that is an input of the call. The engine rejects anything else with
EffectOutsideInputs.
Step 5: Write one shard in apply
The engine calls apply once per effect, with the shard that effect targets and nothing else. A shard that does not exist yet arrives empty.
-
Read the
applyfunction inhello_world.rs:fn apply(greeting: Vec<u8>, pre_data: &ShardData) -> Option<Vec<u8>> {let mut bytes = pre_data.clone().into_inner();bytes.extend(greeting);Some(bytes)}greetingis the effectplanemitted, andpre_datais the current contents of the program's shard on the account. The return value decides what happens to the shard:Return value Result NoneKeep the shard unchanged. Use this for a check that writes nothing. Some(empty)Clear the shard. Some(data)Replace the shard with data.run_programwraps the result in anApplyOutputthat echoes the engine'sApplyInput { self_account_id, selector, pre_data, effect_data }. The engine rejects an output that does not match (ApplyInputMismatch), and rejects a write to a shard the program does not own (ForeignShardWrite).applycannot chain calls. The engine rejects an apply output that carries any (ChainedCallsFromApply).
-
Split a change that touches several accounts into one effect per shard. Open
hello_world_with_move_function.rs, which moves bytes from one account's shard to another's:const WRITE_FUNCTION_ID: u8 = 0;const MOVE_DATA_FUNCTION_ID: u8 = 1;type Instruction = (u8, Vec<u8>);#[derive(borsh::BorshSerialize, borsh::BorshDeserialize)]enum Effect {Append(Vec<u8>),MoveOut(Vec<u8>),}fn plan(input: &PlanInput, instruction: Instruction) -> Plan {let mut plan = Plan::new(input);let (function_id, data) = instruction;match (input.accounts.as_slice(), function_id) {([account], WRITE_FUNCTION_ID) => plan.effect(account, &Effect::Append(data)),([from, to], MOVE_DATA_FUNCTION_ID) => {plan.effect(from, &Effect::MoveOut(data.clone()));plan.effect(to, &Effect::Append(data));}_ => panic!("invalid params"),}plan}A program-defined
Effectenum lets oneapplyhandle several kinds of change.move_dataemits two effects, so the destination'sapplynever sees the source's contents. -
Pin values that
plancannot read.plancannot see what the source shard holds, so the instruction states it, and the source'sapplyrefuses unless the shard holds exactly those bytes:fn apply(effect: Effect, pre_data: &[u8]) -> Option<Vec<u8>> {Some(match effect {Effect::Append(data) => {let mut bytes = pre_data.to_vec();bytes.extend_from_slice(&data);bytes}Effect::MoveOut(data) => {assert_eq!(pre_data, data,"the source account does not hold the bytes the instruction moves out of it");Vec::new()}})}Because the transaction fails if any effect panics, the value the destination appends is the real one, not just a caller's claim. Use this pattern whenever one shard's new value depends on another shard's old value.
Step 6: Chain a call to another program
A program composes with others by declaring chained calls in plan. A caller names (account, shard) pairs only. It cannot hand the called program an account value or an authorisation bit, because the engine resolves each selector against live execution state and computes is_authorized itself. Calls run depth-first, and a transaction may prepare at most 10 chained calls in total (MAX_NUMBER_CHAINED_CALLS), counted across the whole traversal rather than per branch.
-
Open
simple_tail_call.rs. It writes nothing itself, so it skipsrun_program, reads the call withread_program_call, and refuses an apply call:type Instruction = AccountId;fn main() {let ProgramCall::Plan(input, instruction) = read_program_call::<Instruction>() else {panic!("simple_tail_call emits no effect to apply")};let callee_account_id = instruction;let [account] = <[_; 1]>::try_from(input.accounts.clone()).unwrap_or_else(|_| panic!("Input accounts should consist of a single account"));let greeting: Vec<u8> = b"Hello from tail call".to_vec();let mut plan = Plan::new(&input);plan.call(ChainedCall::new(callee_account_id,vec![ProgramShardSelector::new(account.account_id,callee_account_id,)],&greeting,));plan.write()}- The account ID of the called program comes from the instruction, because a deployed program's address is not known until deploy time.
ChainedCall::new(program_account_id, shard_selectors, &instruction)encodes the instruction for the called program. Here it selects that program's own shard on the same account, sohello_worldcan write it.- Without
run_program, you must end withplan.write(). Building aPlanhas no effect until it is written.
-
Delegate authorisation over a Program Derived Address by attaching seeds to the call. Open
tail_call_with_pda.rs:const PDA_SEED: PdaSeed = PdaSeed::new([37; 32]);plan.call(ChainedCall::new(callee_account_id,vec![ProgramShardSelector::new(account.account_id,callee_account_id,)],&greeting,).with_pda_seeds(vec![PDA_SEED]),);For each seed, the engine authorises the called program, and any program it calls in turn, to act on the account derived from
(caller program ID, seed). Here the input account isAccountId::for_public_pda(&self_account_id, &PDA_SEED), sohello_world_with_authorizationseesis_authorized == trueand writes its shard.
Step 7: Combine effects, calls and events
Production programs mix all three outputs in one plan. The bridge program's deposit, in lez/programs/bridge/src/main.rs, is a compact example.
-
Read the deposit branch of the bridge's
plan:let [bridge, recipient, receipt] = <&[_; 3]>::try_from(input.accounts.as_slice()).expect("Deposit requires exactly 3 accounts");assert_eq!(bridge.account_id,bridge_core::compute_bridge_account_id(input.self_account_id),"First account must be bridge PDA");// ... similar checks pin the recipient and the deposit-receipt PDAlet mut plan = Plan::new(input);plan.effect(receipt, &Effect::RecordDeposit);plan.call(custody_transfer(bridge.account_id,bridge_core::compute_bridge_seed(),recipient.account_id,u128::from(amount),));plan.event(ProgramEvent {selector: bridge_core::event::Deposit::SELECTOR,data: bridge_core::event::Deposit {l1_deposit_op_id,recipient_id,amount,}.to_bytes(),});plan- Pin every handle. Account handles are chosen by the transaction, so
planasserts each one is the PDA it expects before declaring anything. - Effect as replay guard.
RecordDepositwrites a one-byte receipt, and itsapplypanics if the receipt shard is already written, so the same deposit cannot be credited twice. - Native balance through a call.
custody_transfer(from, seed, to, amount)returns aChainedCallto the native token program, with the bridge's PDA seed attached so it can debit its own custody account. - Event for off-chain consumers. The event carries no state and changes nothing on chain.
- Pin every handle. Account handles are chosen by the transaction, so
Step 8: Run the engine tests (Optional)
The engine's test suite reads as a specification of the contract your program must meet. Run it to see the rules enforced.
-
From the repository root, run the traversal engine tests:
RISC0_DEV_MODE=1 cargo test --release -p lee_core --features host execution_stateExpected result: the suite passes. It covers input echoes, effect scoping, depth-first traversal, PDA grant scoping and validity-window intersection.
-
Optionally, drive all three
applyoutcomes (keep, clear, write) through a scripted guest. Unlike the previous command, this one builds guest programs, so it needs the RISC Zero toolchain from the prerequisites:RISC0_DEV_MODE=1 cargo test --release -p lee the_runner_echoes_its_inputThe tests to read for each rule:
Rule Tests planmust echo its input, effects land only on declared handles,applymust echo its inputa_plan_must_repeat_the_prepared_inputs_exactly,an_effect_outside_the_call_inputs_is_rejected,an_apply_output_must_echo_the_input_the_engine_computedinlee/state_machine/core/src/execution_state/tests.rsShard ownership and the native-balance boundary a_data_write_on_a_shard_the_executing_program_does_not_own_is_rejected,a_guest_cannot_write_the_native_balance_shardinlee/state_machine/core/src/program/tests.rsReal guests on the public path, including dropped or injected accounts lee/state_machine/src/state/tests/public_program_rules.rsThe same contract on the private path The an_apply_forging_*tests inlee/state_machine/src/state/tests/privacy_preserving.rs
Troubleshooting
A program written for an earlier LEZ release no longer compiles
The ProgramOutput, AccountPostState and AccountDiff types were removed. Rewrite the program as a plan that emits effects and an apply that returns a shard's new data, as in Step 4 and Step 5.
A plan-only program has no effect
A Plan does nothing until it is written. If your guest does not use run_program, end main with plan.write().
The transaction fails with EffectOutsideInputs or ForeignShardWrite
The program declared an effect on a shard that is not an input of the call, or tried to write a shard keyed by another program. Check that the client, or the calling program, selects ProgramShardSelector::new(account_id, <your program's account ID>) for every account your plan writes.