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 statement: inputs, outputs, revealed vs confidential funds, and the proofs that bind them together.
- Understand how stealth addresses protect receiver identity.
- Understand how an output is authorized for spending: the key path (a one-time key) and the script path (a committed condition tree, or MAST).
- Learn what a condition-tree leaf can hold: access rules, timelocks, hashlocks, covenants and WASM spend scripts.
- 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 is carried in a StealthTransferStatement. It has an inputs statement (funds being
spent), an outputs statement (funds being created), and the proofs that bind the two together. 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 by the statement’s balance proof without revealing any individual
amounts. The balance proof sits at the statement level rather than on either side, because it is a claim about both
at once; it is
Noneonly when the transfer is revealed-only, with no stealth inputs and no stealth outputs. The aggregated range proof does belong to the outputs statement: it covers exactly the output commitments, proving each value lies in[minimum_value_promise, 2^64).
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:
// `max_epoch` is the last epoch the transaction may be sequenced in.let unsigned_tx = Transaction::builder(network, max_epoch) .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_key, the output belongs to them. - The sender cannot spend the output after sending it (they don’t know k).
Spend Authorization
Section titled “Spend Authorization”Every stealth output commits, at creation time, to how it may later be spent. That commitment is its
SpendAuthorization, and it takes one of three shapes:
| Variant | Committed in the output | Spent via |
|---|---|---|
Key(spend_key) |
A one-time stealth public key | Key path — a signature from spend_key |
Script(condition_root) |
A 32-byte Merkle root over a set of spend conditions | Script path — revealing one committed condition |
KeyAndScript { spend_key, condition_root } |
Both | Either path; the spender chooses |
It is an enum rather than a pair of optional fields so that the unspendable state — no key and no conditions — cannot be represented at all.
Spending is witness-driven: each spent input carries a SpendWitness that says which path the spender is taking.
The committed authorization only decides which witnesses are admissible; the engine never assumes a path.
pub enum SpendWitness { /// Signature from the output's `spend_key`, supplied in the transaction envelope. KeyPath, /// One committed condition leaf, its inclusion proof, and an optional witness blob. ScriptPath { leaf: SpendCondition, proof: MerkleProof, data: Bytes },}Key Path
Section titled “Key Path”The default and by far the most common path. The one-time stealth public key derived during the stealth address exchange (see above) authorizes the spend: only the recipient can derive the matching private key.
At validation time the engine checks that the transaction’s authorization scope contains a badge matching
NonFungibleAddress::from_public_key(spend_key). If the signature is missing the transaction fails with
RequiredSignatureMissingForStealthUtxo.
Script Path — Condition Trees (MAST)
Section titled “Script Path — Condition Trees (MAST)”A condition tree is a Merklized set of alternative spend conditions — a MAST. The output stores only the root. To spend, the spender reveals exactly one leaf plus an inclusion proof; the engine recomputes the root, checks it against the committed value, and only then evaluates that leaf.
This is what makes broad spending policies cheap and private:
- Only the path actually taken appears on-chain. The alternatives that were not exercised stay hidden — an observer never learns that a refund branch or a co-signer branch existed.
- Breadth is nearly free. An inclusion proof is logarithmic in the number of leaves, so a tree with many alternatives costs a spender little more than one with two.
- The root is a function of the set. Leaf hashes are sorted and must be unique, so authoring order does not change the root.
- Proofs carry no direction bits. A branch hashes its two children in lexicographic byte order, so a proof is a bare list of sibling hashes. Leaves and branches hash under distinct domains, so an internal node can never be reinterpreted as a leaf.
Tree construction and verification are native — a template never builds or verifies a tree.
Leaves: a Conjunction of Atomic Conditions
Section titled “Leaves: a Conjunction of Atomic Conditions”One leaf is a SpendCondition: a flat, non-empty AND of AtomicConditions. There is no OR combinator inside a
leaf, because the tree is the OR — each leaf is an alternative spend path. Atoms cannot nest other atoms, so a
leaf has no recursion to bound.
| Atom | Gates the spend on |
|---|---|
AccessRule(rule) |
A native access rule evaluated against the transaction’s auth scope (signer badges, resource proofs, component scope, m_of_n, …) |
Builtin(predicate) |
A native, consensus-fixed local predicate: a timelock or a hashlock |
Covenant(covenant) |
A native, consensus-fixed constraint on the spending transfer’s outputs and value flow |
TemplateFunction(tf) |
A stateless WASM predicate — a spend script — committed as {template, function, args} |
Builtin Predicates
Section titled “Builtin Predicates”Builtins need no deployed template and are evaluated natively, so their semantics live in trusted core code.
| Predicate | Admits the spend when |
|---|---|
AfterEpoch(n) |
The current epoch is at or after n |
BeforeEpoch(n) |
The current epoch is strictly before n |
HashLock { hash, alg } |
The witness data blob is a preimage whose alg digest equals hash |
alg is Blake2b256 or Sha256. The preimage is hashed with no domain separation, so the same secret can unlock
an HTLC on an external chain (e.g. Bitcoin’s SHA256).
Covenants
Section titled “Covenants”Where a builtin gates an input on a local fact, a covenant constrains the transaction the input is spent into — so conditions propagate forward.
| Covenant | Requires |
|---|---|
OutputPreservesCondition |
At least one stealth output, and every stealth output re-locked under exactly this condition_root |
OutputTo { condition_root, min_value } |
At least one stealth output locked under condition_root promising at least min_value |
BalancePreserved(max_revealed) |
The partition’s committed value is conserved into outputs carrying its condition_root, save for an exact cleartext outflow of at most max_revealed (zero admits no escape) |
A partition is every input and output of the transfer sharing the invoking input’s condition_root. Partitions are
keyed by root equality, so distinct UTXOs committing the same tree form one partition with one shared allowance. Bind a
per-UTXO identity (e.g. a vault nonce) into the leaf’s arguments when they must be separate covenants.
BalancePreserved is proven by a CovenantBalanceClaim — a sub-balance proof carried in the statement’s
covenant_claims, one per covenant-gated partition. The confidential balance is never revealed.
Witness Data
Section titled “Witness Data”A script-path witness may carry a data blob the revealed leaf interprets — a hashlock preimage, a signature, or a
CBOR structure a spend script decodes. It is not committed in condition_root (only the leaf is), so it cannot
change which predicate runs, only satisfy one.
Because a data-consuming builtin such as HashLock reads the whole blob as raw bytes, it must be the sole data
consumer in its leaf. The engine rejects a leaf with more than one data-consuming builtin, or one that mixes a
data-consuming builtin with a TemplateFunction.
Limits
Section titled “Limits”Breadth in the tree is free, but evaluating one revealed leaf is native work, so the spend-time surface is bounded:
| Limit | Value |
|---|---|
Atoms per leaf (max_conditions_per_conjunction) |
16 |
Witness data size (max_witness_data_len) |
4096 bytes |
Inclusion proof siblings (max_inclusion_proof_len) |
32 (a tree of up to 2^32 leaves) |
The tree’s total leaf count is not bounded — it is not a spend-time cost.
Creating a Script-Gated Output with ootle-rs
Section titled “Creating a Script-Gated Output with ootle-rs”By default a stealth output is key-path only. PayTo chooses otherwise:
PayTo variant |
Produces |
|---|---|
StealthPublicKey (default) |
Key(spend_key) — the one-time stealth key |
AccessRule(rule) |
Script(root) over a single access-rule leaf |
TemplateFunction(tf) |
Script(root) over a single spend-script leaf |
Conditions(vec![...]) |
Script(root) over a full multi-leaf tree |
Output has conveniences for the last two:
use ootle_rs::{ crypto::pay_to::PayTo, stealth::Output, template_types::{rule, stealth::{AtomicCondition, BuiltinPredicate, HashAlg, SpendCondition}},};
// Single access-rule leaf: 2-of-3 multisig.let multisig = Output::new(recipient.clone(), tari_token, amount) .with_pay_to(PayTo::AccessRule( rule!(m_of_n(2, public_key(pk1), public_key(pk2), public_key(pk3))) ));
// A two-leaf HTLC tree: claim before the deadline with the preimage, or refund to the funder after it.let claim = SpendCondition::all([ AtomicCondition::Builtin(BuiltinPredicate::HashLock { hash, alg: HashAlg::Sha256 }), AtomicCondition::Builtin(BuiltinPredicate::BeforeEpoch(deadline)),]);let refund = SpendCondition::all([ AtomicCondition::Builtin(BuiltinPredicate::AfterEpoch(deadline)), AtomicCondition::AccessRule(rule!(public_key(funder_pk))),]);let conditions = vec![claim, refund];
let htlc = Output::new(recipient, tari_token, amount) .with_spend_conditions(conditions.clone());The value is still encrypted to the destination address, so the recipient can discover and decrypt the output as usual — only the spending rule changes.
Spending via the Script Path
Section titled “Spending via the Script Path”Build the witness from the same set of leaves the output committed to, revealing the one you intend to satisfy, then attach it to the input:
use ootle_rs::{ crypto::stealth::script_path_witness_with_data, template_types::{bytes::Bytes, stealth::StealthInput},};
// Reveal the `claim` leaf with its inclusion proof, supplying the preimage as the witness data// the hashlock consumes. Use `script_path_witness` when the leaf consumes no data.let (witness, _root) = script_path_witness_with_data( &conditions, &conditions[0], Bytes::from_vec(preimage.to_vec()),)?;
let (transfer, required_signers) = StealthTransfer::new(tari_token, &provider) .spend_stealth_input(owner_address, StealthInput::with_witness(commitment, witness)) .to_revealed_output(FEE) .to_stealth_output(Output::new(recipient, tari_token, amount)) .prepare() .await?;Spend Scripts
Section titled “Spend Scripts”A TemplateFunction leaf runs arbitrary WASM as the spend predicate. A spend script is an ordinary template function
with is_mut == false whose last parameter is a SpendContext. It authorizes the spend by returning normally and
rejects it by panicking — the same convention as a resource AuthHook, and, like a failed Bitcoin script, a
deliberate rejection is indistinguishable from a bug or an out-of-gas abort.
#[template]mod spend_scripts { use super::*;
pub struct SpendScripts {}
impl SpendScripts { /// Absolute timelock: rejects until `unlock_epoch`. pub fn timelock(unlock_epoch: u64, ctx: SpendContext) { ctx.require_timelock(unlock_epoch); }
/// Recursive covenant: every output must carry this same spend condition. pub fn preserve_covenant(ctx: SpendContext) { ctx.require_output_preserves_condition(); }
/// Witness-data lock: authorizes only if the spend-supplied data matches the committed bytes. pub fn require_witness_data(expected: Vec<u8>, ctx: SpendContext) { assert!(ctx.data() == expected, "unexpected witness data"); } }}The leading parameters are committed in the condition — {template, function, args} are all part of the leaf
hash, so a spender cannot substitute a different predicate or different arguments. Templates are immutable substates,
so the referenced code cannot change after the output is created. SpendContext is injected by the engine.
SpendContext exposes a read-only view of the spending transfer. Confidential values are never revealed — only
what the balance proof already operates on:
| Accessor | Returns |
|---|---|
inputs() / outputs() |
Commitments; outputs also expose minimum_value_promise, auth and tag |
current_input() |
Index, commitment and committed condition_root of the input being authorized |
revealed_input_amount() / revealed_output_amount() |
The transfer’s public amounts |
current_epoch() |
The epoch, fixed for the duration of execution |
data() |
The raw witness data blob for this input |
Plus assertion helpers that mirror the native covenants: require_timelock, require_output_preserves_condition,
require_output_to, require_balance_preserved, require_balance_preserved_with_allowance.
Privacy Trade-off
Section titled “Privacy Trade-off”A key-path spend reveals nothing about the spending policy. A script-path spend publishes the leaf it exercises, and that leaf may say a great deal — which keys can sign, which component is involved, what the deadline was.
Two rules of thumb:
- Make the common case the key path (or the leaf you expect to take), so the branches that reveal the most are the ones that are rarely taken.
- Put one-time keys in leaves, derived unlinkably from the output’s nonce, rather than account keys. The revealed leaf then exposes a key nobody can tie back to an identity.
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), LOCKED) .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), LOCKED) .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
CommitmentValueProofwhen 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 (who proves knowledge of the commitment mask) or the holder of the secret view key (who proves the value against the encrypted viewable balance), since only they can construct the proof. - The same applies to burning a bucket that holds confidential commitments: use
Bucket::burn_with_value_proofswith oneCommitmentValueProofper commitment, and to minting commitments viamint_confidential, which requires a proof for each commitment the statement mints.
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::{Epoch, 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?;
// Every transaction declares the last epoch it may be sequenced in; past it, it can never land.let max_epoch = Epoch(provider.get_epoch().await?.as_u64() + 10);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, max_epoch) .take_faucet_funds() .into_stealth_transfer(faucet_transfer) .and_pay_fee_from_revealed_output() .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(), max_epoch) .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.
- The balance proof belongs to the statement, not to its inputs or outputs: it proves
∑inputs == ∑outputsacross both sides at once. The aggregated range proof is the outputs statement’s own. - Stealth addresses are unique per output, unlinkable to the recipient’s real public key.
- Spending is witness-driven: an output commits a
spend_key, acondition_root, or both, and each spent input declares which path it takes. - Condition trees (MAST) commit a set of alternative spend paths; a spend reveals exactly one leaf plus a logarithmic inclusion proof, and the unused branches stay hidden.
- A leaf is an AND of access rules, builtin timelocks and hashlocks, covenants, or WASM spend scripts; the tree itself supplies the OR.
- 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, component interactions, and the condition leaf a script-path spend reveals can all leak information. Design transactions carefully to minimize exposure.