Skip to content

Instantly share code, notes, and snippets.

@sdbondi
Last active June 29, 2026 08:19
Show Gist options
  • Select an option

  • Save sdbondi/aa6c27f407d8d99eeacd87efad266d5b to your computer and use it in GitHub Desktop.

Select an option

Save sdbondi/aa6c27f407d8d99eeacd87efad266d5b to your computer and use it in GitHub Desktop.
Tari Ootle: build a stealth transfer statement with only a view key (spend authorized by an access_rule scoped to a component)

Spending a Tari Ootle stealth UTXO with only a view key — and submitting it through a component

This gist shows that in Tari Ootle you can build a complete, cryptographically valid stealth transfer statement holding only the view key — the secret that decrypts a UTXO's encrypted_data (recovering the commitment mask + value) — and no spend key. It then builds, signs, and submits a transaction that passes that statement into a component, which executes the spend.

That works because a stealth input needs only its (mask, value) to participate in a transfer statement; the spend authorization is a separate gate the engine checks at execution time. When the UTXO is locked under access_rule(scoped to component C), that gate is satisfied by component C executing the spend, not by any signature.

⚠️ The "view key" in this gist is not the ElGamal view key (used for viewable-balance proofs). It is the AEAD key parent that decrypts encrypted_data, derived as H(DH(view_secret, sender_public_nonce)). The two are independent.

Files

File What it is
stealth_view_key_transfer.rs Runnable example. Phase 1 (offline): decrypt with the view key, build the statement, validate it. Phase 2 (network, gated by OOTLE_INDEXER_URL): submit a tx that calls component_c.release(statement).
Cargo.toml Dependencies, pinned to the versions the Ootle workspace uses.
README.md This file — including the component (template) side.

Run it

gh gist clone <this-gist>
cd <dir>
cargo run

Phase 1 always runs and prints:

✓ Built a valid StealthTransferStatement using only the view key.
  inputs:  1
  outputs: 1

