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 decryptsencrypted_data, derived asH(DH(view_secret, sender_public_nonce)). The two are independent.
| 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. |
gh gist clone <this-gist>
cd <dir>
cargo runPhase 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).
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 ismask·G + value·H, and the aggregate input mask feeds the Schnorr balance proof. Both come straight out of decryptingencrypted_datawith the view key. No spend secret key appears anywhere in statement construction. - For an
access_rule-gated input: a script-pathSpendWitnessthat reveals theaccess_ruleleaf and its inclusion proof against the committedcondition_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.
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.
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 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.rsResourceManager::stealth_transfer_with_opt_input_bucket(statement, input_bucket)— same fileBucket::stealth_transfer(statement)—crates/template_lib/src/models/bucket.rsVault::pay_fee_stealth(statement)—crates/template_lib/src/models/vault.rs
- 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. - The access rule must be satisfiable by C's scope at execution.
verify_input_authorizationschecksScopedToComponent(C)against the current executing component. That is C only if C invokes the transfer (thereleasemethod above). A top-levelStealthTransferinstruction 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.)
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.
| 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.