2025-12-11 20:59:37 -03:00

22 KiB
Raw Blame History

Program deployment tutorial

This guide walks you through running the sequencer, compiling example programs, deploying a Hello World program, and interacting with accounts.

You'll find:

  • Programs: example NSSA programs under methods/guest/src/bin.
  • Runners: scripts to create and submit transactions to invoke these programs publicly and privately under src/bin.

0. Install the wallet

From the projects root directory:

cargo install --path wallet --force

1. Run the sequencer

From the projects root directory, start the sequencer:

cd sequencer_runner
RUST_LOG=info cargo run $(pwd)/configs/debug

Keep this terminal open. Well use it only to observe the node logs.

Note

If you have already ran this before you'll see a rocksdb directory with stored blocks. Be sure to remove that directory to follow this tutorial.

Checking and setting up the wallet

For sanity let's check that the wallet can connect to it.

wallet check-health

If this is your first time, the wallet will ask for a password. This is used as seed to deterministically generate all account keys (public and private). For this tutorial, use: program-tutorial

You should see ✅All looks good! if everything went well.

2. Compile the example programs

In a second terminal, from the lssa root directory, compile the example Risc0 programs:

cargo risczero build --manifest-path examples/program_deployment/methods/guest/Cargo.toml

The compiled .bin files will appear under:

examples/program_deployment/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/

For convenience, export this path:

export EXAMPLE_PROGRAMS_BUILD_DIR=$(pwd)/examples/program_deployment/methods/guest/target/riscv32im-risc0-zkvm-elf/docker

3. Hello world example

The Hello world program reads an arbitrary sequence of bytes from its instruction and appends them to the data field of the input account. Execution succeeds only if the account is:

  • Uninitialized, or
  • Already owned by this program

If uninitialized, the program will claim the account and emit the updated state.

Navigate to the example directory

All remaining commands must be run from:

cd examples/program_deployment

Deploy the Program

Use the wallets built-in program deployment command:

wallet deploy-program $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world.bin

4. Public execution of the Hello world example

Create a Public Account

Generate a new public account:

wallet account new public

You'll see an output similar to:

Generated new account with account_id Public/BzdBoL4JRa5M873cuWb9rbYgASr1pXyaAZ1YW9ertWH9 at path /0

The relevant part is the account id BzdBoL4JRa5M873cuWb9rbYgASr1pXyaAZ1YW9ertWH9

Check the account state

New accounts are always Uninitialized. Verify:

wallet account get --account-id Public/BzdBoL4JRa5M873cuWb9rbYgASr1pXyaAZ1YW9ertWH9

Expected output:

Account is Uninitialized

The Public/ prefix tells the wallet to query the public state.

Execute the Hello world program

Run the example:

cargo run --bin run_hello_world \
    $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world.bin \
    BzdBoL4JRa5M873cuWb9rbYgASr1pXyaAZ1YW9ertWH9

Note

  • Passing the .bin lets the script compute the program ID and build the transaction.
  • Because this program executes publicly, the node performs the execution.
  • The program will claim the account and write data into it.

Monitor the sequencer terminal to confirm execution.

Inspect the updated account

After the transaction is processed, check the new state:

wallet account get --account-id Public/BzdBoL4JRa5M873cuWb9rbYgASr1pXyaAZ1YW9ertWH9

Example output:

{
  "balance": 0,
  "program_owner_b64": "o6C6/bbjDmN9VUC51McBpPrta8lxrx2X0iHExhX0yNU=",
  "data_b64": "SG9sYSBtdW5kbyE=",
  "nonce": 0
}

The data_b64 field contains de data in Base64. Decode it:

echo -n SG9sYSBtdW5kbyE= | base64 -d

You should see Hola mundo!.

5. Understanding the code in hello_world.rs.

The Hello world example demonstrates the minimal structure of an NSSA program. Its purpose is very simple: append the instruction bytes to the data field of a single account.

