Privacy

Ootle / Concepts / Privacy

Privacy

This page covers the cryptography behind private state:

  • The two privacy layers — confidential (hides amounts) and stealth (hides ownership too).
  • Pedersen commitments: how a balance is hidden but still provably consistent.
  • Bulletproofs+: how the engine proves an encrypted value is in range.
  • Stealth addresses: one-time keys derived by Diffie-Hellman.
  • The balance proof and ElGamal viewable balances for auditing.
  • Encrypted payloads for values, masks, and memos.
  • The threat model — what is hidden, and what still leaks.

Note

This page describes the primitives. For a step-by-step treatment of building stealth transfers — including condition trees, covenants, and runnable examples — see Stealth Transfers.


Two layers of privacy

Ootle’s privacy is not one mechanism but two, and they stack:

LayerHidesMechanismWhere funds live
ConfidentialThe amountPedersen commitments + range proofsVaults (balances)
StealthThe amount and the ownerCommitments + one-time addressesUTXOs (unspent outputs)

A confidential resource keeps balances hidden inside a vault: an observer sees that a vault holds something, but not how much. A stealth resource goes further — funds are individual UTXOs with one-time addresses, so an observer cannot link an output to a person, and cannot read the amount. The native token, TARI, is a stealth resource.

Caution

Both layers hide values, but not everything. Transaction fees must be paid in a known amount, so fees are revealed; and any interaction with a public component can link a transaction to an identity. The full list of leaks is in Threat model below.


Pedersen commitments

The foundation is a Pedersen commitment. A value vvv is committed to with a blinding factor masktext{mask}mask:

C=mask⋅G+v⋅HC = text{mask} cdot G + v cdot HC=mask⋅G+v⋅H

where GGG and HHH are independent generators of the Ristretto group. The engine builds these with an extended Pedersen factory (crates/engine_types/src/crypto/helpers.rs):

use tari_crypto::ristretto::pedersen::extended_commitment_factory::ExtendedPedersenCommitmentFactory;

Two properties make this the right primitive:

  • Hiding. For a random mask, CCC reveals nothing about vvv — an attacker cannot distinguish a commitment to 5 from a commitment to 5000.
  • Binding and homomorphic. The scheme is computationally binding (you cannot open CCC to two different values), and linear: commitments add. That is what allows a set of inputs and outputs to be checked without decrypting any of them.

The name of the blinding factor in code is mask; the committed value is value.

Why a second generator matters. The definition is only sound because GGG and HHH are independent — nobody knows a scalar xxx with H=x⋅GH = x cdot GH=x⋅G. If such an xxx were known, a commitment would collapse to a single-generator point (mask+x⋅v)⋅G(text{mask} + x cdot v) cdot G(mask+x⋅v)⋅G, and a spender could re-open the same point as a different value by adjusting the mask to compensate. Independence of the two generators is exactly what pins a commitment to one value; the extended Pedersen factory in crates/engine_types/src/crypto/helpers.rs is what supplies the pair.


Range proofs: Bulletproofs+

A commitment hides vvv but says nothing about it. Without a further check, a malicious spender could commit to a negative value (which the group arithmetic would happily accept) and mint value from nothing. The arithmetic is what makes this real rather than pedantic: values are scalars modulo the group order ℓellℓ, so “−1-1−1″ is the enormous positive number ℓ−1ell – 1ℓ−1. A spender could therefore pair a genuine output of +1+1+1 with a phantom output of −1-1−1 whose commitment they later discard — the balance proof (∑in=∑outsum text{in} = sum text{out}∑in=∑out, checked on commitments) still holds, but one unit of value has been conjured into existence.

Ootle closes this with range proofs, and the implementation is Bulletproofs+ — the more efficient successor to the original Bulletproofs:

crates/engine_types/src/crypto/helpers.rs
use tari_crypto::ristretto::bulletproofs_plus::BulletproofsPlusService;
const BP_BIT_LENGTH: usize = u64::BITS as usize; // 64
static ref RANGE_PROOF_AGG_1_SERVICE: BulletproofsPlusService =    BulletproofsPlusService::init(BP_BIT_LENGTH, 1, ExtendedPedersenCommitmentFactory::default()).unwrap();

