Skip to content

Stealth Transfers

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) vs AccessRule (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.

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.

PropertyHidden?How
Transfer amountYesPedersen commitments hide the value; range proofs verify a valid value range (non-negative), balance proof signature ensures no inflation
UTXO ownerYesOne-time stealth addresses derived via Diffie-Hellman; no on-chain link to the recipient’s public key
Sender/Receiver identityYesSee Privacy Gotchas below
Memo contentYesEncrypted with XChaCha20-Poly1305; only the recipient can decrypt
Revealed amountsNoRevealed inputs/outputs are public by design

A stealth transfer consists of inputs (funds being spent) and outputs (funds being created). Each side can contain both confidential and revealed components.

Structure of a Stealth TransferINPUTSStealth Input (UTXO)Commitment: C = mask.G + value.HRequires signature from UTXO owner🔒Stealth Input (UTXO)Commitment: C = mask.G + value.HRequires signature from UTXO owner🔒Revealed Input (from Bucket) — optionalPublic amount (e.g. from vault withdrawal)👁Balance Proof: ∑inputs == ∑outputsOUTPUTSStealth Output (new UTXO)Commitment: C = mask.G + value.HStealth address (one-time key)Encrypted data: value + mask + memo🔒Stealth Output (change)Commitment: C = mask.G + value.HStealth address (back to sender)Encrypted data: value + mask🔒Revealed Output → Bucket — optionalPublic amount returned as a Bucket👁Aggregated Range Proof (Bulletproofs)Confidential (hidden)Revealed (public)Zero-knowledge proof

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.


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.

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 Bucket with a specific amount, the revealed output provides that bucket.
  • Interacting with public components — bridging confidential funds into the public smart contract layer.

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));

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();

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:

  1. The sender generates a random ephemeral nonce r.
  2. The sender computes the shared secret: c = H(r · K) where K is the recipient’s public key.
  3. The stealth public key is: P = c·G + K
  4. 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).

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:

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.

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:

RuleDescription
allow_allAnyone can spend the UTXO (no authorization required)
deny_allThe 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))
);

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)))
));

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.

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();

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));

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);

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);

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 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);

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 StealthValueProof when 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);

Stealth transfers provide strong privacy for amounts and UTXO ownership, but there are scenarios where privacy can be weakened or lost.

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.

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.

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.


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.

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?;

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 Bucket that 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.