Part 6: Transaction Scripts
In this section, you'll learn how to write transaction scripts - code that the account owner explicitly executes. We'll implement an initialization script that enables the bank to accept deposits.
The companion testnet example uses AuthSingleSig with Falcon512Poseidon2 and saves the bank owner's key in its keystore. This authentication component enforces ownership. The NoAuth component in our MockChain test helper is only for isolated tests and must not be used to deploy the bank to a live network.
What You'll Build in This Part
By the end of this section, you will have:
- Created the
init-tx-scripttransaction script project - Understood the
#[tx_script]attribute and function signature - Learned the difference between transaction scripts and note scripts
- Verified initialization works via a MockChain test
Building on Part 5
In Parts 4-5, you created note scripts that execute when notes are consumed. Now you'll create a transaction script - code the account owner explicitly runs:
┌────────────────────────────────────────────────────────────────────────┐
│ Script Types Comparison │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Note Scripts (Parts 4-5) Transaction Scripts (Part 6) │
│ ────────────────────────────── ────────────────────────────── │
│ Triggered by note consumption Attached to a transaction │
│ Receive account: &mut Wallet Receive account: &mut Wallet │
│ Read active_note:: context Run setup / owner operations │
│ Process incoming assets Authorized by account auth │
│ │
│ deposit-note/ init-tx-script/ │
│ └── account.deposit(...) └── account.initialize() │
│ │
└────────────────────────────────────────────────────────────────────────┘
Transaction Scripts vs Note Scripts
| Aspect | Transaction Script | Note Script |
|---|---|---|
| Initiation | Selected for the transaction | Triggered when note is consumed |
| Access | Account wrapper bindings | Account wrapper bindings |
| Use case | Setup, owner operations | Receiving messages/assets |
| Parameter | account: &mut Wallet | account: &mut Wallet |
| Context | Active account | Active account and active_note:: |
Use transaction scripts for:
- One-time initialization
- Admin/owner operations
- Operations that don't involve receiving notes
Use note scripts for:
- Receiving assets from other accounts
- Processing requests from other accounts
- Multi-party interactions
Step 1: Create the Transaction Script Project
Create a new directory for the transaction script:
mkdir -p contracts/init-tx-script/src
Step 2: Configure the Project Files
Like the account component and the note scripts, a transaction script needs three project files: Cargo.toml, miden-project.toml, and .cargo/config.toml.
Create the Cargo.toml:
[package]
name = "init-tx-script"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
miden = "=0.14.0"
Create the miden-project.toml:
[package]
name = "init-tx-script"
version = "0.1.0"
[lib]
kind = "tx-script"
path = "src/lib.rs"
namespace = "miden:base/[email protected]"
[dependencies]
miden-core = "*"
miden-protocol = "*"
bank-account = { path = "../bank-account" }
Create the .cargo/config.toml:
[build]
target = "wasm32-wasip2"
[target.wasm32-wasip2]
rustflags = ["--cfg", "miden"]
Key configuration:
kind = "tx-script"- Marks this as a transaction script (notaccount-componentornote)namespace = "miden:base/[email protected]"- The standard transaction-script namespace- The
bank-accountpath dependency lets the script call the account component through the interface embedded in its compiled package
Step 3: Implement the Transaction Script
Create the initialization script:
// Do not link against libstd (i.e. anything defined in `std::`)
#![no_std]
#![feature(alloc_error_handler)]
use miden::*;
/// Native (active) account this tx-script runs against: the bank-account `Bank` component.
#[account(bank_account::Bank)]
pub struct Wallet;
/// Initialize Transaction Script
///
/// This transaction script initializes the bank account, enabling deposits.
/// It must be executed by the bank account owner before any deposits can be made.
///
/// # Flow
/// 1. Transaction is created with this script attached
/// 2. Script executes in the context of the bank account
/// 3. Calls `account.initialize()` to enable deposits
/// 4. Bank is ready to process deposits after the transaction commits
///
/// # Arguments
/// * `_arg` - Transaction script argument (unused in this script)
/// * `account` - Mutable reference to the bank account (`Bank` component)
#[tx_script]
fn run(_arg: Word, account: &mut Wallet) {
account.initialize();
}
The #[account] Attribute and the Native Account
A transaction script runs against the transaction's native (active) account. The #[account(...)] attribute binds a wrapper struct to a component so the script can call that component's methods directly:
/// Native (active) account this tx-script runs against: the bank-account `Bank` component.
#[account(bank_account::Bank)]
pub struct Wallet;
This generates a Wallet type that wraps the bank-account Bank component. The #[tx_script] function then receives a &mut Wallet, giving it direct access to the component's public methods (such as initialize()).
The #[tx_script] Attribute
The #[tx_script] attribute marks the entry point for a transaction script:
#[tx_script]
fn run(_arg: Word, account: &mut Wallet) {
account.initialize();
}
Function Signature
| Parameter | Type | Description |
|---|---|---|
_arg | Word | Optional argument passed when executing |
account | &mut Wallet | Mutable reference to the native account |
The Wallet type is generated by the #[account(...)] attribute and provides access to the bound component's public methods.
The Native Account Binding
Both note scripts and transaction scripts bind the native account with #[account(bank_account::Bank)] and call its methods directly on the &mut Wallet parameter. The entrypoints below belong in separate note-script and transaction-script contracts; the note method goes inside an impl marked #[note]. The difference is the trigger and the available context:
// Note script: triggered by note consumption, has access to note context.
#[note_script]
fn run(self, _arg: Word, account: &mut Wallet) {
let depositor = active_note::get_sender(); // note context
for asset in active_note::get_initial_assets() {
account.deposit(depositor, asset); // native-account method
}
}
// Transaction script: explicitly run by the owner, no note context.
#[tx_script]
fn run(_arg: Word, account: &mut Wallet) {
account.initialize(); // native-account method
}
The Wallet wrapper provides:
- Direct method access without module prefixes
- Proper mutable/immutable borrowing
- Automatic native-account context binding
Step 4: Build the Transaction Script
Compiler 0.10 resolves and builds the bank-account path dependency automatically. The #[account(...)] macro reads the interface and procedure roots from the compiled dependency to generate the script's account bindings.
From the project root, build the transaction script directly:
cd contracts/init-tx-script
miden build --release
cd ../..
Expected output
Compiling init-tx-script v0.1.0
Finished `release` profile [optimized] target(s)
Account Deployment Pattern
A public account becomes visible on-chain when its first transaction commits. The bank's initialized flag is separate from deployment: it controls whether the bank's deposit and withdrawal methods can run.
The live example first consumes a funding note to obtain native tokens for fees. That transaction deploys the account while the bank is still uninitialized. The owner then submits the initialization script:
┌────────────────────────────────────────────────────────────────────────┐
│ Initialization Flow │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. OWNER SIGNS THE INITIALIZATION TRANSACTION │
│ ┌────────────────────────────────────────────┐ │
│ │ Account: Bank's AccountId │ │
│ │ Script: init-tx-script │ │
│ │ AuthSingleSig verifies the owner signature │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 2. TRANSACTION SCRIPT EXECUTES │
│ ┌────────────────────────────────────────┐ │
│ │ run(_arg, account) │ │
│ │ └── account.initialize() │ │
│ │ └── Sets the initialized flag to 1 │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 3. TRANSACTION COMMITS │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ bank_account::bank::initialized = [1, 0, 0, 0] │ │
│ │ Bank can now process deposits and withdrawals │ │
│ │ Funding already deployed the account in the live example │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘
Before initialization, other accounts can already create notes addressed to the bank, but its deposit and withdrawal methods reject them. After initialization, those methods can process the notes. A funding P2ID is handled by BasicWallet and does not credit any depositor's ledger balance.
Using Script Arguments
The _arg parameter can pass data to the script:
#[tx_script]
fn run(arg: Word, account: &mut Wallet) {
assert!(arg[0].as_canonical_u64() == 42, "Expected initialization argument");
account.initialize();
}
When creating the transaction, pass the argument with tx_script_args:
let tx_script_args = Word::from([42u32, 0, 0, 0]);
let tx_context = mock_chain
.build_transaction(bank_account.id())
.tx_script(init_tx_script)
.tx_script_args(tx_script_args) // Pass the argument
.build()?;
Try It: Verify Initialization Works
Let's test that the initialization transaction script correctly flips the initialized flag. The companion test file integration/tests/init_test.rs verifies exactly this:
use integration::helpers::{
build_project_in_dir, build_tx_script_from_package, create_testing_account_from_package,
AccountCreationConfig,
};
use miden_client::{
account::{component::{InitStorageData, StorageValueName}, StorageSlotName},
auth::AuthScheme,
Word,
};
use miden_testing::{Auth, MockChain};
use std::{path::Path, sync::Arc};
/// Companion test for Part 6 of the miden-bank tutorial. Verifies that running
/// the init transaction script flips the bank's `initialized` flag from 0 to 1.
///
/// The earlier tutorial parts rely on the bank deferring `require_initialized()`
/// enforcement, so this test exists to prove that once the guard is re-enabled
/// the init flow still works end-to-end before any deposits are accepted.
#[tokio::test]
async fn init_test() -> anyhow::Result<()> {
// Build the bank-account and init-tx-script contracts
let bank_package = Arc::new(build_project_in_dir(
Path::new("../contracts/bank-account"),
true,
)?);
let init_tx_script_package = Arc::new(build_project_in_dir(
Path::new("../contracts/init-tx-script"),
true,
)?);
// The `initialized` value slot has no schema default, so `AccountComponent::from_package`
// requires it to be seeded (with a zero Word = uninitialized) or it errors with
// `InitValueNotProvided`. The `balances` map slot defaults to empty.
let initialized_slot = StorageSlotName::new("bank_account::bank::initialized")
.expect("Valid slot name");
let bank_cfg = AccountCreationConfig {
init_storage_data: {
let mut data = InitStorageData::default();
data.insert_value(
StorageValueName::from_slot_name(&initialized_slot),
Word::default(),
)?;
data
},
..Default::default()
};
let mut bank_account =
create_testing_account_from_package(bank_package.clone(), bank_cfg)?;
// Verify bank starts uninitialized
let before = bank_account.storage().get_item(&initialized_slot)?;
assert_eq!(before[0].as_canonical_u64(), 0, "Bank should start uninitialized");
println!("Before init: initialized = {}", before[0].as_canonical_u64());
// Build mock chain
let mut builder = MockChain::builder();
builder.add_existing_basic_faucet(
Auth::BasicAuth {
auth_scheme: AuthScheme::Falcon512Poseidon2,
},
"TEST",
10_000_000,
Some(10),
)?;
builder.add_account(bank_account.clone())?;
let mut mock_chain = builder.build()?;
// Execute init transaction script
let init_tx_script = build_tx_script_from_package(init_tx_script_package.as_ref())?;
let init_tx_context = mock_chain
.build_transaction(bank_account.id())
.tx_script(init_tx_script)
.build()?;
let executed_init = init_tx_context.execute().await?;
mock_chain.add_pending_executed_transaction(&executed_init)?;
mock_chain.prove_next_block()?;
bank_account = mock_chain.committed_account(bank_account.id())?.clone();
// Verify initialized flag flipped to 1
let after = bank_account.storage().get_item(&initialized_slot)?;
assert_eq!(
after[0].as_canonical_u64(),
1,
"Bank should be initialized after running init tx script"
);
println!("After init: initialized = {}", after[0].as_canonical_u64());
println!("\nInit test passed!");
Ok(())
}
A few things to note in this test:
- The slot name is
bank_account::bank::initialized(the namespace isbank_account, notmiden_bank_account). - The
initializedvalue slot has no schema default, so it must be seeded viaInitStorageDataorAccountComponent::from_packageerrors withInitValueNotProvided. Only thebalancesmap slot defaults to empty. - A
kind = "tx-script"contract exposes its entry procedure with#[transaction_script]. Thebuild_tx_script_from_packagehelper loads it withTransactionScript::from_package.
Enable the Initialization Guard
Now that we have the init transaction script, it's time to enable the require_initialized() guard that we've had commented out since Part 2. Open contracts/bank-account/src/lib.rs and uncomment the guard in both the deposit() and withdraw() methods:
Before (Parts 2-5):
// NOTE: Initialization guard — enabled in Part 6 (Transaction Scripts)
// self.require_initialized();
After (Part 6 onward):
self.require_initialized();
With this change, deposits and withdrawals will fail unless the bank has been initialized via the transaction script. This is a critical security measure — it prevents assets from being deposited into an uninitialized bank.
Try It: Verify Initialization
From the project root, run the companion init test to verify the transaction script correctly flips the initialized flag:
cargo test --package integration --test init_test -- --nocapture
Expected output
Compiling integration v0.1.0 (/path/to/miden-bank/integration)
Finished `test` profile [unoptimized + debuginfo] target(s)
Running tests/init_test.rs
running 1 test
Before init: initialized = 0
After init: initialized = 1
Init test passed!
test init_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored
Your actual output may include additional trace lines from the Miden VM or MockChain. As long as you see the test passing, these can be safely ignored.
Account binding errors: Check that #[account(bank_account::Bank)] matches the dependency name and exported component trait. Declare the bank-account path under [dependencies] and remove legacy wit overrides. Rebuild the transaction script with miden build --release; the compiler builds its account dependency automatically.
"Dependency not found": Check that the bank-account path dependency in miden-project.toml points to the account project.
The MockChain test above is the source of truth for verifying this flow. The live-network bin (cargo run --bin initialize) also runs against a testnet node. It prints the new bank ID and waits while you request native tokens from the testnet faucet, then consumes the funding note and submits the signed initialization transaction.
What We've Built So Far
| Component | Status | Description |
|---|---|---|
bank-account | ✅ Complete | Full deposit logic with storage and constraints |
deposit-note | ✅ Complete | Note script that calls deposit method |
init-tx-script | ✅ Complete | Transaction script for initialization |
withdraw-request-note | Not started | Coming in Part 7 |
Complete Code for This Part
Click to see the complete init-tx-script code
// Do not link against libstd (i.e. anything defined in `std::`)
#![no_std]
#![feature(alloc_error_handler)]
use miden::*;
/// Native (active) account this tx-script runs against: the bank-account `Bank` component.
#[account(bank_account::Bank)]
pub struct Wallet;
/// Initialize Transaction Script
///
/// This transaction script initializes the bank account, enabling deposits.
/// It must be executed by the bank account owner before any deposits can be made.
///
/// # Flow
/// 1. Transaction is created with this script attached
/// 2. Script executes in the context of the bank account
/// 3. Calls `account.initialize()` to enable deposits
/// 4. Bank is ready to process deposits after the transaction commits
///
/// # Arguments
/// * `_arg` - Transaction script argument (unused in this script)
/// * `account` - Mutable reference to the bank account (`Bank` component)
#[tx_script]
fn run(_arg: Word, account: &mut Wallet) {
account.initialize();
}
[package]
name = "init-tx-script"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
miden = "=0.14.0"
[package]
name = "init-tx-script"
version = "0.1.0"
[lib]
kind = "tx-script"
path = "src/lib.rs"
namespace = "miden:base/[email protected]"
[dependencies]
miden-core = "*"
miden-protocol = "*"
bank-account = { path = "../bank-account" }
[build]
target = "wasm32-wasip2"
[target.wasm32-wasip2]
rustflags = ["--cfg", "miden"]
Key Takeaways
#[tx_script]marks the entry point with signaturefn run(_arg: Word, account: &mut Wallet)#[account(...)]binds aWalletwrapper to the native account's component, enabling direct method calls- Direct account access - Methods called on the
accountparameter, not via module imports - Authorization - The account's authentication component controls which transactions can update it; a transaction script alone does not enforce ownership
- Deployment pattern - First state change makes account visible on-chain
- Loading a transaction script -
build_tx_script_from_packageusesTransactionScript::from_packageto load the compiled entry procedure
See the complete transaction script implementation in contracts/init-tx-script/src/lib.rs.
Next Steps
Now that you understand transaction scripts, let's learn the advanced topic of creating output notes in Part 7: Creating Output Notes.