A demonstration program for the Logos Execution Zone (LEZ) that shows how programs can own and control accounts through Program Derived Accounts (PDAs), and compose with other programs through chained calls.
The Treasury program acts as an on-chain vault manager. It can:
- Create Vaults — deploy a new token and mint initial supply into a treasury-controlled vault
- Send — transfer tokens from a vault to any recipient
- Deposit — receive tokens from external senders into a vault
All vault accounts are PDAs — accounts whose authority is derived from the Treasury program itself, not from any external key. This means only the Treasury program can authorize actions on its vaults.
A Program Derived Account (PDA) is an account whose ID (address) is deterministically computed from:
- A program ID (which program is the "authority" of the PDA)
- A seed (a 32-byte value that makes each PDA unique)
PDA Account ID = hash("/NSSA/v0.2/AccountId/PDA/" || program_id || seed)
PDAs are special because:
- No private key corresponds to them — nobody can sign for them externally
- Only the deriving program can authorize operations on them (by providing the seed)
- Deterministic — anyone can recompute the address given the program ID and seed
In LEZ, there are two distinct concepts that control who can modify an account:
| Concept | Meaning | Who? |
|---|---|---|
| Program Ownership | Which program can mutate the account's data and balance fields |
Set when a program "claims" the account |
| Authority | Who can set is_authorized = true on the account |
For PDAs: the program that derived the account ID |
A typical pattern (used in this program):
- The Treasury program derives a vault PDA and is its authority
- The Token program claims the vault account and becomes its owner (it writes balance data)
- When Treasury wants to spend from the vault, it sets
is_authorized = trueand provides the PDA seed - The Token program sees the authorized flag and executes the transfer
┌─────────────────────────────────────────────────────┐
│ Treasury Program │
│ (treasury_program_id) │
└──────────┬──────────────────────┬───────────────────┘
│ │
│ seed: padded │ seed: token_definition_id
│ "treasury_state" │ bytes ([u8; 32])
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ Treasury │ │ Vault Holding │
│ State PDA │ │ PDA │
│ │ │ │
│ Owned by: │ │ Owned by: │
│ Treasury │ │ Token program │
│ program │ │ (after claim) │
│ │ │ │
│ Authority: │ │ Authority: │
│ Treasury │ │ Treasury │
│ program │ │ program │
└──────────────┘ └──────────────────┘
- Treasury State PDA: stores vault count — owned and controlled entirely by Treasury
- Vault Holding PDA: one per token — owned by Token program (holds balance data), but authorized by Treasury
lez-treasury/
├── Cargo.toml — workspace definition
├── README.md — this file
├── treasury_core/ — shared types (used on-chain and off-chain)
│ └── src/lib.rs — Instruction enum, TreasuryState, PDA helpers
├── treasury_program/ — on-chain program logic
│ └── src/
│ ├── lib.rs
│ ├── create_vault.rs — CreateVault handler
│ ├── send.rs — Send handler
│ └── receive.rs — Deposit handler
├── methods/ — risc0 build infrastructure
│ ├── build.rs — embeds guest ELF via risc0_build
│ ├── src/lib.rs — re-exports embedded methods
│ └── guest/
│ └── src/bin/treasury.rs — zkVM guest binary entry point
└── examples/
└── program_deployment/ — off-chain runner scripts
└── src/bin/
├── deploy_and_create_vault.rs
└── send_from_vault.rs
The core crate provides deterministic PDA computation using AccountId::from((&ProgramId, &PdaSeed)) — the same mechanism used by the LEZ runtime:
/// Fixed 32-byte seed for the treasury state PDA (padded with zeroes).
const TREASURY_STATE_SEED: [u8; 32] = { /* b"treasury_state" padded to 32 bytes */ };
/// Compute the treasury state PDA account ID.
pub fn compute_treasury_state_pda(treasury_program_id: &ProgramId) -> AccountId {
AccountId::from((treasury_program_id, &treasury_state_pda_seed()))
}
/// Compute the vault holding PDA for a given token definition.
/// Uses the token definition's AccountId bytes as the seed.
pub fn compute_vault_holding_pda(
treasury_program_id: &ProgramId,
token_definition_id: &AccountId,
) -> AccountId {
AccountId::from((treasury_program_id, &vault_holding_pda_seed(token_definition_id)))
}The PdaSeed constructors wrap 32-byte arrays:
pub fn treasury_state_pda_seed() -> PdaSeed {
PdaSeed::new(TREASURY_STATE_SEED)
}
pub fn vault_holding_pda_seed(token_definition_id: &AccountId) -> PdaSeed {
PdaSeed::new(*token_definition_id.value())
}These functions are used both inside the zkVM (by the program) and off-chain (by deployment scripts) to derive the same addresses.
This instruction demonstrates three key patterns:
a) First-time PDA claiming:
let treasury_post_state = if treasury_state.account == Account::default() {
// First call — claim the PDA for this program
AccountPostState::new_claimed(treasury_post)
} else {
// Already claimed — just update
AccountPostState::new(treasury_post)
};b) Authorizing a PDA in a chained call:
// Mark the vault as authorized — Treasury is the authority of this PDA
let mut vault_for_chain = vault_holding.clone();
vault_for_chain.is_authorized = true;c) Building a chained call with PDA seeds:
let chained_call = ChainedCall::new(
token_program_id,
vec![token_definition.clone(), vault_for_chain],
&token_core::Instruction::NewFungibleDefinition {
name: token_name,
total_supply: initial_supply,
},
)
// Provide the seed so the runtime can verify: hash(treasury_id, seed) == vault PDA
.with_pda_seeds(vec![vault_holding_pda_seed(&token_definition.account_id)]);Demonstrates transferring from a PDA vault. The key insight: the vault is owned by the Token program (which manages balances), but the Treasury program is its authority (it can authorize spending).
// Look up the token definition to compute the correct PDA seed
let vault_token_holding = token_core::TokenHolding::try_from(&vault_holding.account.data)
.expect("Vault must be a valid TokenHolding");
let definition_id = vault_token_holding.definition_id();
// Authorize the vault PDA
let mut vault_for_chain = vault_holding.clone();
vault_for_chain.is_authorized = true;
// Chain to Token::Transfer with PDA proof
let chained_call = ChainedCall::new(
token_program_id,
vec![vault_for_chain, recipient_holding.clone()],
&token_core::Instruction::Transfer { amount_to_transfer: amount },
)
.with_pda_seeds(vec![vault_holding_pda_seed(&definition_id)]);Deposits are simpler — no PDA authorization needed because the vault is the receiver, not the sender:
// The sender is authorized by the user's signature in the transaction.
// We just chain to Token::Transfer: sender → vault
let chained_call = ChainedCall::new(
token_program_id,
vec![sender_holding.clone(), vault_holding.clone()],
&token_core::Instruction::Transfer { amount_to_transfer: amount },
);
// No .with_pda_seeds() — only needed when spending FROM a PDAThe guest binary is the entry point compiled to RISC-V for the zkVM. It reads inputs, dispatches to the right handler, and writes outputs:
fn main() {
let (ProgramInput { pre_states, instruction }, instruction_words)
= read_nssa_inputs::<Instruction>();
let pre_states_clone = pre_states.clone();
let (post_states, chained_calls) = match instruction {
Instruction::CreateVault { .. } => { /* dispatch to create_vault */ }
Instruction::Send { .. } => { /* dispatch to send */ }
Instruction::Deposit { .. } => { /* dispatch to deposit */ }
};
// Use the chained-call variant since all instructions may produce chained calls
write_nssa_outputs_with_chained_call(
instruction_words, pre_states_clone, post_states, chained_calls,
);
}- Rust (edition 2024 / nightly)
- Risc0 toolchain:
curl -L https://risczero.com/install | bash && rzup install
cargo check -p treasury_core -p treasury_programcargo risczero build --manifest-path methods/guest/Cargo.tomlThe compiled ELF will be in target/riscv32im-risc0-zkvm-elf/docker/treasury.bin.
# 1. Start the sequencer (from the lssa repo)
cd /path/to/lssa/sequencer_runner
RUST_LOG=info cargo run $(pwd)/configs/debug
# 2. Install the wallet CLI (from the lssa repo root)
cargo install --path wallet --force
# 3. Deploy the treasury + token programs
export PROGRAMS_DIR=$(pwd)/target/riscv32im-risc0-zkvm-elf/docker
wallet deploy-program $PROGRAMS_DIR/treasury.bin
wallet deploy-program $PROGRAMS_DIR/token.bin # from lssa repo buildThe runner automatically computes PDA account IDs from the program binaries. You only need to provide the token definition account (a regular public account):
# Create a public account for the token definition
wallet account new public
# Output: Generated new account with account_id Public/<TOKEN_DEF_ID>
# Run CreateVault — PDAs are computed automatically!
cd examples/program_deployment
cargo run --bin deploy_and_create_vault \
$PROGRAMS_DIR/treasury.bin \
$PROGRAMS_DIR/token.bin \
<TOKEN_DEF_ID>The runner will print all the computed addresses:
Treasury program ID: [...]
Token program ID: [...]
Treasury state PDA: <auto-computed>
Token definition: <TOKEN_DEF_ID>
Vault holding PDA: <auto-computed>
Under the hood, 3 accounts are passed to the program:
| # | Account | Computed how |
|---|---|---|
| 0 | treasury_state |
compute_treasury_state_pda(treasury_program_id) — auto |
| 1 | token_definition |
You provide this (created with wallet account new public) |
| 2 | vault_holding |
compute_vault_holding_pda(treasury_program_id, token_def_id) — auto |
# Create a recipient account
wallet account new public
# Output: Generated new account with account_id Public/<RECIPIENT_ID>
# Send 100 tokens — PDAs are computed automatically!
cargo run --bin send_from_vault \
$PROGRAMS_DIR/treasury.bin \
$PROGRAMS_DIR/token.bin \
<TOKEN_DEF_ID> \
<RECIPIENT_ID> \
100Accounts (auto-computed from the token definition ID):
| # | Account | Computed how |
|---|---|---|
| 0 | treasury_state |
Auto from treasury program ID |
| 1 | vault_holding |
Auto from treasury program ID + token def ID |
| 2 | recipient_holding |
You provide this |
Same pattern — you provide the sender's account and token definition, PDAs are computed:
| # | Account | Computed how |
|---|---|---|
| 0 | treasury_state |
Auto from treasury program ID |
| 1 | sender_holding |
You provide this (authorized by user signature) |
| 2 | vault_holding |
Auto from treasury program ID + token def ID |
Here's the full execution flow for a Send instruction:
User submits transaction
│
│ Accounts: [treasury_state, vault_holding, recipient_holding]
│ Instruction: Send { amount: 100, token_program_id }
│
▼
┌─────────────────────────────────────────────────────┐
│ 1. LEZ Runtime executes Treasury program │
│ │
│ treasury_program::send::send() │
│ ├─ Read vault token holding data │
│ ├─ Set vault_holding.is_authorized = true │
│ ├─ Build ChainedCall to Token::Transfer │
│ │ └─ .with_pda_seeds([vault_seed]) │
│ └─ Return post_states + chained_calls │
│ │
│ 2. Runtime verifies PDA: │
│ hash(treasury_program_id, vault_seed) │
│ == vault_holding.account_id ✓ │
│ │
│ 3. Runtime executes chained call: Token::Transfer │
│ ├─ vault_holding (authorized) → sender │
│ ├─ recipient_holding → receiver │
│ └─ Debit vault, credit recipient │
│ │
│ 4. All state changes committed atomically │
└─────────────────────────────────────────────────────┘
| Pattern | Code | Purpose |
|---|---|---|
| PDA derivation | AccountId::from((&program_id, &PdaSeed::new(seed))) |
Deterministic address from program + seed |
| PDA authorization | account.is_authorized = true |
Grant authority to chained program |
| PDA proof | .with_pda_seeds(vec![seed]) |
Prove to runtime you derived this PDA |
| Account claiming | AccountPostState::new_claimed(account) |
First-time PDA ownership |
| Conditional claim | Check account == Account::default() |
Claim only if uninitialized |
| Chained call | ChainedCall::new(program_id, accounts, &instruction) |
Cross-program invocation |
| Output with chains | write_nssa_outputs_with_chained_call(...) |
Return results + chained calls |
- LEZ Repository — full framework source
programs/amm/— AMM program (advanced PDA usage: pool + vault + liquidity token PDAs)programs/token/— Token program (the program we chain to)nssa/core/src/program.rs— core types (ProgramInput,ChainedCall,PdaSeed, etc.)examples/program_deployment/README.md— step-by-step deployment tutorial