What this program does in a nutshell

  1. Reads the program inputs
  • The list of pre-state accounts (pre_states)
  • The instruction bytes (instruction)
  • The raw instruction data (used again when writing outputs)
  1. Checks that there is exactly one input account: this example operates on a single account, so it expects pre_states to contain exactly one entry.
  2. Builds the post-state: It clones the input account and appends the instruction bytes to its data field.
  3. Handles account claiming logic: If the account is uninitialized (i.e. not yet claimed by any program), its program_owner will equal DEFAULT_PROGRAM_ID. In that case, the program issues a claim request, meaning: "This program now owns this account."
  4. Outputs the proposed state transition: write_nssa_outputs emits:
  • The original instruction data
  • The original pre-states
  • The new post-states

Code walkthrough

  1. Reading inputs:
let (ProgramInput { pre_states, instruction: greeting }, instruction_data)
    = read_nssa_inputs::<Instruction>();
  1. Extracting the single account:
let [pre_state] = pre_states
    .try_into()
    .unwrap_or_else(|_| panic!("Input pre states should consist of a single account"));
  1. Constructing the updated account post state
let mut this = pre_state.account.clone();
let mut bytes = this.data.into_inner();
bytes.extend_from_slice(&greeting);
this.data = bytes.try_into().expect("Data should fit within the allowed limits");
  1. Instantiating the AccountPostState with a claiming request only if the account pre state is uninitialized:
let post_state = if post_account.program_owner == DEFAULT_PROGRAM_ID {
    AccountPostState::new_claimed(post_account)
} else {
    AccountPostState::new(post_account)
};
  1. Emmiting the output
write_nssa_outputs(instruction_data, vec![pre_state], vec![post_state]);

6. Understanding the runner script run_hello_world.rs

The run_hello_world.rs example demonstrates how to construct and submit a public transaction that executes the hello_world program. Below is a breakdown of what the file does and how the pieces fit together.

1. Wallet initialization

let wallet_config = fetch_config().await.unwrap();
let wallet_core = WalletCore::start_from_config_update_chain(wallet_config)
    .await
    .unwrap();

The example loads the wallet configuration and initializes WalletCore. This gives access to:

  • the sequencer client,
  • the wallets account storage.

2. Parsing inputs

let program_path = std::env::args_os().nth(1).unwrap().into_string().unwrap();
let account_id: AccountId = std::env::args_os().nth(2).unwrap().into_string().unwrap().parse().unwrap();

The program expects two arguments:

  • Path to the guest binary
  • AccountId of the public account to operate on

This is the account that the program will claim and write data into.

3. Loading the program bytecode

let bytecode: Vec<u8> = std::fs::read(program_path).unwrap();
let program = Program::new(bytecode).unwrap();

The Risc0 ELF is read from disk and wrapped in a Program object, which can be used to compute the program ID. The ID is used by the node to identify which program is invoked by the transaction.

4. Preparing the instruction data

let greeting: Vec<u8> = vec![72,111,108,97,32,109,117,110,100,111,33];

The example hardcodes the ASCII bytes for Hola mundo!. These bytes are passed to the program as its “instruction,” which the Hello World program simply appends to the accounts data field.

5. Creating the public transaction

let nonces = vec![];
let signing_keys = [];
let message = Message::try_new(program.id(), vec![account_id], nonces, greeting).unwrap();
let witness_set = WitnessSet::for_message(&message, &signing_keys);
let tx = PublicTransaction::new(message, witness_set);

A public transaction consists of:

  • a Message
  • a corresponding WitnessSet

For this simple example, no signing or nonces are required. The transaction includes only the program ID, the target account, and the instruction bytes. The Hello World program allows this because it does not explicitly require authorization. In the next example, well see how authorization requirements are enforced and how to construct a transaction that includes signatures and nonces.

6. Submitting the transaction

let response = wallet_core.sequencer_client.send_tx_public(tx).await.unwrap();

The transaction is sent to the sequencer, which processes it and updates the public state accordingly.

Once executed, youll be able to query the updated account to see the newly written "Hola mundo!" data.

