Stealth Transfers
Covered in this guide
Section titled “Covered in this guide”In this guide, you will:
- Understand the privacy properties of stealth transfers: what is hidden and what is not.
- Learn the structure of a stealth transfer: inputs, outputs, revealed vs confidential funds.
- Understand how stealth addresses protect receiver identity.
- Learn about spend conditions:
Signed(public key) vsAccessRule(multisig, component-scoped, etc.). - Learn about encrypted data and memos attached to outputs.
- See how to execute stealth transfers programmatically in WASM templates.
- Walk through a complete stealth transfer example using
ootle-rs.
What is a Stealth Transfer?
Section titled “What is a Stealth Transfer?”A stealth transfer moves funds between parties while hiding who owns what and how much is being transferred. Unlike public transfers where amounts and addresses are visible on-chain, stealth transfers use cryptographic commitments and one-time addresses to keep transaction details private.
The native tTARI (testnet) / $TARI (mainnet) token is a stealth resource. Any custom resource can also be created as stealth.
Privacy Properties
Section titled “Privacy Properties”| Property | Hidden? | How |
|---|---|---|
| Transfer amount | Yes | Pedersen commitments hide the value; range proofs verify a valid value range (non-negative), balance proof signature ensures no inflation |
| UTXO owner | Yes | One-time stealth addresses derived via Diffie-Hellman; no on-chain link to the recipient’s public key |
| Sender/Receiver identity | Yes | See Privacy Gotchas below |
| Memo content | Yes | Encrypted with XChaCha20-Poly1305; only the recipient can decrypt |
| Revealed amounts | No | Revealed inputs/outputs are public by design |
Anatomy of a Stealth Transfer
Section titled “Anatomy of a Stealth Transfer”A stealth transfer consists of inputs (funds being spent) and outputs (funds being created). Each side can contain both confidential and revealed components.
The engine enforces that the sum of all inputs (confidential + revealed) equals the sum of all outputs (confidential + revealed). This is proven cryptographically via a balance proof without revealing any individual amounts.
Revealed Funds
Section titled “Revealed Funds”Not all parts of a stealth transfer need to be private. Revealed inputs and outputs carry a public, plaintext amount. Both are optional — a transfer can be fully confidential (no revealed amounts at all), fully revealed, or a mix of both.
Why Use Revealed Funds?
Section titled “Why Use Revealed Funds?”The primary use case is paying transaction fees. Fees must be paid in a known amount so that validators can verify them, making revealed outputs essential.
Other use cases include:
- Depositing into a component vault — if a component method expects a
Bucketwith a specific amount, the revealed output provides that bucket. - Interacting with public components — bridging confidential funds into the public smart contract layer.
Revealed Inputs
Section titled “Revealed Inputs”A revealed input is a public amount sourced from a Bucket. In practice, this usually comes from
a vault withdrawal or a faucet.
When building a stealth transfer, you declare how much revealed funding you expect:
StealthTransfer::new(tari_token, &provider) // The transfer expects 10 TARI + 1000 microtari // from a bucket (e.g. a faucet). .spend_revealed_input(10 * TARI + 1000) // ...Inside a template, the revealed input bucket is passed to the stealth transfer:
let input_bucket = self.vault.withdraw(transfer.inputs_statement.revealed_amount);self.manager .stealth_transfer_with_opt_input_bucket(transfer, Some(input_bucket));Revealed Outputs
Section titled “Revealed Outputs”When a stealth transfer includes a revealed output amount, the engine returns a Bucket containing
that amount. This bucket can then be used like any other bucket — deposited into a vault, used to pay fees, or
passed to another instruction.
StealthTransfer::new(tari_token, &provider) // ... // Output 500 microtari as a revealed (public) bucket for fees. .to_revealed_output(500u64) // ...A common pattern is to place the revealed output bucket on the workspace and use it to pay fees:
let unsigned_tx = Transaction::builder(network) .with_fee_instructions_builder(|builder| { builder .stealth_transfer(tari_token, transfer) .put_last_instruction_output_on_workspace("fees") .pay_fee_from_bucket("fees") }) .build_unsigned();Stealth Addresses
Section titled “Stealth Addresses”When you send funds to another party, you never put their real public key in the output. Instead, a one-time stealth address is derived for each output using a Diffie-Hellman key exchange:
- The sender generates a random ephemeral nonce r.
- The sender computes the shared secret: c = H(r · K) where K is the recipient’s public key.
- The stealth public key is: P = c·G + K
- The corresponding private key is: p = c + k (only the recipient can compute this, since only they know k).
The ephemeral public nonce R = r·G is stored alongside the output so that the recipient can recompute the shared secret and derive the spending key.
This means:
- Each output has a unique address that cannot be linked to the recipient’s real public key by an outside observer.
- The recipient scans new outputs by trying to derive the stealth key using their private key. If it matches the output’s spend condition, the output belongs to them.
- The sender cannot spend the output after sending it (they don’t know k).
Spend Conditions
Section titled “Spend Conditions”Every stealth UTXO has a spend condition that determines what authorization is required to spend it. When a stealth input is consumed in a transfer, the engine validates the spend condition before allowing the spend.
There are two variants:
Signed — Public Key Authorization
Section titled “Signed — Public Key Authorization”The default and most common spend condition. The UTXO can only be spent by proving ownership of a specific public key via a transaction signature:
SpendCondition::Signed(public_key)This is typically the one-time stealth public key derived during the stealth address exchange (see above). Only the recipient — who can derive the corresponding private key — can produce a valid signature.
At validation time, the engine checks that the transaction’s authorization scope contains a proof (badge) matching
NonFungibleAddress::from_public_key(pk). If the signature is missing, the transaction fails with
RequiredSignatureMissingForStealthUtxo.
AccessRule — Rule-Based Authorization
Section titled “AccessRule — Rule-Based Authorization”Instead of requiring a specific signature, the UTXO can be gated by an access rule. This enables advanced spending policies such as multisig, component-scoped access, or resource-gated spending:
SpendCondition::AccessRule(rule)Access rules support the same constructs used elsewhere in Ootle:
| Rule | Description |
|---|---|
allow_all | Anyone can spend the UTXO (no authorization required) |
deny_all | The UTXO can never be spent |
public_key(pk) | Equivalent to Signed(pk) but expressed as an access rule |
resource(addr) | Requires a proof of a specific resource token |
non_fungible(nf) | Requires a proof of a specific non-fungible token |
component(addr) | Only spendable within a call to a specific component |
template(addr) | Only spendable within a call to a specific template |
any_of(...) | Any one of the listed rules must be satisfied |
all_of(...) | All of the listed rules must be satisfied |
m_of_n(m, ...) | At least m of n listed requirements must be satisfied |
For example, a 2-of-3 multisig spend condition:
use tari_template_lib::rule;
let spend_condition = SpendCondition::AccessRule( rule!(m_of_n(2, public_key(pk1), public_key(pk2), public_key(pk3))));Or a UTXO that can only be spent by a specific component:
let spend_condition = SpendCondition::AccessRule( rule!(component(my_component_address)));Setting the Spend Condition with ootle-rs
Section titled “Setting the Spend Condition with ootle-rs”By default, stealth outputs use Signed with the one-time stealth public key derived for the recipient.
To use an access rule instead, set pay_to on the output:
use ootle_rs::{crypto::pay_to::PayTo, stealth::Output};use tari_template_lib::rule;
let output = Output::new(recipient, tari_token, amount) .with_pay_to(PayTo::AccessRule( rule!(m_of_n(2, public_key(pk1), public_key(pk2), public_key(pk3))) ));Encrypted Data and Memos
Section titled “Encrypted Data and Memos”Each stealth output carries an EncryptedData payload encrypted with XChaCha20-Poly1305. The encryption key
is derived from the same Diffie-Hellman exchange used for the stealth address.
The encrypted payload contains:
- Value (8 bytes) — the amount in this output.
- Mask (32 bytes) — the Pedersen commitment blinding factor, needed to spend the output later.
- Memo (0–255 bytes, optional) — an arbitrary message for the recipient.
Only the recipient (who knows the secret key corresponding to the stealth address) can decrypt this data.
Memos allow the sender to attach a message to an output that only the recipient can read:
.to_stealth_output( Output::new(recipient, tari_token, amount) .with_memo_message("Payment for invoice #42"))Memos support several formats:
- Message — a UTF-8 string (up to 253 bytes).
- Bytes — arbitrary binary data.
- PayRefAndBytes — a payment reference combined with arbitrary data.
Programmatic Stealth Transfers in Templates
Section titled “Programmatic Stealth Transfers in Templates”WASM templates can execute stealth transfers using the ResourceManager and Bucket APIs.
This is useful for templates that manage stealth resources on behalf of users.
StealthTransferStatement Helpers
Section titled “StealthTransferStatement Helpers”The StealthTransferStatement provides helper methods to extract revealed amounts:
// Get the revealed input amount (the public amount expected from a bucket).let revealed_in: Amount = statement.revealed_input_amount();
// Get the revealed output amount (the public amount returned as a bucket).let revealed_out: Amount = statement.revealed_output_amount();Executing a Transfer via ResourceManager
Section titled “Executing a Transfer via ResourceManager”The ResourceManager provides two methods for stealth transfers:
// Execute a stealth transfer (no revealed input bucket needed).// Returns Some(Bucket) if there are revealed outputs, None otherwise.let maybe_bucket: Option<Bucket> = resource_manager.stealth_transfer(statement);
// Execute a stealth transfer with a revealed input bucket.let maybe_bucket: Option<Bucket> = resource_manager .stealth_transfer_with_opt_input_bucket(statement, Some(input_bucket));Executing a Transfer via Bucket
Section titled “Executing a Transfer via Bucket”If you already have a Bucket from minting or a vault withdrawal, you can call stealth_transfer directly on it:
let bucket = ResourceBuilder::stealth() .with_token_symbol("TKN") .initial_supply(1_000_000);
// Convert the minted supply into stealth UTXOs.// The returned bucket contains any revealed output amount.let revealed_bucket = bucket.stealth_transfer(mint_statement);Paying Fees from a Vault
Section titled “Paying Fees from a Vault”Vaults holding stealth resources can pay transaction fees directly:
// Pay fees using a stealth transfer from the vault.// The transfer statement must produce a positive revealed output.self.vault.pay_fee_stealth(transfer_statement);Full Template Example
Section titled “Full Template Example”Here is a template that manages a stealth faucet, demonstrating minting, programmatic transfers, and revealed output handling:
use tari_template_lib::prelude::*;
#[template]mod template { use super::*;
pub struct StealthFaucet { manager: ResourceManager, supply_vault: Vault, }
impl StealthFaucet { pub fn new(initial_supply: Amount, mint: StealthTransferStatement) -> Component<Self> { let bucket = ResourceBuilder::stealth() .mintable(rule!(allow_all)) .initial_supply(initial_supply);
let resource_address = bucket.resource_address(); // Convert the minted funds into stealth UTXOs. // Any revealed output is returned as a bucket. let revealed_output_bucket = bucket.stealth_transfer(mint); let supply_vault = Vault::from_bucket(revealed_output_bucket);
Component::new(Self { manager: resource_address.into(), supply_vault, }) .with_access_rules(AccessRules::allow_all()) .create() }
pub fn programmatic_transfer(&self, transfer: StealthTransferStatement) { // Use helper methods to extract revealed amounts from the statement. let revealed_input = transfer.revealed_input_amount(); let revealed_output = transfer.revealed_output_amount();
// If there are revealed inputs required, take them from the supply vault. let maybe_input_bucket = if revealed_input.is_positive() { Some(self.supply_vault.withdraw(revealed_input)) } else { None };
let bucket = self .manager .stealth_transfer_with_opt_input_bucket(transfer, maybe_input_bucket) .expect("Transfer must have revealed outputs");
// The returned bucket contains `revealed_output` amount of funds. // Deposit them back into the vault. assert!(revealed_output.is_positive(), "Expected revealed output"); self.supply_vault.deposit(bucket); } }}Creating Stealth Resources
Section titled “Creating Stealth Resources”Creating a stealth resource uses ResourceBuilder::stealth(). This is covered in the
Resources guide, but here is a quick example:
let bucket = ResourceBuilder::stealth() .with_token_symbol("PRIV") .with_divisibility(6) .mintable(rule!(allow_all)) .initial_supply(1_000_000);A stealth resource can optionally have a view key. When set, every output must include a ViewableBalanceProof
that allows the view key holder to decrypt balances without being able to spend them. This is useful for auditing
or regulatory compliance while preserving spending privacy.
ResourceBuilder::stealth() .with_view_key(auditor_public_key) .initial_supply(1_000_000);Total Supply Tracking
Section titled “Total Supply Tracking”By default, stealth resources track their total supply. Since individual UTXO amounts are hidden, there is no way to derive the total supply by summing balances. The engine maintains the total supply amount in the resource substate that is incremented on mint and decremented on burn. This gives token issuers and users a way to view the circulating supply.
This has implications for minting and burning:
- Minting always produces a revealed amount returned as a
Bucket. The minted value is public because the engine needs to update the total supply. You then use a stealth transfer to convert the bucket into stealth UTXOs. - Burning a stealth UTXO requires a
StealthValueProofwhen supply tracking is enabled. This proof reveals the value of the UTXO being burnt so that the total supply can be decremented. This effectively limits burns to the UTXO owner or the holder of the secret view key, since only they can construct the proof.
If your resource does not need total supply tracking, you can disable it to save on fees and avoid revealing values during burns:
ResourceBuilder::stealth() .with_token_symbol("PRIV") .disable_total_supply_tracking() .initial_supply(1_000_000);Privacy Gotchas
Section titled “Privacy Gotchas”Stealth transfers provide strong privacy for amounts and UTXO ownership, but there are scenarios where privacy can be weakened or lost.
Signing with Your Public Key
Section titled “Signing with Your Public Key”If a transaction calls a component method that checks the caller’s identity (e.g. withdraw on your account),
the transaction must be signed with your public key. This links the transaction to your identity, even
though the stealth outputs themselves hide who received the funds.
For example, if you withdraw revealed funds from your account to use as a stealth transfer input:
- Your account address (and therefore your public key) is visible in the transaction.
- The stealth outputs hide the destination, but an observer knows you initiated the transfer.
To maximize privacy, prefer spending stealth inputs only. When a transaction has only stealth inputs and no component method calls that require identity, an ephemeral key can be used to sign, revealing nothing about the sender.
Revealed Amounts Leak Information
Section titled “Revealed Amounts Leak Information”Any revealed input or output amount is public. If you withdraw exactly 100 TARI from your account and the stealth transfer has a revealed input of 100 TARI, the amounts are linkable even though the stealth outputs are private.
Transaction Graph Analysis
Section titled “Transaction Graph Analysis”While individual UTXOs hide their owner, the structure of transactions is public: an observer can see which inputs were spent and which outputs were created, allowing them to form a UTXO graph.
Example: Stealth Transfer with ootle-rs
Section titled “Example: Stealth Transfer with ootle-rs”The ootle-rs crate provides a high-level builder for stealth transfers. Here is a complete example showing
how to receive funds from a faucet and then send them to another address.
Step 1: Setup
Section titled “Step 1: Setup”use ootle_rs::{ Network, ToAccountAddress, TransactionRequest, builtin_templates::{UnsignedTransactionBuilder, faucet::IFaucet}, const_nonzero_u64, default_indexer_url, key_provider::PrivateKeyProvider, provider::{ProviderBuilder, WalletProvider}, stealth::{Output, StealthTransfer}, template_types::{UtxoAddress, constants::{TARI, TARI_TOKEN}}, wallet::OotleWallet,};use tari_ootle_transaction::Transaction;
const NETWORK: Network = Network::LocalNet;
let sender_secret = PrivateKeyProvider::random(NETWORK);let sender_address = sender_secret.address().clone();
let wallet = OotleWallet::from(sender_secret.clone());let mut provider = ProviderBuilder::new() .wallet(wallet) .connect(default_indexer_url(NETWORK)) .await?;Step 2: Receive Funds from a Faucet (revealed input -> stealth output)
Section titled “Step 2: Receive Funds from a Faucet (revealed input -> stealth output)”let tari_token = TARI_TOKEN;
// Build a stealth transfer: take revealed funds from the faucet,// output a small revealed amount for fees, and the rest as a stealth UTXO.let (faucet_transfer, required_signers) = StealthTransfer::new(tari_token, &provider) .spend_revealed_input(10 * TARI + 1000) .to_revealed_output(500u64) .to_stealth_output( Output::new(sender_address.clone(), tari_token, const_nonzero_u64!(10 * TARI + 500)) ) .prepare() .await?;
// Keep track of outputs so we can spend them later.let my_utxos = faucet_transfer.stealth_outputs().to_vec();
// Build the transaction using the faucet template helper.let unsigned_tx = IFaucet::new(&provider) .take_faucet_funds_stealth(faucet_transfer, true) .prepare() .await?;
// Sign and send.let authorizer = provider.wallet().stealth_authorizer(required_signers);let transaction = TransactionRequest::default() .with_transaction(unsigned_tx) .build(&authorizer) .await?;
provider.send_transaction(transaction).await?;Step 3: Send to Another Party (stealth input -> stealth outputs)
Section titled “Step 3: Send to Another Party (stealth input -> stealth outputs)”let recipient = address!("otl_loc_1xfack4y...");
let (transfer, required_signers) = StealthTransfer::new(tari_token, &provider) // Spend an existing stealth UTXO. .spend_stealth_input(sender_address.clone(), my_utxos[0].commitment()) // Revealed output for fees. .to_revealed_output(500u64) // 8 TARI to the recipient with an encrypted memo. .to_stealth_output( Output::new(recipient, tari_token, const_nonzero_u64!(8 * TARI)) .with_memo_message("Payment for services") ) // Change back to sender. .to_stealth_output( Output::new(sender_address, tari_token, const_nonzero_u64!(2 * TARI)) ) .prepare() .await?;
// Build the transaction, placing the revealed output on the workspace for fees.let unsigned_tx = Transaction::builder(provider.network()) .with_fee_instructions_builder(|builder| { builder .stealth_transfer(tari_token, transfer) .put_last_instruction_output_on_workspace("fees") .pay_fee_from_bucket("fees") }) .add_input(tari_token) .add_input(UtxoAddress::new(tari_token, my_utxos[0].commitment().into())) .build_unsigned();
let authorizer = provider.wallet().stealth_authorizer(required_signers);let transaction = TransactionRequest::default() .with_transaction(unsigned_tx) .build(&authorizer) .await?;
provider.send_transaction(transaction).await?;Summary
Section titled “Summary”Stealth transfers are the foundation of privacy in Tari Ootle. They combine Pedersen commitments, Bulletproof range proofs, one-time stealth addresses, and encrypted payloads to hide transaction amounts and UTXO ownership.
Key takeaways:
- Confidential amounts are hidden in Pedersen commitments; only the sender and recipient know the value.
- Stealth addresses are unique per output, unlinkable to the recipient’s real public key.
- Revealed funds are public and primarily used for fees; they bridge between the confidential and public layers.
- Revealed outputs produce a
Bucketthat can be used in subsequent instructions (e.g. fee payment). - Encrypted memos allow private communication between sender and recipient.
- Privacy is not absolute: signing transactions, revealed amounts, change patterns, and component interactions can all leak information. Design transactions carefully to minimize exposure.