Phase 2 (submission) runs only when OOTLE_INDEXER_URL is set, because it needs a live indexer, a deployed component C, and a funded account. The example "mints" the input UTXO locally so phase 1 is self-contained; on a live network you scan that UTXO from the indexer instead — and only then will phase 2 actually commit (a toy UTXO that isn't on-chain is rejected at input resolution).

Why the view key is enough

A stealth input is just a commitment plus a witness selecting which authorization path is being exercised:

pub struct StealthInput {
    pub commitment: PedersenCommitmentBytes,
    pub witness: SpendWitness, // KeyPath | ScriptPath { leaf, proof, data }
}

Building the statement requires:

  • Per input: its (mask, value). The commitment is mask·G + value·H, and the aggregate input mask feeds the Schnorr balance proof. Both come straight out of decrypting encrypted_data with the view key. No spend secret key appears anywhere in statement construction.
  • For an access_rule-gated input: a script-path SpendWitness that reveals the access_rule leaf and its inclusion proof against the committed condition_root. Building that needs only the public condition set — again, no secret.

The engine, not the statement, enforces authorization. In crates/engine/src/runtime/impl.rs::verify_input_authorizations, a script-path input evaluates the revealed leaf, and an AccessRule leaf is checked against the current auth scope:

AtomicCondition::AccessRule(access_rule) => {
    let allowed = self.tracker
        .read_with(|state| state.authorization().check_access_rule(access_rule))?;
    if !allowed { /* AccessDenied */ }
}

For RuleRequirement::ScopedToComponent(C) the native check is simply state.current_component()? == Some(C) — true only while C is the executing component. That is the whole reason the statement is passed into C.

Phase 1 — build the statement (off-chain, key lines)

The full version is in stealth_view_key_transfer.rs. The essence (via the ootle_rs::crypto re-export of the wallet stealth crypto):

use ootle_rs::crypto::{StealthCryptoApi, StealthInputWitness, stealth::{condition_root, script_path_witness}};

let crypto = StealthCryptoApi::new();

// (a) Decrypt with the view key only -> (mask, value). skip_memo = true.
let decrypted = crypto.decrypt_utxo_data(
    &encrypted_data, &commitment, &view_secret, &sender_public_nonce, true,
)?;
let mask_and_value = decrypted.into_mask_and_value();

// (b) Reveal the access_rule leaf (script path). No secret needed.
let conditions = vec![SpendCondition::access_rule(rule_for_c)];
let (spend_witness, root) = script_path_witness(&conditions, &conditions[0])?;

// (c) Input witness = (mask, value) + script-path auth.
let input = StealthInputWitness::with_script_path(mask_and_value, spend_witness, root);

// (d) Assemble. Balance proof is built from masks only.
let statement = crypto.generate_transfer_statement(
    vec![input], Amount::zero(), &outputs, Amount::zero(),
)?;

StealthInputWitness::with_script_path is the constructor to use — the default ::new() is a key-path witness.

Phase 2 — submit a transaction that passes the statement into component C

Build the call, sign with a fee account, submit through the ootle-rs indexer provider, and wait. The fee/seal account needs no rights over the input UTXO — the UTXO is authorized by component C's access rule, not by a key.

use ootle_rs::{
    TransactionRequest,
    builtin_templates::component::{IComponent, TransactionBuildable},
    key_provider::PrivateKeyProvider,
    provider::{ProviderBuilder, WalletProvider},
    template_types::UtxoAddress,
    wallet::OotleWallet,
};
use tari_ootle_transaction::args;

let wallet = OotleWallet::from(PrivateKeyProvider::random(NETWORK)); // your funded account key
let mut provider = ProviderBuilder::new().wallet(wallet).connect(indexer_url).await?;

let utxo = UtxoAddress::new(resource_address.clone(), input_commitment.into());

// The statement is CBOR-encoded as the single argument to component C's `release` method.
let unsigned_tx = IComponent::new(&provider)
    .call_method(component_c, "release", args![statement]) // <-- statement goes in here
    .add_input(resource_address) // the stealth resource substate
    .add_input(utxo)             // the UTXO being spent
    .pay_fee(1000u64)
    .prepare()
    .await?;

let transaction = TransactionRequest::default()
    .with_transaction(unsigned_tx)
    .build(provider.wallet())    // wallet seals/signs (fee account)
    .await?;

let pending = provider.send_transaction(transaction).await?;
let outcome = pending.watch().await?;

args![statement] CBOR-encodes the StealthTransferStatement into a literal argument (any tari_bor::Encode value works). The two add_inputs declare the substates the engine must fetch: the stealth resource and the UTXO being spent. (StealthTransfer can also be a top-level instruction via TransactionBuilder::stealth_transfer(resource, statement) — but then the auth scope is the transaction signer's, not C's, so the access rule won't pass. See the gotchas.)

The on-chain side: component C

The UTXO is locked under SpendAuthorization::Script(condition_root([access_rule(ScopedToComponent(C))])). Component C receives the pre-built StealthTransferStatement as a method argument and executes the spend. Because C is the caller, the access rule passes — no per-input spend key is required.

use tari_template_lib::prelude::*;

#[template]
mod escrow {
    use super::*;

    pub struct Escrow {
        // The resource whose stealth UTXOs are locked to this component.
        resource: ResourceAddress,
    }

    impl Escrow {
        pub fn instantiate(resource: ResourceAddress) -> Component<Self> {
            Component::new(Self { resource })
                // Anyone may submit a statement; the SPEND is gated by the UTXO's access rule,
                // which requires THIS component to be the executor.
                .with_access_rules(AccessRules::allow_all())
                .create()
        }

        /// Release stealth funds. The caller builds `statement` off-chain with only the view key
        /// (phase 1). The input UTXOs are locked under
        /// `access_rule(ScopedToComponent(this component))`, so the spend is authorized purely by
        /// the fact that this method — i.e. this component — is executing.
        ///
        /// Returns a bucket of any *revealed* funds the statement produced (None if fully confidential).
        pub fn release(&self, statement: StealthTransferStatement) -> Option<Bucket> {
            ResourceManager::get(self.resource).stealth_transfer(statement)
        }
    }
}

Relevant template-lib entry points (all accept a pre-built StealthTransferStatement):

  • ResourceManager::stealth_transfer(statement) -> Option<Bucket>crates/template_lib/src/resource/manager.rs
  • ResourceManager::stealth_transfer_with_opt_input_bucket(statement, input_bucket) — same file
  • Bucket::stealth_transfer(statement)crates/template_lib/src/models/bucket.rs
  • Vault::pay_fee_stealth(statement)crates/template_lib/src/models/vault.rs

Two things that make or break it

  1. Balance must hold: Σ input_values + revealed_input == Σ output_values + revealed_output. The example moves the whole value into one output, so both revealed amounts are zero.
  2. The access rule must be satisfiable by C's scope at execution. verify_input_authorizations checks ScopedToComponent(C) against the current executing component. That is C only if C invokes the transfer (the release method above). A top-level StealthTransfer instruction runs in the transaction signer's scope, not C's, and the rule will not pass. The "pass it into C" handoff is load-bearing, not just ergonomic. (The enclosing transaction still needs some fee-paying signer — but never the input's spend key.)

Caveat: the high-level SDK transfer builder is key-path only

ootle-rs's high-level StealthTransfer builder (and ootle_sdk_core::resolve_one_stealth_utxo) decrypt with the view-only secret but emit a key-path StealthInputWitness and require the spending account to sign. For the access-rule case you drop to ootle_rs::crypto (the re-exported wallet stealth crypto), build the with_script_path witness yourself, and assemble the statement directly — exactly as in phase 1 — then submit it with the generic component-call builder shown in phase 2.

API map

Step Symbol Crate / path
View-only decrypt StealthCryptoApi::decrypt_utxo_data crates/wallet/crypto/src/api.rs (ootle_rs::crypto)
View-only scan (alt.) scan_stealth_output(.., account_secret: None, ..) crates/ootle_sdk_core/src/stealth/scan.rs
AEAD key derivation encrypted_data_dh_kdf_aead crates/wallet/crypto/src/kdfs.rs
Reveal access-rule leaf script_path_witness / condition_root crates/wallet/crypto/src/stealth.rs
Input witness StealthInputWitness::with_script_path crates/wallet/crypto/src/unblinded_statement.rs
Assemble statement StealthCryptoApi::generate_transfer_statement crates/wallet/crypto/src/api.rs
Statement type StealthTransferStatement crates/template_lib_types/src/stealth/statement.rs
Build the call IComponent::call_method + args! crates/wallet/ootle-rs/src/builtin_templates/component.rs
Sign TransactionRequest::build(wallet) crates/wallet/ootle-rs/src/types/transaction/request.rs
Submit + wait IndexerProvider::send_transaction / PendingTransaction::watch crates/wallet/ootle-rs/src/provider/
Engine auth gate verify_input_authorizations crates/engine/src/runtime/impl.rs
Crypto validation validate_transfer crates/engine_types/src/stealth/transfer.rs

All paths are in the tari-project/tari-ootle repo. ootle-rs itself ships runnable examples under crates/wallet/ootle-rs/examples/ (stealth_transfer.rs, template_invoke.rs) that this gist mirrors.

[package]
name = "stealth-view-key-transfer"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "stealth-view-key-transfer"
path = "stealth_view_key_transfer.rs"
[dependencies]
# The Ootle Rust wallet SDK ("ootle-rs"): provider, wallet, transaction builder, and a re-export of
# the stealth crypto as `ootle_rs::crypto` and the core types as `ootle_rs::template_types`.
ootle-rs = "0.13"
# The `args!` macro + transaction types (same version ootle-rs uses, so `NamedArg` unifies).
tari_ootle_transaction = "0.35"
# Engine-side validation (balance proof + range proof) — used in phase 1 to prove the statement is sound.
tari_engine_types = "0.35"
# `.to_byte_type()` conversions (RistrettoPublicKey -> RistrettoPublicKeyBytes, etc.).
ootle_byte_type = "0.9"
# External crates, pinned to the versions the Ootle workspace uses so the key/RNG types unify.
tari_crypto = "0.23"
rand = "0.10.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
//! Build a Tari Ootle stealth transfer statement from a **view key only**, then submit a
//! transaction that passes that statement into a component for execution.
//!
//! The "view key" here is the secret that decrypts a UTXO's `encrypted_data` (recovering the
//! commitment mask + value). It is *independent* of the ElGamal view key used for viewable-balance
//! proofs.
//!
//! Two phases:
//!
//! 1. (offline) A holder of only the view key builds a complete, valid `StealthTransferStatement`
//! — Schnorr balance proof + Bulletproof range proof — with **no** spend secret key. We prove
//! it by validating it locally. Spend *authorization* is a separate, engine-side gate: the
//! input is locked under `access_rule(scoped to component C)`, satisfied at execution time by
//! component C executing the spend.
//!
//! 2. (network) We build a transaction that calls `component_c.release(statement)`, sign it, and
//! submit it through the `ootle-rs` indexer provider. Because component C is the executor, its
//! access rule passes and the input is spent.
//!
//! What is faked for self-containment: phase 1 "mints" the input UTXO locally so it has a real
//! commitment / sender nonce / `encrypted_data` to decrypt. On a live network you scan that UTXO
//! from the indexer instead — and only then will phase 2 actually commit (the toy UTXO below does
//! not exist on-chain, so against a real indexer it shows the wiring and is rejected at input
//! resolution).
//!
//! Run: `cargo run` (phase 2 runs only when `OOTLE_INDEXER_URL` is set)
use ootle_byte_type::ToByteType;
use ootle_rs::{
Network, TransactionRequest,
builtin_templates::component::{IComponent, TransactionBuildable},
crypto::{
MaskAndValue, OutputWitness, StealthCryptoApi, StealthInputWitness, StealthOutputWitness,
stealth::{condition_root, script_path_witness},
},
key_provider::PrivateKeyProvider,
provider::{ProviderBuilder, WalletProvider},
template_types::{
Amount, ComponentAddress, EncryptedData, ObjectKey, ResourceAddress, UtxoAddress,
access_rules::{AccessRule, RequireRule, RestrictedAccessRule, RuleRequirement},
crypto::{PedersenCommitmentBytes, UtxoTag},
stealth::{SpendAuthorization, SpendCondition, StealthTransferStatement},
},
wallet::OotleWallet,
};
use tari_crypto::{
keys::{PublicKey, SecretKey},
ristretto::{RistrettoPublicKey, RistrettoSecretKey},
};
use tari_engine_types::stealth::validate_transfer;
use tari_ootle_transaction::args;
const NETWORK: Network = Network::LocalNet;
/// Everything the submit step needs: the statement plus the on-chain coordinates of the input UTXO
/// and the component that authorizes the spend.
struct Prepared {
statement: StealthTransferStatement,
resource_address: ResourceAddress,
input_commitment: PedersenCommitmentBytes,
component_c: ComponentAddress,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ---- Phase 1 (offline): build the statement with the view key only; prove it validates. ----
let prepared = build_statement_with_view_key()?;
validate_transfer(&prepared.statement, None)?;
println!("✓ Built a valid StealthTransferStatement using only the view key.");
println!(" inputs: {}", prepared.statement.stealth_inputs().len());
println!(" outputs: {}", prepared.statement.stealth_outputs().len());
// ---- Phase 2 (network): pass the statement into component C and submit. ----
match std::env::var("OOTLE_INDEXER_URL") {
Ok(url) => submit_release(&url, prepared).await?,
Err(_) => {
println!();
println!("Phase 2 (submit) is gated. To run it:");
println!(" • start a local Ootle network + indexer, deploy component C, fund an account");
println!(" • point this at the indexer: OOTLE_INDEXER_URL=http://127.0.0.1:18300 cargo run");
println!(" • use a real, scanned UTXO (the toy one above is not on-chain) in build_statement_with_view_key()");
},
}
Ok(())
}
/// Phase 1 — pure crypto, no network. Recover (mask, value) with the view key, build the
/// script-path input witness for the access-rule leaf, and assemble the transfer statement.
fn build_statement_with_view_key() -> Result<Prepared, Box<dyn std::error::Error>> {
let mut rng = rand::rng();
let crypto = StealthCryptoApi::new();
// The recipient's view secret — the only secret needed to spend this UTXO. (NOT the ElGamal view key.)
let view_secret = RistrettoSecretKey::random(&mut rng);
let view_public = RistrettoPublicKey::from_secret_key(&view_secret);
// The component the UTXO is locked to, and the access-rule condition committed on the UTXO.
let component_c = ComponentAddress::new(ObjectKey::from_array([0x11u8; ObjectKey::LENGTH]));
let rule_for_c = AccessRule::Restricted(RestrictedAccessRule::Require(RequireRule::Require(
RuleRequirement::ScopedToComponent(component_c),
)));
let conditions = vec![SpendCondition::access_rule(rule_for_c)];
let committed_root = condition_root(&conditions)?;
// The stealth resource the UTXO belongs to.
let resource_address = ResourceAddress::new(ObjectKey::from_array([0x33u8; ObjectKey::LENGTH]));
// --- Mint the UTXO locally: random mask + value, encrypted to the recipient's view key. ---
// (On a live network you scan this UTXO from the indexer instead of minting it.)
let value: u64 = 1_000_000; // 1 tTARI, in microtari
let input_mask = RistrettoSecretKey::random(&mut rng);
let input_nonce_secret = RistrettoSecretKey::random(&mut rng);
let input_sender_nonce = RistrettoPublicKey::from_secret_key(&input_nonce_secret);
let input_commitment: PedersenCommitmentBytes =
MaskAndValue::new(value, input_mask.clone()).to_commitment().to_byte_type();
let input_encrypted_data: EncryptedData =
crypto.encrypt_value_and_mask(value, &input_mask, &view_public, &input_nonce_secret, None)?;
// From here on we keep only `view_secret` plus what is public on-chain: the commitment, the
// sender nonce, and the `encrypted_data` blob.
drop(input_mask);
// 1. Decrypt with the view key only -> recover (mask, value). No spend key involved.
let decrypted = crypto.decrypt_utxo_data(
&input_encrypted_data,
&input_commitment,
&view_secret, // claim_secret == the view-only secret
&input_sender_nonce,
true, // skip_memo
)?;
let recovered = decrypted.into_mask_and_value();
assert_eq!(recovered.value, value, "view key recovered the value");
// 2. Script-path input witness: reveal the access_rule leaf from the committed set (no secret).
let leaf = conditions[0].clone();
let (spend_witness, root) = script_path_witness(&conditions, &leaf)?;
assert_eq!(root, committed_root, "revealed leaf hashes back to the committed root");
let input_witness = StealthInputWitness::with_script_path(recovered, spend_witness, committed_root);
// 3. One output carrying the value onward (here, a fresh stealth output to some recipient).
let out_mask = RistrettoSecretKey::random(&mut rng);
let out_nonce_secret = RistrettoSecretKey::random(&mut rng);
let out_sender_nonce = RistrettoPublicKey::from_secret_key(&out_nonce_secret);
let out_recipient_view = RistrettoPublicKey::from_secret_key(&RistrettoSecretKey::random(&mut rng));
let out_owner = RistrettoPublicKey::from_secret_key(&RistrettoSecretKey::random(&mut rng));
let out_encrypted_data =
crypto.encrypt_value_and_mask(value, &out_mask, &out_recipient_view, &out_nonce_secret, None)?;
let output_witness = StealthOutputWitness {
witness: OutputWitness {
amount: value,
mask: out_mask,
sender_public_nonce: out_sender_nonce,
minimum_value_promise: 0,
encrypted_data: out_encrypted_data,
resource_view_key: None,
},
auth: SpendAuthorization::Key(out_owner.to_byte_type()),
tag: UtxoTag::new(0),
};
let outputs = vec![output_witness];
// 4. Assemble. The balance proof is built from masks alone — the view key did all the secret work.
let statement = crypto.generate_transfer_statement(
vec![input_witness],
Amount::zero(), // revealed_input_amount
&outputs,
Amount::zero(), // revealed_output_amount
)?;
Ok(Prepared {
statement,
resource_address,
input_commitment,
component_c,
})
}
/// Phase 2 — build, sign, and submit a transaction that passes `statement` into component C, which
/// executes `ResourceManager::stealth_transfer(statement)`. The signing/fee account needs NO rights
/// over the input UTXO — that UTXO is authorized by component C's access rule, not by a key.
async fn submit_release(indexer_url: &str, prepared: Prepared) -> Result<(), Box<dyn std::error::Error>> {
let Prepared {
statement,
resource_address,
input_commitment,
component_c,
} = prepared;
// The fee-paying / sealing account. Replace with your funded account's key.
let fee_account = PrivateKeyProvider::random(NETWORK);
let wallet = OotleWallet::from(fee_account);
let mut provider = ProviderBuilder::new().wallet(wallet).connect(indexer_url).await?;
// The on-chain address of the UTXO being spent, so the engine fetches it as an input.
let utxo = UtxoAddress::new(resource_address.clone(), input_commitment.into());
// The statement is CBOR-encoded as the single argument to component C's `release` method.
let unsigned_tx = IComponent::new(&provider)
.call_method(component_c, "release", args![statement])
.add_input(resource_address) // the stealth resource substate
.add_input(utxo) // the UTXO being spent
.pay_fee(1000u64)
.prepare()
.await?;
let transaction = TransactionRequest::default()
.with_transaction(unsigned_tx)
.build(provider.wallet())
.await?;
let pending = provider.send_transaction(transaction).await?;
let tx_id = pending.tx_id();
let outcome = pending.watch().await?;
println!("✓ Submitted tx {tx_id} — component C executed release(statement). Outcome: {outcome:?}");
Ok(())
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment