Skip to main content

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.​

Version

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:

  • plan sees 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.
  • apply materialises 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.

Prerequisites

What to expect​

  • You can write a program's plan function that declares effects on account handles without reading account data.
  • You can write a program's apply function 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​

  1. 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.git
    cd logos-execution-zone
    git checkout v0.3.2
    ls examples/program_deployment/methods/guest/src/bin

    Expected result:

    hello_world.rs
    hello_world_with_authorization.rs
    hello_world_with_move_function.rs
    simple_tail_call.rs
    tail_call_with_pda.rs

    Each 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.

  1. Note the types that name a shard, which you pass around instead of account data:

    TypeFieldsPurpose
    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 plan receives for each input. It carries no data. The engine computes is_authorized.
    ShardDataWrapped Vec<u8>, at most 100 KiBThe contents of one shard, as apply receives and returns it.
  2. 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.

  1. 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. Its main function 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. I is your instruction type. run_program Borsh-decodes it from the transaction's instruction data before calling plan.
    • apply: FnOnce(E, &P) -> Option<D>. E is your effect type, Borsh-decoded from the effect that plan emitted. P is the shard's current data, as &ShardData or &[u8]. D is anything that converts into ShardData, such as Vec<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.

  1. Read the plan function in hello_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 the input you were given.
    • plan.effect(account, &instruction) declares one effect on the program's own shard of account. The effect value is Borsh-encoded and handed to apply later. Here the effect is the raw instruction bytes.

    PlanInput carries:

    FieldDescription
    self_account_idThe account ID of the program being run.
    caller_account_idThe calling program for a chained call, or None for a top-level transaction.
    accountsOne AccountMeta handle per input the caller selected, in order.
    instruction_dataThe raw instruction bytes. run_program has already decoded these into your instruction type.
  2. Check authorisation in plan when the program must only modify an account that is authorised. Open hello_world_with_authorization.rs. The only difference from hello_world.rs is one assertion:

    // Fail if the input account is not authorized
    assert!(account.is_authorized, "Missing required authorization");
    info

    The engine sets is_authorized in one of three ways:

  3. Use the other Plan builder methods as your program needs them:

    MethodWhat 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 own apply, 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.

  1. Read the apply function in hello_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)
    }

    greeting is the effect plan emitted, and pre_data is the current contents of the program's shard on the account. The return value decides what happens to the shard:

    Return valueResult
    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_program wraps the result in an ApplyOutput that echoes the engine's ApplyInput { 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).
    • apply cannot chain calls. The engine rejects an apply output that carries any (ChainedCallsFromApply).
  2. 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 Effect enum lets one apply handle several kinds of change. move_data emits two effects, so the destination's apply never sees the source's contents.

  3. Pin values that plan cannot read. plan cannot see what the source shard holds, so the instruction states it, and the source's apply refuses 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.

  1. Open simple_tail_call.rs. It writes nothing itself, so it skips run_program, reads the call with read_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, so hello_world can write it.
    • Without run_program, you must end with plan.write(). Building a Plan has no effect until it is written.
  2. 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 is AccountId::for_public_pda(&self_account_id, &PDA_SEED), so hello_world_with_authorization sees is_authorized == true and 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.

  1. 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 PDA

    let 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 plan asserts each one is the PDA it expects before declaring anything.
    • Effect as replay guard. RecordDeposit writes a one-byte receipt, and its apply panics 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 a ChainedCall to 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.

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.

  1. From the repository root, run the traversal engine tests:

    RISC0_DEV_MODE=1 cargo test --release -p lee_core --features host execution_state

    Expected result: the suite passes. It covers input echoes, effect scoping, depth-first traversal, PDA grant scoping and validity-window intersection.

  2. Optionally, drive all three apply outcomes (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_input

    The tests to read for each rule:

    RuleTests
    plan must echo its input, effects land only on declared handles, apply must 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_computed in lee/state_machine/core/src/execution_state/tests.rs
    Shard ownership and the native-balance boundarya_data_write_on_a_shard_the_executing_program_does_not_own_is_rejected, a_guest_cannot_write_the_native_balance_shard in lee/state_machine/core/src/program/tests.rs
    Real guests on the public path, including dropped or injected accountslee/state_machine/src/state/tests/public_program_rules.rs
    The same contract on the private pathThe an_apply_forging_* tests in lee/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.