7. Private execution of the Hello world example

This section is very similar to the previous case:

Create a private account

Generate a new private account:

wallet account new private

You'll see an output similar to:

Generated new account with account_id Private/7EDHyxejuynBpmbLuiEym9HMUyCYxZDuF8X3B89ADeMr at path /0

The relevant part for this tutorial is the account id 7EDHyxejuynBpmbLuiEym9HMUyCYxZDuF8X3B89ADeMr

You can check it's uninitialized with

wallet account get --account-id Private/7EDHyxejuynBpmbLuiEym9HMUyCYxZDuF8X3B89ADeMr

Privately executing the Hello world program

Execute the Hello world program

Run the example:

cargo run --bin run_hello_world_private \
    $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world.bin \
    7EDHyxejuynBpmbLuiEym9HMUyCYxZDuF8X3B89ADeMr

Note

  • This command may take a few minutes to complete. A ZK proof of the Hello world program execution and the privacy preserving circuit are being generated. Depending on the machine this can take from 30 seconds to 4 minutes.
  • We are passing the same hello_world.bin binary as in the previous case with public executions. This is because the program is the same, it is the privacy context of the input account that's different.
  • Because this program executes privately, the local machine runs the program and generate the proof of execution.
  • The program will claim the private account and write data into it.

Syncing the new private account values

The run_hello_world script submitted a transaction and it was (hopefully) accepted by the node. On chain there is now a commitment to the new private account values, and the account data is stored encrypted. However, the local client hasnt updated its private state yet. Thats why, if you try to get the private account values now, it still reads the old values from local storage instead.

wallet account get --account-id Private/7EDHyxejuynBpmbLuiEym9HMUyCYxZDuF8X3B89ADeMr

This will still show Account is Uninitialized. To see the new values locally, you need to run the wallet sync command. Once the client syncs, the local store will reflect the updated account data.

To sync private accounts run:

wallet account sync-private

Note

  • This queries the node for transactions and goes throught the encrypted accounts. Whenever a new value is found for one of the owned private accounts, the local storage is updated.

After this completes, running

wallet account get --account-id Private/7EDHyxejuynBpmbLuiEym9HMUyCYxZDuF8X3B89ADeMr

should show something similar to

{
  "balance":0,
  "program_owner_b64":"dWgtNRixwjC0C8aA0NL0Iuss3Q26Dw6ECk7bzExW4bI=",
  "data_b64":"SG9sYSBtdW5kbyE=",
  "nonce":236788677072686551559312843688143377080
}

The run_hello_world_private.rs runner

This example extends the public run_hello_world.rs flow by constructing a privacy-preserving transaction instead of a public one. Both runners load a guest program, prepare a transaction, and submit it. But the private version handles encrypted account data, nullifiers, ephemeral keys, and zk proofs.

Unlike the public version, run_hello_world_private.rs must:

  • prepare the private account pre-state (nullifier keys, membership proof, encrypted values)
  • derive a shared secret to encrypt the post-state
  • compute the correct visibility mask (initialized vs. uninitialized private account)
  • execute the guest program inside the zkVM and produce a proof
  • build a PrivacyPreservingTransaction composed of:
  • a Message encoding commitments + encrypted post-state
  • a WitnessSet embedding the zk proof

Luckily all that complexity is hidden behind the wallet_core.send_privacy_preserving_tx function:

    let accounts = vec![PrivacyPreservingAccount::PrivateOwned(account_id)];

    // Construct and submit the privacy-preserving transaction
    wallet_core
        .send_privacy_preserving_tx(
            accounts,
            &Program::serialize_instruction(greeting).unwrap(),
            &program,
        )
        .await
        .unwrap();

Check the run_hello_world_private.rs file to see how it is used.

8. Account authorization mechanism

The Hello world example does not enforce any authorization on the input account. This means any user can execute it on any account, regardless of ownership. NSSA provides a mechanism for programs to enforce proper authorization before an execution can succeed. The meaning of authorization differs between public and private accounts:

  • Public accounts: authorization requires that the transaction is signed with the accounts signing key.
  • Private accounts: authorization requires that the circuit verifies knowledge of the accounts nullifier secret key.

