Tari Ootle is a decentralized smart contract platform. You write templates in Rust, compile to WASM, and deploy to the Tari L2 network. Deployed instances are called components and hold on-chain state. Follow these rules exactly.
Overview
Tari Ootle is a decentralized application platform built on the Tari Layer 2 network. You build templates (smart contracts) in Rust, compile them to WASM (wasm32-unknown-unknown), publish them to the network, and interact with deployed components (instances of templates) via transactions.
Key concepts:
- Template — A Rust module annotated with
#[template]that defines the logic and state structure. Compiled to WASM and deployed to the network. - Component — A live instance of a template on-chain. Holds state (struct fields) and exposes public methods.
- Resource — A native digital asset (fungible token or non-fungible NFT). Created with
ResourceBuilder. Cannot be copied or accidentally destroyed. - Vault — An on-chain container that holds exactly one type of resource. Must be stored in a component before the function returns.
- Bucket — A temporary container for resources during a transaction. Used to move assets between vaults or to/from method calls.
- Transaction — A set of instructions (CallFunction, CallMethod, etc.) that are signed, submitted, and executed atomically.
Crate ecosystem:
| Crate | Purpose | Used In |
|---|---|---|
tari_template_lib |
Core template library: prelude, ResourceBuilder, Vault, Bucket, ComponentManager, CallerContext, emit_event, rand, macros (args!, rule!, metadata!) |
Templates (WASM) |
tari_template_lib_types |
Shared types: Amount, ComponentAddress, ResourceAddress, NonFungibleId, AccessRule, OwnerRule, Metadata |
Templates & client |
tari_ootle_transaction |
TransactionBuilder and args! macro for constructing transactions |
Client & tests |
ootle-rs |
Client wallet and Indexer provider: sign, submit, watch transactions; builtin template helpers (faucet) | Client apps |
tari_template_test_tooling |
Local test harness (dev-dependency): compile templates to WASM and run against the engine in-process | Tests only |
Important macro distinction:
args!(fromtari_template_lib::prelude) — used inside templates for cross-template calls (alias forinvoke_args!)args!(fromtari_ootle_transaction) — used in test code and client code forTransactionBuilder,TemplateTest::call_function, andcall_method. ProducesVec<NamedArg>.
Getting Started Workflow
The typical development workflow for building on Tari Ootle:
Generate a template project from the official starter repo:
cargo generate https://github.com/tari-project/wasm-templateThis repo contains:
wasm_templates/— Blank template starters (e.g.,wasm_templates/empty)examples/— Complete working examples with templates and client apps (e.g.,examples/guessing_game/template,examples/guessing_game/cli)
When prompted, select the subfolder matching your needs.
Write your template in
src/lib.rsinside the#[template]module.Build to WASM:
cargo build --target wasm32-unknown-unknown --releaseTest locally using
tari_template_test_tooling(see Testing Templates).Publish to the network via the Wallet Web UI (see Publishing Templates).
Interact with your component using a client app built with
ootle-rs, the Wallet CLI, or a pre-built example CLI fromcargo generate.
Tip: For the guessing game and other example templates, pre-published template addresses are available on the Esmeralda testnet. Check the Tari Ootle guides for current addresses — you can skip publishing and go straight to interacting.
CLI App Workflow Order (IMPORTANT)
When running a CLI client app (e.g., the guessing game CLI), operations MUST happen in this order:
- Initialize wallet — Create keypairs, select network, connect to indexer
- Fund admin account — Get tTARI from the faucet
- Publish the template — Deploy the WASM to the network. Direct the user to the Wallet Web UI at
http://127.0.0.1:5100to publish. Do NOT write custom publish code — the Web UI handles fee estimation, upload, and provides the template address. - Create/deploy the game component — Instantiate the template on-chain (requires the template address from step 3)
- Register players — Add player accounts AFTER the game component exists
- Play — Start rounds, make guesses, end games
CRITICAL: Never register players or add users before the template is published and the game component is deployed. Players need a component to interact with. Do not add a publish command to an example CLI unless the user asks for one; for a one-off publish, direct them to the Wallet Web UI.
Tooling Requirements
The only tools needed for Ootle development are:
rustupwith thewasm32-unknown-unknowntargetcargo-generate(for scaffolding)- Standard Rust toolchain (
cargo build,cargo test) wasm-opt— optional, for checking or shrinking the binary yourself (see Minifying the WASM)
Do NOT install rust-analyzer extensions, cargo-expand, wasm-pack, wasm-bindgen, or other WASM/Rust analysis tools. They are unnecessary for Ootle development and add bloat. The
wasm32-unknown-unknowntarget and standardcargo buildare sufficient.
Non-Interactive CLI Usage
The generated CLI examples use dialoguer for interactive prompts (Select, Input), which requires a real TTY. Some agent runners execute commands in a non-interactive shell without a TTY.
When running CLI commands that have interactive prompts:
- Tell the user to run the command in their terminal rather than trying to run it through the agent runner
- Do NOT try to pipe input, use
expect, or wrap withscript— these are fragile workarounds - Do NOT modify the CLI to add non-interactive flags unless the user specifically asks for it
- If the CLI already supports
--flagstyle arguments that bypass prompts, use those
Writing a Template
Project Setup
# Install cargo-generate if not already installed
cargo install cargo-generate
# Generate a new template project from the official starter
cargo generate https://github.com/tari-project/wasm-template
The wasm-template repository offers multiple starting points:
wasm_templates/empty— A minimal blank template to start from scratchexamples/guessing_game/template— A complete guessing game template with testsexamples/guessing_game/cli— A ready-to-use CLI client for the guessing game
When you run cargo generate, select the subfolder that matches your goal. For a blank slate, choose a wasm_templates/ entry. For a working example to learn from, choose from examples/.
A generated template project looks like:
your_template/
├── Cargo.toml
├── src/
│ └── lib.rs # Template source code
└── tests/
└── test.rs # Unit tests
The generated Cargo.toml must include:
[package]
name = "your_template"
version = "0.1.0"
edition = "2024"
[dependencies]
tari_template_lib = "0.31"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = 's' # Optimize for size.
lto = true # Enable Link Time Optimization.
codegen-units = 1 # Reduce number of codegen units to increase optimizations.
panic = 'abort' # Abort on panic.
strip = true # Strip symbols and debug info.
CRITICAL: The
crate-type = ["cdylib"]is required for WASM compilation. Without it, the build will not produce a.wasmfile.
CRITICAL: Keep
crate-type = ["cdylib"]and nothing else. Addingrlibalongside it — a common attempt to make template code unit-testable — stops Cargo applyinglto = trueto the target, so the[profile.release]settings below quietly stop shrinking the binary. To unit-test pure logic (tallies, state machines), put it in a separate crate that both the template and the tests depend on.
Tip: The
[profile.release]section inCargo.tomlsignificantly reduces the size of the compiled WASM file, which lowers the fees required for on-chain storage and publishing.
Versions:
0.31is the currenttari_template_librelease on crates.io. Use the minor version (e.g."0.31"not"0.31.1") to pick up patches automatically, and check crates.io for a newer minor before starting a new template. Keep it in step withtari_template_test_tooling(see Test Setup) — the test harness compiles your template against its own copy oftari_template_lib, and a mismatched pair builds the two halves against different copies of the library types.
Compilation
# Add the WASM target (one-time setup)
rustup target add wasm32-unknown-unknown
# Build the template
cargo build --target wasm32-unknown-unknown --release
Output: target/wasm32-unknown-unknown/release/your_template_name.wasm
Minifying the WASM
Publishing through the wallet daemon — the Web UI, or the transactions.publish_template JSON-RPC method — runs wasm-opt over the binary for you and stores the optimized result, so a Web UI publish is already minified. Run it yourself to check the size before publishing, or when a client publishes a raw binary with TransactionBuilder::publish_template, which stores exactly the bytes it is handed:
wasm-opt -Oz --enable-bulk-memory \
--strip-debug --strip-producers --strip-target-features \
target/wasm32-unknown-unknown/release/your_template_name.wasm \
-o target/wasm32-unknown-unknown/release/your_template_name.min.wasm
--enable-bulk-memoryis required: rustc'swasm32-unknown-unknownoutput uses bulk-memory operations andwasm-optrejects the module without it.The strip flags are not optional on a raw publish. The engine accepts no custom section other than
tari_tdef, and rustc emitsname,producersandtarget_featuressections — a binary that still carries them is rejected at publish time.Check the size before publishing:
ls -la target/wasm32-unknown-unknown/release/*.wasm. Only the first 96 KiB of a template are priced as ordinary storage; every whole kilobyte beyond that is charged quadratically, so keeping the binary near or below 96 KiB is what keeps a publish cheap — see Publishing Templates.
Template Structure
Every template follows this pattern:
use tari_template_lib::prelude::*;
#[template]
mod my_template {
use super::*;
// Component state — all fields must be serde-serializable
pub struct MyComponent {
my_vault: Vault,
counter: u64,
}
impl MyComponent {
// CONSTRUCTOR: any function returning Self or Component<Self>
// creates a new component instance on-chain when called via CallFunction
pub fn new() -> Component<Self> {
let token = ResourceBuilder::public_fungible()
.with_token_symbol("TOK")
.build(); // → ResourceAddress
Component::new(Self {
my_vault: Vault::new_empty(token),
counter: 0,
})
.with_access_rules(
ComponentAccessRules::new()
.method("public_method", rule!(allow_all))
.default(rule!(deny_all))
)
.create()
}
// PUBLIC METHOD: takes &self or &mut self, called via CallMethod
pub fn public_method(&mut self, value: u64) {
self.counter += value;
}
// READ-ONLY METHOD: takes &self, cannot modify state
pub fn get_counter(&self) -> u64 {
self.counter
}
// FUNCTION (no &self): called via CallFunction on the template, not a method on a component
pub fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
}
// Private helper: no &self, not in impl block's public interface
fn internal_helper() -> u64 {
42
}
}
The #[template] Macro
- Generates the WASM ABI so the validator engine can call your code
- Exposes public functions in
implblocks as callable methods/functions - Handles state serialization/deserialization automatically
- Structs defined inside the
#[template]module automatically get serde derives - Only one
#[template]module per crate
Component State Rules
- All struct fields MUST be serde-serializable (types from
tari_template_libalready are) - Use
&mut selfmethods to modify state - Use
&selfmethods for read-only access - A component can also be an
enum - State size affects transaction costs
- Supported field types: all Rust primitives,
String,Vec<T>,HashMap<K,V>,BTreeMap<K,V>,Option<T>,Vault,Amount,ResourceAddress,ComponentAddress,RistrettoPublicKeyBytes,NonFungibleId,ComponentManager, and any struct inside the#[template]module
Constructors
// Simple constructor — returning Self creates the component with default rules
pub fn new_simple() -> Self {
Self { counter: 0 }
}
// Explicit constructor — returns Component<Self> for full control
pub fn new_explicit() -> Component<Self> {
Component::new(Self { counter: 0 })
.with_access_rules(ComponentAccessRules::new()
.method("do_something", rule!(allow_all))
.default(rule!(deny_all))
)
.with_owner_rule(OwnerRule::OwnedBySigner)
.create()
}
// Constructor with address allocation — allows creating and calling in one transaction
pub fn new_with_allocation(addr: ComponentAddressAllocation) -> Component<Self> {
Component::new(Self { counter: 0 })
.with_address_allocation(addr)
.with_access_rules(ComponentAccessRules::new()
.method("do_something", rule!(allow_all))
.default(rule!(deny_all))
)
.create()
}
// Constructor with public key address — deterministic address from a public key
pub fn new_with_public_key() -> Component<Self> {
let pk = CallerContext::transaction_signer_public_key();
Component::new(Self { counter: 0 })
.with_public_key_address(pk)
.with_access_rules(ComponentAccessRules::allow_all())
.create()
}
Error Handling
Errors in templates are handled by panicking. When a panic occurs, the transaction fails atomically and no state changes are committed:
pub fn do_something(&mut self, value: u64) {
assert_ne!(value, 0, "Value cannot be zero");
assert!(value <= 1024, "Value too large");
if !value.is_power_of_two() {
panic!("Value must be a power of two");
}
self.counter += value;
}
There are no Result-based error flows in templates. Panics are the error mechanism.
Resources
There are 4 resource types:
- Public Fungible — Interchangeable tokens (like ERC-20), amounts visible on-chain
- Public Non-Fungible — Unique tokens (like ERC-721), metadata visible on-chain
- Confidential — Fungible tokens with hidden amounts (Pedersen commitments)
- Stealth — Confidential UTXOs with hidden owners (TARI/tTARI is a stealth resource)
Creating Resources
// ─── Public Fungible Token ───
// Without initial supply → returns ResourceAddress
let token_addr: ResourceAddress = ResourceBuilder::public_fungible()
.with_token_symbol("TOK")
.metadata("name", "My Token")
.build();
// With initial supply → returns Bucket containing tokens
let token_bucket: Bucket = ResourceBuilder::public_fungible()
.with_token_symbol("TOK")
.metadata("name", "My Token")
.initial_supply(Amount::from(1000))
.build();
// ─── Non-Fungible (NFT) ───
let nft_addr: ResourceAddress = ResourceBuilder::non_fungible()
.with_token_symbol("NFT")
.metadata("name", "My NFT Collection")
.build(); // Mint later via ResourceManager
// With initial supply of NFTs
let nft_bucket: Bucket = ResourceBuilder::non_fungible()
.with_token_symbol("NFT")
.initial_supply_with_data(vec![
(NonFungibleId::from_u64(1), &metadata!["name" => "First"], &()),
(NonFungibleId::from_u64(2), &metadata!["name" => "Second"], &()),
])
.build();
// ─── Confidential Fungible ───
let conf_addr: ResourceAddress = ResourceBuilder::confidential()
.with_token_symbol("cTOK")
.with_view_key(CallerContext::transaction_signer_public_key())
.build();
// ─── Stealth (like TARI) ───
let stealth_addr: ResourceAddress = ResourceBuilder::stealth()
.with_token_symbol("sTOK")
.build();
Resource Builder Options (All Types)
ResourceBuilder::public_fungible() // or non_fungible(), confidential(), stealth()
// Metadata
.with_token_symbol("SYM") // Token symbol (displayed by explorers)
.metadata("name", "Token Name") // Add metadata key-value pair
.add_metadata("key", "value") // Same as .metadata()
// Access rules on the resource itself
.mintable(rule!(resource(admin_badge))) // Who can mint new tokens (default: deny_all)
.burnable(rule!(allow_all)) // Who can burn tokens (default: deny_all)
.recallable(rule!(deny_all)) // Who can forcefully recall from vaults
.freezable(rule!(deny_all)) // Who can freeze vaults holding this resource
.withdrawable(rule!(allow_all)) // Who can withdraw (default: allow_all)
.depositable(rule!(allow_all)) // Who can deposit (default: allow_all)
.update_non_fungible_data(rule!(...)) // Who can update NFT mutable data
.update_access_rules(rule!(...)) // Who can change these rules later
// Ownership
.with_owner_rule(OwnerRule::OwnedBySigner) // Owner of the resource definition
// Advanced
.with_divisibility(2) // Fungible only: decimal places (default: 0)
.disable_total_supply_tracking() // Don't track total supply on-chain
.with_address_allocation(alloc) // Pre-allocated resource address
// Finalize
.build() // Create the resource (returns ResourceAddress)
.initial_supply(Amount::from(1000)) // Mint initial tokens (changes return to Bucket)
Minting NFTs After Creation
let manager: ResourceManager = vault.get_resource_manager();
// Mint a single NFT
let nft_bucket: Bucket = manager.mint_non_fungible(
NonFungibleId::from_string("unique-id"), // Unique ID within this resource
&metadata!["name" => "My NFT"], // Immutable data (cannot change after mint)
&(), // Mutable data (can be updated later)
);
// NonFungibleId variants:
NonFungibleId::from_string("my-id") // String (max 64 chars)
NonFungibleId::from_u64(42) // u64
NonFungibleId::from_u32(1) // u32
NonFungibleId::from_u256([0u8; 32]) // 32-byte array
NonFungibleId::random() // Random UUID-style
Vaults — Complete API
// ─── Creation ───
let vault = Vault::new_empty(resource_address); // Empty vault for a resource type
let vault = Vault::from_bucket(bucket); // Create vault containing bucket's tokens
// ─── Deposits ───
vault.deposit(bucket); // Add tokens from bucket into vault
// ─── Withdrawals ───
let bucket = vault.withdraw(amount); // Withdraw fungible amount → Bucket
let bucket = vault.withdraw(1u64); // Can pass u64 directly
let bucket = vault.withdraw_non_fungible(nft_id); // Withdraw one NFT by ID → Bucket
let bucket = vault.withdraw_non_fungibles(id_set); // Withdraw multiple NFTs → Bucket
let bucket = vault.withdraw_all(); // Withdraw everything → Bucket
// ─── Queries ───
let balance: Amount = vault.balance(); // Current balance
let locked: Amount = vault.locked_balance(); // Locked/frozen balance
let addr: ResourceAddress = vault.resource_address();// Resource type held by this vault
let ids: BTreeSet<NonFungibleId> = vault.get_non_fungible_ids(); // All NFT IDs in vault
// ─── Resource Management ───
let manager: ResourceManager = vault.get_resource_manager(); // For minting etc.
// ─── Fee Payment ───
vault.pay_fee(amount); // Pay transaction fee from this vault
// ─── Authorization ───
vault.authorize(); // Create auth proof from vault contents (RAII)
let proof = vault.create_proof_by_amount(amount); // Create proof for a specific amount
CRITICAL: A
VaultMUST be stored in a component struct field before the function returns. An orphaned vault (created but not stored) will cause the transaction to fail.
Buckets — Complete API
// ─── Queries ───
let addr: ResourceAddress = bucket.resource_address(); // What resource this holds
let rtype: ResourceType = bucket.resource_type(); // Fungible, NonFungible, etc.
let amt: Amount = bucket.amount(); // How many tokens
let empty: bool = bucket.is_empty(); // Whether empty
let ids = bucket.get_non_fungible_ids(); // NFT IDs in bucket
let nfts = bucket.get_non_fungibles(); // Full NFT data
// ─── Splitting ───
let new_bucket = bucket.take(Amount::from(50)); // Split off some tokens
// ─── Combining ───
let combined = bucket.join(other_bucket); // Merge two same-resource buckets
// ─── Destruction ───
bucket.burn(); // Permanently destroy tokens
bucket.drop_empty(); // Assert empty and drop (panics if not)
// ─── Proofs ───
let proof = bucket.create_proof(); // Create ownership proof
CRITICAL: A
BucketMUST be consumed before the function returns. Consume it by: depositing into a vault, burning, returning from a function, or passing to another component. An orphaned bucket will cause the transaction to fail.
Access Rules and Authorization
Component Access Rules
Component::new(Self { ... })
.with_access_rules(
ComponentAccessRules::new() // Default: deny_all for unlisted methods
.method("guess", rule!(allow_all))
.method("admin_action", rule!(resource(admin_badge)))
.default(rule!(deny_all))
)
// OR use the convenience constructor:
// ComponentAccessRules::allow_all() // Default: allow_all for unlisted methods
.with_owner_rule(OwnerRule::OwnedBySigner)
.create()
The rule! Macro — Complete Reference
// ─── Basic Rules ───
rule!(allow_all) // No restrictions
rule!(deny_all) // Nobody can call
// ─── Resource-Based Rules ───
rule!(resource(resource_address)) // Must hold this resource in a proof
rule!(non_fungible(NonFungibleAddress::new(res, id))) // Must hold specific NFT
rule!(public_key(ristretto_public_key_bytes)) // Must be signed by this key
// ─── Scope Rules ───
rule!(component(component_address)) // Only callable from this component
rule!(template(template_address)) // Only callable from this template
// ─── Composite Rules ───
rule!(any_of(resource(a), resource(b))) // Any one condition met (OR)
rule!(all_of(resource(a), resource(b))) // All conditions met (AND)
rule!(m_of_n(2, resource(a), resource(b), resource(c)))// M of N conditions met
Owner Rules
OwnerRule::OwnedBySigner // Default: transaction signer is owner
OwnerRule::None // No owner (nobody can update access rules)
OwnerRule::ByAccessRule(rule) // Custom rule determines ownership
OwnerRule::ByPublicKey(pk) // Specific public key is owner
Caller Context — Complete API
// Get the authenticated signer (ALWAYS use this for identity, never accept as argument)
let signer: RistrettoPublicKeyBytes = CallerContext::transaction_signer_public_key();
// Get current component address (only in CallMethod context)
let addr: ComponentAddress = CallerContext::current_component_address();
// Get signer proof (for passing as authorization)
let proof: Proof = CallerContext::get_main_signer_proof();
let proof: Proof = CallerContext::get_signer_proof_for_public_key(pk);
// Address allocation (for creating components/resources with deterministic addresses)
let alloc: ComponentAddressAllocation = CallerContext::allocate_component_address(None);
let alloc: ComponentAddressAllocation = CallerContext::allocate_component_address(Some(pk));
let alloc: ResourceAddressAllocation = CallerContext::allocate_resource_address();
NEVER accept a public key as a method argument for authentication. Always use
CallerContext::transaction_signer_public_key()— it cannot be spoofed.
Cross-Component Calls
// Get a reference to another component
let other: ComponentManager = ComponentManager::get(component_address);
// Call a method that returns a value
let value: u64 = other.call("method_name", args![arg1, arg2]);
// Call a method that returns unit (fire-and-forget)
other.invoke("method_name", args![arg1, arg2]);
// Common pattern: deposit a bucket into another component (e.g., Account)
other.invoke("deposit", args![prize_bucket]);
// Get template address of a component
let tmpl: TemplateAddress = other.get_template_address();
// Get the address
let addr: ComponentAddress = other.component_address();
Events
// Emit an event (permanently recorded in the transaction receipt)
emit_event("GameEnded", metadata![
"winner_account" => winner_address.to_string(),
"number" => winning_number.to_string(),
"round" => round.to_string(),
]);
Events are indexed by the Indexer and can be queried by explorers and dApps. The topic is formatted as "TemplateName.EventTopic" in receipts.
Randomness
use tari_template_lib::rand::random_bytes;
// Get N pseudorandom bytes
let bytes: Vec<u8> = random_bytes(4);
// Convenience: get a random u32
use tari_template_lib::rand::random_u32;
let n: u32 = random_u32();
// Common pattern: random number in range
fn generate_number() -> u8 {
random_bytes(1)[0] % 11 // 0..=10
}
WARNING:
random_bytesis deterministic — entropy comes from the transaction itself to ensure all validators produce the same result. Do NOT use for cryptographic security. You cannot use therandcrate in templates (no entropy source onwasm32-unknown-unknown).
Publishing Templates
Publishing is the most expensive operation you will run — every validator stores the WASM permanently, so the fee is dominated by binary size. Fees are denominated in microtari (µT); 1 tTARI = 1,000,000 µT. At current testnet rates a publish costs a flat 250,000 µT, plus the first 96 KiB of the binary at the per-byte storage rate, plus a quadratic premium of
100 µT × units²whereunitsis the number of whole kilobytes beyond 96 KiB. On top of that every validator Cranelift-compiles the binary, charged as execution points at 140,000 µT plus 2.1 µT per binary byte — for a 96 KiB template that roughly doubles the bill, to ~0.72 tTARI all-in. Binaries over 1 MiB are rejected outright. Shrink the binary first (see Minifying the WASM) and take the fee from a dry run (see Fee Estimation (Dry-Run)) or the Web UI's "Estimate Fee" button — never a hardcoded number. Do NOT add apublishsubcommand to an example CLI; a client app may publish programmatically when the fee comes from a dry-run estimate.
Publish via Wallet Web UI (Easiest for a One-Off Publish)
- Open the Tari Ootle Wallet web UI (default:
http://127.0.0.1:5100) - Click "Publish Template" on the Home page
- Select fee account with tTARI (testnet Tari)
- Upload the
.wasmfile fromtarget/wasm32-unknown-unknown/release/ - Click "Estimate Fee" then "Publish Template"
- Find the template address under "Templates" in the sidebar
- Paste the template address into the CLI's state file or
--template-addressflag
Publish Programmatically (ootle-rs)
Programmatic publishing is fine for a client app when the fee comes from a dry-run estimate, never from a hardcoded value — see Fee Estimation (Dry-Run).
use tari_ootle_transaction::TransactionBuilder;
use ootle_rs::TransactionRequest;
let wasm_binary: Vec<u8> = std::fs::read("target/wasm32-unknown-unknown/release/your_template.wasm")?;
let build = |max_fee: u64| TransactionBuilder::new(provider.network())
.with_auto_fill_inputs()
.pay_fee_from_component(account_addr, max_fee)
.publish_template(wasm_binary.clone().try_into().unwrap())
.build_unsigned();
// Dry-run with a generous max_fee, then submit with the fee it reports.
let required = provider
.sign_and_send_dry_run(build(50_000_000))
.await?
.finalize
.required_fees();
let tx = TransactionRequest::default()
.with_transaction(build(required))
.build(provider.wallet())
.await?;
let receipt = provider.send_transaction(tx).await?.watch().await?;
// Get the new template address from the receipt
let template_addr = receipt.diff_summary.upped
.iter()
.find_map(|s| s.substate_id.as_template())
.expect("template address in receipt");
Fee guidance for publishing: the cost follows the binary size, not what the template does — see the rates above. Take the exact figure from a dry run: from ootle-rs,
IndexerProvider::sign_and_send_dry_runreturns anExecuteResultwhosefinalize.required_fees()is the minimum a real submission may carry; over JSON-RPC,transactions.publish_templatewithdry_run: truereturnsdry_run_fee.
Fee Estimation (Dry-Run)
Never hardcode a fee in client code. Dry-run the transaction, then submit with what the dry run reports:
// 1. Build with a generous max_fee. A dry run is metered at whatever max_fee it
// carries, so one that is too small aborts on fee exhaustion instead of
// reporting the real cost.
let unsigned = TransactionBuilder::new(provider.network())
.with_auto_fill_inputs()
.pay_fee_from_component(account_addr, 10_000_000u64)
.call_method(component_addr, "some_method", args![])
.build_unsigned();
// 2. Dry-run it. The result is the ExecuteResult a real run would produce.
let result = provider.sign_and_send_dry_run(unsigned).await?;
let required = result.finalize.required_fees();
// 3. Rebuild with the reported fee and submit that.
let unsigned = TransactionBuilder::new(provider.network())
.with_auto_fill_inputs()
.pay_fee_from_component(account_addr, required)
.call_method(component_addr, "some_method", args![])
.build_unsigned();
The max_fee the dry run carries has to be generous but still within the paying account's balance — it is withdrawn from the fee vault for the run, so a dry run cannot be metered above what the account holds.
required_fees() is a floor, not an estimate to pad: it is what the dry run was charged plus a small allowance for the metering drift that carrying a different max_fee causes, so no margin multiplier is needed. Submitting above the floor is harmless when an account vault pays — the unspent remainder is returned to that vault — but a fee paid purely by a stealth reveal keeps no change, so pay close to the floor there.
The wallet daemon exposes the same estimate over JSON-RPC: transactions.publish_template with dry_run: true returns dry_run_fee, and transactions.submit_dry_run returns the full result. The Web UI's "Estimate Fee" button runs a dry run too.
The pattern applies to every transaction type: faucet claims, publishes, method calls and stealth spends.
Interacting with Deployed Components (Client-Side Rust)
Setup Wallet and Provider
use ootle_rs::{
key_provider::PrivateKeyProvider,
provider::ProviderBuilder,
wallet::OotleWallet,
default_indexer_url,
};
use tari_ootle_common_types::Network;
const NETWORK: Network = Network::Esmeralda; // Testnet (default_indexer_url is configured)
// Create a random wallet (for testing) or load from seed
let secret = PrivateKeyProvider::random(NETWORK);
let wallet = OotleWallet::from(secret);
let mut provider = ProviderBuilder::new()
.wallet(wallet)
.connect(default_indexer_url(NETWORK))
.await?;
// With custom transaction timeout (default is 32 seconds — too short for testnet):
use std::time::Duration;
let mut provider = ProviderBuilder::new()
.wallet(wallet)
.connect_with_transaction_timeout(default_indexer_url(NETWORK), Duration::from_secs(120))
.await?;
Timeout guidance: The default transaction timeout is 32 seconds, which is often too short for the Esmeralda testnet. Use
connect_with_transaction_timeout()with 120 seconds for testnet usage. LocalNet is faster and the default is usually fine.
Available networks:
Network::Esmeralda— Public testnet (indexer:http://217.182.93.35:50124)Network::LocalNet— Local development (indexer:http://localhost:12500)- Other networks (MainNet, StageNet, NextNet, Igor) are not yet configured with default indexer URLs
Fund Account (Testnet Faucet)
use ootle_rs::{
TransactionRequest,
builtin_templates::{UnsignedTransactionBuilder, faucet::IFaucet},
};
use tari_template_lib_types::constants::TARI;
let unsigned_tx = IFaucet::new(&provider)
.take_faucet_funds(10 * TARI) // Request 10 TARI
.pay_fee(500u64) // Fee for the transaction
.prepare()
.await?;
let tx = TransactionRequest::default()
.with_transaction(unsigned_tx)
.build(provider.wallet())
.await?;
let pending = provider.send_transaction(tx).await?;
let outcome = pending.watch().await?;
Transaction Pattern (Sign → Send → Watch)
Every on-chain interaction follows this pattern:
use tari_ootle_transaction::{TransactionBuilder, args};
use ootle_rs::TransactionRequest;
// 1. Build an unsigned transaction
let unsigned_tx = TransactionBuilder::new(provider.network())
.with_auto_fill_inputs() // Auto-detect input substates
.pay_fee_from_component(account_addr, 2000u64) // Placeholder — take this from a dry run
.call_function(template_addr, "new", args![]) // Or call_method(...)
.build_unsigned();
// 2. Sign it
let tx = TransactionRequest::default()
.with_transaction(unsigned_tx)
.build(provider.wallet())
.await?;
// 3. Send and wait for finalization
let pending = provider.send_transaction(tx).await?;
let receipt = pending.watch().await?;
The fees in these examples are placeholders. Real client code takes the fee from a dry run (see Fee Estimation (Dry-Run)); a hardcoded amount is either wasteful or fails the transaction.
Call a Template Function (Create Component)
let unsigned_tx = TransactionBuilder::new(provider.network())
.with_auto_fill_inputs()
.pay_fee_from_component(account_addr, 2000u64)
.call_function(template_addr, "new", args![])
.build_unsigned();
Call a Component Method
let unsigned_tx = TransactionBuilder::new(provider.network())
.with_auto_fill_inputs()
.pay_fee_from_component(account_addr, 2000u64)
.call_method(component_addr, "start_game", args![nft_id])
.build_unsigned();
TransactionBuilder — Key Methods
TransactionBuilder::new(network)
// Input handling
.with_auto_fill_inputs() // Auto-detect required substates
.add_input(substate_address) // Add specific input
.with_inputs(iter_of_inputs) // Add multiple inputs
.with_unversioned_inputs(iter) // Add unversioned inputs
// Fee payment
.pay_fee_from_component(account, amount) // Pay fee from an account component
.pay_fee_from_bucket(bucket_label, amount) // Pay fee from a workspace bucket
// Instructions
.call_function(template, "fn_name", args![...]) // Call template function
.call_method(component, "method", args![...]) // Call component method
.create_account(public_key) // Create an account component
.create_account_with_bucket(pk, bucket_label) // Create account with initial funds
.publish_template(wasm_binary) // Deploy a template
// Workspace (chain instruction outputs)
.put_last_instruction_output_on_workspace("label") // Store output for later use
.take_from_bucket("label", amount) // Take from workspace bucket
// Address allocation
.allocate_component_address() // Pre-allocate component address
.allocate_resource_address() // Pre-allocate resource address
// Build
.build_unsigned() // Produce unsigned transaction
Read Addresses from Receipts
// Find the new component address
let component_addr = receipt.diff_summary.upped
.iter()
.find_map(|s| s.substate_id.as_component_address())
.expect("component address in receipt");
// Find a resource address (excluding native TARI)
use tari_template_lib_types::constants::TARI_TOKEN;
let resource_addr = receipt.diff_summary.upped
.iter()
.find_map(|s| s.substate_id.as_resource_address().filter(|a| *a != TARI_TOKEN))
.expect("resource address in receipt");
// Find a template address (returns PublishedTemplateAddress)
// IMPORTANT: Use as_template() on SubstateId — NOT as_template_address()
// as_template_address() does NOT exist on SubstateId
let template_addr = receipt.diff_summary.upped
.iter()
.find_map(|s| s.substate_id.as_template())
.expect("template address in receipt");
IMPORTANT API note: On
SubstateId, the method isas_template()— it returnsOption<PublishedTemplateAddress>. There is NOas_template_address()method onSubstateId. If you need the underlyingTemplateAddress(aHash32), call.as_template_address()on thePublishedTemplateAddressresult, not on theSubstateId.
Read Events from Receipts
let event = receipt.events
.iter()
.find(|e| e.topic() == "GuessingGame.GameEnded")
.expect("event in receipt");
let value = event.get_payload("field_name");
Manual Inputs
When the transaction touches vaults/components that auto-fill can't detect, add them manually:
let unsigned_tx = TransactionBuilder::new(provider.network())
.with_auto_fill_inputs()
.add_input(specific_substate_address)
.with_inputs(addresses.iter().copied().map(Into::into))
.pay_fee_from_component(account_addr, 2000u64)
.call_method(component_addr, "end_game", args![])
.build_unsigned();
Testing Templates
Use tari_template_test_tooling as a dev-dependency. It compiles your template to WASM and runs transactions against it locally using the same execution engine as the network.
Test Setup
Add to your test crate's Cargo.toml:
[dev-dependencies]
tari_template_test_tooling = "0.40"
tari_template_test_toolingre-exports the transaction crate, so usetari_template_test_tooling::transaction::args(and the other transaction items) in tests rather than adding `t
…(truncated)