Skip to content

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.

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.

Two layers, and what each one hidesConfidential hides a number at a known place. Stealth hides the place as well.Confidentialhides the amountcomponentvaultC = mask·G + v·HAn observer sees the vaultand a transfer, not the balance.Stealthhides the amount and the ownerUTXOP, RUTXOP, RUTXOP, RAn observer sees outputs exist, but not their amount or whose they are.A confidential balance still sits at an address that can be watched. A stealth output has no such address:it is a one-time key, so there is nothing to watch in the first place.

The foundation is a Pedersen commitment. A value vv is committed to with a blinding factor mask\text{mask}:

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

where GG and HH 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, CC reveals nothing about vv — an attacker cannot distinguish a commitment to 5 from a commitment to 5000.
  • Binding and homomorphic. The scheme is computationally binding (you cannot open CC 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 GG and HH are independent — nobody knows a scalar xx with H=x⋅GH = x \cdot G. If such an xx were known, a commitment would collapse to a single-generator point (mask+x⋅v)⋅G(\text{mask} + x \cdot v) \cdot 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.


A commitment hides vv 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" is the enormous positive number ℓ−1\ell - 1. A spender could therefore pair a genuine output of +1+1 with a phantom output of −1-1 whose commitment they later discard — the balance proof (∑in=∑out\sum \text{in} = \sum \text{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 nn commitments at nn 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} - 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.


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 KK and derives a one-time address:

StepExpressionCode
Shared secretc=H(r⋅K)c = H(r \cdot K)stealth_owner_dh
Stealth public keyP=c⋅G+KP = c \cdot G + Kowner_stealth_dh_stealth_address
Spending key (recipient)p=c+kp = c + kowner_stealth_dh_secret
Ephemeral nonceR=r⋅GR = r \cdot 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 kk, can recompute cc from RR and derive the spending key pp; a third party sees PP and RR and cannot connect either to the recipient’s public key. The output carries RR so the recipient can scan.

The trick that makes this work is a single algebraic identity. The sender forms r⋅Kr \cdot K; the recipient forms k⋅Rk \cdot R. Because K=k⋅GK = k \cdot G and R=r⋅GR = r \cdot 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 R

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

The stealth address exchangeBoth sides arrive at the same secret c, from different secrets. Nobody else can reach it.Senderknows r, and the recipient’s KR = r·Gc = H(r·K)P = c·G + Kpublishes R and PObserversees R, P, and the ciphertexthas neither r nor kcannot form r·K or k·Rcannot link P to Kamount and owner stay hiddenRecipientknows k (so K = k·G)c = H(k·R) — the same cp = c + k (spending key)checks P = c·G + Kscans using RR and P travel in the clear — and the observer, tapping that wire, still learns nothing

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


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=∑outputs\sum \text{inputs} = \sum \text{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.

Hidden values, checkable commitmentsThe engine never sees the top row. It checks the bottom row, and the two agree.as cleartext — stays in the wallet, never on chain5+3=4+4=8 = 8the same statement, as commitments — this is what the engine verifiesC₁+C₂=C₃+C₄=points equalWhy this is soundCommitments are additive, so theengine can add the points in the lowerrow without opening any of them.If the masks are arranged to cancel,the two sums are equal exactly whenthe hidden values are equal.Range proofs then stop a negativevalue from faking that equality.

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.


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); the holder of the view private key pp recovers the value point by

V=E−p⋅RV = E - p \cdot R

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

crates/engine_types/src/crypto/elgamal.rs
/// The compressed point `V = E - p·R`, which the value lookup table is reverse-searched
pub 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.


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.


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.


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.


  • 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 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), P=cG+KP = cG + K, p=c+kp = c + k.
  • A statement-level balance proof proves ∑in=∑out\sum \text{in} = \sum \text{out} without revealing values.
  • ElGamal viewable balances (V=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.