From the program development perspective it is very simple: input accounts come with a flag indicating whether they has been properly authorized. And so, the only difference between the program hello_world.rs and hello_world_with_authorization.rs is in the lines

    // #### Difference with `hello_world` example here:
    // Fail if the input account is not authorized
    // The `is_authorized` field will be correctly populated or verified by the system if
    // authorization is provided.
    if !pre_state.is_authorized {
        panic!("Missing required authorization");
    }
    // ####

Which just checks the is_authorized flag and fails if it is set to false.

9. Public execution of the Hello world with authorization example

The workflow to execute it publicly is very similar:

Deploy the program

wallet deploy-program $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world_with_authorization.bin

Create a new public account

Our previous public account is already claimed by the simple Hello world program. So we need a new one to work with this other version of the hello program

wallet account new public

Outupt:

Generated new account with account_id Public/9Ppqqf8NeCX58pnr8ZqKoHvSoYGqH79dSikZAtLxKgXE at path /1

Run the program

cargo run --bin run_hello_world_with_authorization \
    $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world_with_authorization.bin \
    9Ppqqf8NeCX58pnr8ZqKoHvSoYGqH79dSikZAtLxKgXE

10. Understanding run_hello_world_with_authorization.rs

From the runner script perspective, the only difference is that the signing keys are passed to the WitnessSet constructor for it to sign it. You can see this in the following parts of the code:

  1. Loading the sigining keys from the wallet storage
    // Load signing keys to provide authorization
    let signing_key = wallet_core
        .storage
        .user_data
        .get_pub_account_signing_key(&account_id)
        .expect("Input account should be a self owned public account");
  1. Fetching the current public nonce.
    // Construct the public transaction
    // Query the current nonce from the node
    let nonces = wallet_core
        .get_accounts_nonces(vec![account_id])
        .await
        .expect("Node should be reachable to query account data");
  1. Instantiate the witness set using the signing keys
    let signing_keys = [signing_key];
    let message = Message::try_new(program.id(), vec![account_id], nonces, greeting).unwrap();
    // Pass the signing key to sign the message. This will be used by the node
    // to flag the pre_state as `is_authorized` when executing the program
    let witness_set = WitnessSet::for_message(&message, &signing_keys);

Seeing the mechanism in action

If everything went well you won't notice any difference with the first Hello world, because the runner takes care of signing the transaction to provide authorization and the program just succeeds. Try using the run_hello_world.rs runner with the hello_world_with_authorization.bin program. This will fail because the runner will submit the transaction without the corresponding signature.

cargo run --bin run_hello_world \
    $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world_with_authorization.bin \
    9Ppqqf8NeCX58pnr8ZqKoHvSoYGqH79dSikZAtLxKgXE

You should see something like the following on the node logs.

[2025-12-11T13:43:22Z WARN  sequencer_core] Error at transition ProgramExecutionFailed(
        "Guest panicked: Missing required authorization",
    )

11. Public and private account interaction example

Previous examples only operated on public or private accounts independently. Those minimal programs were useful to introduce basic concepts, but they couldn't demonstrate how different types of accounts interact within a single program invocation. The "Hello world with move function" introduces two operations that require one or two input accounts:

  • write: appends arbitrary bytes to a single account. This is what we already had.
  • move_data: reads all bytes from one account, clears it, and appends those bytes to another account. Because these operations may involve multiple accounts, we'll see how public and private accounts can participate together in one execution. It highlights how ownership checks work, when an account needs to be claimed, and how multiple post-states are emitted when several accounts are modified.

Note

The program logic is completely agnostic to whether input accounts are public or private. It always executes the same way. See methods/guest/src/bin/hello_world_with_move_function.rs. The program just reads the instruction bytes and updates the accounts state. All privacy handling happens on the runner side. When constructing the transaction, the runner decides which accounts are public or private and prepares the appropriate proofs. The program itself can't differentiate between privacy modes.