A range proof demonstrates that the committed value lies in a valid range without revealing it. Two implementation facts matter:

  • Aggregation. Proofs over multiple commitments are batched: the engine keeps services for aggregation factors 1, 2, 4, 8, and 16, and verifies a proof over nnn commitments at nnn rounded up to a power of two (MAX_LAZY_BP_AGG_FACTORS = 16). Aggregating is what keeps a many-output stealth transfer cheap to verify.
  • Six-decimal precision. Values are amounts in TARI’s smallest unit; the native token has divisibility 6, so 1 TARI = 1_000_000 base units. The bit length bounds the largest single value at 264−12^{64} – 1264−1 base units.

Range proofs are the reason a confirmed transfer can be checked for validity — “no value was invented” — without any party learning an amount.


Stealth addresses

Confidential amounts below still leave one thing visible: which vault. Stealth transfers remove that by never writing the recipient’s real key on-chain.

For each output, the sender performs a Diffie-Hellman exchange with the recipient’s public key KKK and derives a one-time address:

StepExpressionCode
Shared secretc=H(r⋅K)c = H(r cdot K)c=H(r⋅K)stealth_owner_dh
Stealth public keyP=c⋅G+KP = c cdot G + KP=c⋅G+Kowner_stealth_dh_stealth_address
Spending key (recipient)p=c+kp = c + kp=c+kowner_stealth_dh_secret
Ephemeral nonceR=r⋅GR = r cdot GR=r⋅Gstored as sender_public_nonce

All of these live in crates/wallet/crypto/src/kdfs.rs. The public entry points take a Network, and the domain hasher mixes it into the derivation, so a key derived for one network does not carry over to another. Only the recipient, who knows kkk, can recompute ccc from RRR and derive the spending key ppp; a third party sees PPP and RRR and cannot connect either to the recipient’s public key. The output carries RRR so the recipient can scan.

The trick that makes this work is a single algebraic identity. The sender forms r⋅Kr cdot Kr⋅K; the recipient forms k⋅Rk cdot Rk⋅R. Because K=k⋅GK = k cdot GK=k⋅G and R=r⋅GR = r cdot GR=r⋅G,

r⋅K  =  r⋅(k⋅G)  =  k⋅(r⋅G)  =  k⋅Rr cdot K ;=; r cdot (k cdot G) ;=; k cdot (r cdot G) ;=; k cdot Rr⋅K=r⋅(k⋅G)=k⋅(r⋅G)=k⋅R

so both sides are computing the same group element from different secrets. Neither the sender (which knows rrr but not kkk) nor the recipient (which knows kkk but not rrr) can reach it alone, yet each can reach it with its own key and the other’s public value. Hashing that shared point gives ccc — and ccc is what both hides the recipient and lets only them spend.

A view-only key can be baked into an address, letting a holder see incoming funds without being able to spend them. That is what supports auditable-but-private accounts; the address type carries it as OotleAddress { view_only_key, .. } (crates/ootle_address/src/ootle_address.rs).


The balance proof

Hiding every value creates an obvious problem: how does anyone know value was conserved? The answer is a balance proof over the whole statement. Because commitments are homomorphic, the protocol can prove

∑inputs=∑outputssum text{inputs} = sum text{outputs}∑inputs=∑outputs

by checking the corresponding commitments rather than the values. If the blinding factors are chosen so that the masks cancel, the summed input commitment equals the summed output commitment exactly when the cleartext sums are equal.

The balance proof belongs to the statement, not to either side, because it is a claim about both at once. It is absent only for a transfer that is revealed-only. The aggregated range proof — the one described above — belongs to the outputs statement, and covers exactly the output commitments. See Stealth Transfers for the full statement layout.


ElGamal viewable balances

Sometimes amounts must be visible to someone — an auditor, a regulator, or the issuer of an asset — without becoming public. Ootle supports this with an ElGamal encrypted balance attached to each output when the resource has a view key.

The ciphertext is a pair (E,R)(E, R)(E,R); the holder of the view private key ppp recovers the value point by

V=E−p⋅RV = E – p cdot RV=E−p⋅R

and then reverse-looks-up VVV in a table that maps each small value vvv to v⋅Gv cdot Gv⋅G:

crates/engine_types/src/crypto/elgamal.rs
/// The compressed point `V = E - p·R`, which the value lookup table is reverse-searchedpub fn value_lookup_target(&self, view_private_key: &RistrettoSecretKey) -> RistrettoPublicKeyBytes {    let point = &self.encrypted - view_private_key * &self.public_nonce;    point.to_byte_type()}