Let's start by deploying the program

wallet deploy-program $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world_with_move_function.bin

Let's also create a new public account

wallet account new public

Output:

Generated new account with account_id Public/95iNQMbmxMRY6jULiHYkCzCkYKPEuysvBh5kEHayDxLs at path /0/0

Let's execute the write function

cargo run --bin run_hello_world_with_move_function \
    $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world_with_move_function.bin \
    write-public 95iNQMbmxMRY6jULiHYkCzCkYKPEuysvBh5kEHayDxLs mundo!

Let's crate a new private account.

wallet account new private

Output:

Generated new account with account_id Private/8vzkK7vsdrS2gdPhLk72La8X4FJkgJ5kJLUBRbEVkReU at path /1

Let's execute the write function

cargo run --bin run_hello_world_with_move_function \
    $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world_with_move_function.bin \
    write-private 8vzkK7vsdrS2gdPhLk72La8X4FJkgJ5kJLUBRbEVkReU Hola

To check the values of the accounts are as expected run:

wallet account get --account-id Public/95iNQMbmxMRY6jULiHYkCzCkYKPEuysvBh5kEHayDxLs

and

wallet account sync-private
wallet account get --account-id Private/8vzkK7vsdrS2gdPhLk72La8X4FJkgJ5kJLUBRbEVkReU

and check the (base64 encoded) data values are mundo! and Hola respectively.

Now we can execute the move function to clear the data on the public account and move it to the private account.

cargo run --bin run_hello_world_with_move_function \
    $EXAMPLE_PROGRAMS_BUILD_DIR/hello_world_with_move_function.bin \
    move-data-public-to-private 95iNQMbmxMRY6jULiHYkCzCkYKPEuysvBh5kEHayDxLs 8vzkK7vsdrS2gdPhLk72La8X4FJkgJ5kJLUBRbEVkReU

After succeeding, re run the get and sync commands and check that the public account has empty data and the private account data is Holamundo!.

12. Program composition: tail calls

Programs can chain calls to other programs when they return. This is the tail call or chained call mechanism. It is used by programs that depend on other programs.

The examples include a guest/src/bin/simple_tail_call.rs program that shows how to trigger this mechanism. It internally calls the first Hello World program with a fixed greeting: Hello from tail call.

Note

This program hardcodes the ID of the Hello World program. If something fails, check that this ID matches the one produced when building the Hello World program. You can see it in the output of cargo risczero build from the earlier sections of this tutorial. If it differs, update the ID in simple_tail_call.rs and build again.

As before, let's start by deploying the program

wallet deploy-program $EXAMPLE_PROGRAMS_BUILD_DIR/simple_tail_call.bin

We'll use the first public account of this tutorial. The one with account id BzdBoL4JRa5M873cuWb9rbYgASr1pXyaAZ1YW9ertWH9. This account is already owned by the Hello world program and its data reads Hola mundo!.

Let's run the tail call program

cargo run --bin run_hello_world_through_tail_call \
    $EXAMPLE_PROGRAMS_BUILD_DIR/simple_tail_call.bin \
    BzdBoL4JRa5M873cuWb9rbYgASr1pXyaAZ1YW9ertWH9

Once the transaction is processed, query the account values with:

wallet account get --account-id Public/BzdBoL4JRa5M873cuWb9rbYgASr1pXyaAZ1YW9ertWH9

You should se an output similar to

{
  "balance":0,
  "program_owner_b64":"fpnW4tFY9N6llZcBHaXRwu7xe+7WZnZX9RWzhwNbk1o=",
  "data_b64":"SG9sYSBtdW5kbyFIZWxsbyBmcm9tIHRhaWwgY2FsbA==",
  "nonce":0
}

Decoding the (base64 encoded) data

echo -n SG9sYSBtdW5kbyFIZWxsbyBmcm9tIHRhaWwgY2FsbA== | base64 -d

Output:

Hola mundo!Hello from tail call