The “reverse lookup” is why the scheme targets amounts (bounded, enumerable) rather than arbitrary data. A ViewableBalanceProof accompanies the ciphertext so that a verifier can confirm the encrypted amount matches the committed amount — an auditor sees the value, an observer sees only that some valid value exists.

Tip

A view key is a disclosure choice, not a weakening of the cryptography: spending still requires the spend key, so a view-only holder can audit but not move funds.


Encrypted payloads

Each stealth output carries an EncryptedData payload encrypted with XChaCha20-Poly1305, keyed from the same Diffie-Hellman exchange used for the stealth address (crates/wallet/crypto/src/encrypted_data.rs). It holds:

  • the value (8 bytes),
  • the mask (32 bytes) — the Pedersen blinding factor, without which the recipient cannot later spend the output,
  • an optional memo (0–255 bytes).

The mask being inside the ciphertext is essential: it is the spending secret for that output, and it must reach the recipient privately.


Hashing

Ootle uses BLAKE2b with domain separation for its internal hashes (crates/engine_types/src/hashing.rs): each use-case mixes in a distinct domain tag, so a hash produced for one purpose can never be reinterpreted as valid for another. Templates can also call other hash functions directly through EngineArgs — HASH_SHA256, HASH_SHA512, and HASH_KECCAK256 alongside HASH_BLAKE2B (crates/template_lib_types/src/engine_args.rs) — so a hashlock secret can be shared with a chain that speaks SHA-256, such as Bitcoin. BLAKE2b remains the engine’s internal default; the others are there for interoperability.

Spend conditions themselves are committed in a Merklized condition tree (MAST): the output stores only a root, and a spend reveals one leaf plus a logarithmic inclusion proof. The alternative spend paths stay hidden. The full model — SpendAuthorization (key, script, or both) and conjunctions of atomic conditions — is in Stealth Transfers, with the types in crates/template_lib_types/src/stealth/unspent_output.rs.


Threat model

Privacy on Ootle is a property of how a transaction is built, not a blanket guarantee. Here is the honest ledger.

Hidden:

  • Confidential and stealth amounts — hidden by commitments.
  • Stealth ownership — hidden by one-time addresses; no link to the recipient’s real key.

Revealed by design:

  • Transaction fees must be paid in a known amount, so a fee is always plaintext.
  • Revealed inputs and outputs are public. If any part of a transfer is revealed, its amount is visible.

Leakage you can cause:

  • Signing with a public key. Calling a component method that checks caller identity forces a signature that links the transaction to you, even if the outputs hide their recipients.
  • Amount correlation. If you withdraw exactly the amount you then transfer as a revealed input, the two are linkable.
  • Graph analysis. The structure of transactions — which inputs were spent, which outputs were created — is public even when owners and amounts are not.
  • Script-path reveals. A script-path spend publishes the condition leaf it exercises, which may name keys, a deadline, or a component.

The design guidance that follows from this — prefer spending stealth inputs only, keep revealed amounts to the fee, and put one-time keys in condition leaves — is worked through in Stealth Transfers under Privacy Gotchas.


Summary

  • Two layers: confidential hides amounts; stealth hides amounts and ownership.
  • Amounts are Pedersen commitments C=mask⋅G+v⋅HC = text{mask} cdot G + v cdot HC=mask⋅G+v⋅H, hiding but homomorphic.
  • Bulletproofs+ range proofs prove values are in range, aggregated for efficiency.
  • Stealth addresses use Diffie-Hellman: c=H(rK)c = H(rK)c=H(rK), P=cG+KP = cG + KP=cG+K, p=c+kp = c + kp=c+k.
  • A statement-level balance proof proves ∑in=∑outsum text{in} = sum text{out}∑in=∑out without revealing values.
  • ElGamal viewable balances (V=E−pRV = E – pRV=E−pR) let a view key audit without spending power.
  • Payloads are encrypted with XChaCha20-Poly1305; internal hashing is domain-separated BLAKE2b.
  • Privacy is conditional: fees, revealed amounts, identity-linked signatures, and transaction graphs all leak. Design deliberately.

Next: what these primitives mean for a real application — Privacy in Applications.