Privacy
Covered in this page
Section titled “Covered in this page”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.
Two layers of privacy
Section titled “Two layers of privacy”Ootle’s privacy is not one mechanism but two, and they stack:
| Layer | Hides | Mechanism | Where funds live |
|---|---|---|---|
| Confidential | The amount | Pedersen commitments + range proofs | Vaults (balances) |
| Stealth | The amount and the owner | Commitments + one-time addresses | UTXOs (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.
Pedersen commitments
Section titled “Pedersen commitments”The foundation is a Pedersen commitment. A value is committed to with a blinding factor :
where and 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, reveals nothing about — an attacker cannot distinguish a commitment to 5 from a commitment to 5000. - Binding and homomorphic. The scheme is computationally binding (you cannot open 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 and are independent —
nobody knows a scalar with . If such an were known, a commitment would collapse
to a single-generator point , 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+
Section titled “Range proofs: Bulletproofs+”A commitment hides 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 , so "" is the enormous positive number . A spender could therefore pair a genuine output of with a phantom output of whose commitment they later discard — the balance proof (, 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:
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 commitments at
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_000base units. The bit length bounds the largest single value at 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
Section titled “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 and derives a one-time address:
| Step | Expression | Code |
|---|---|---|
| Shared secret | stealth_owner_dh | |
| Stealth public key | owner_stealth_dh_stealth_address | |
| Spending key (recipient) | owner_stealth_dh_secret | |
| Ephemeral nonce | stored 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 , can
recompute from and derive the spending key ; a third party sees and and cannot
connect either to the recipient’s public key. The output carries so the recipient can scan.
The trick that makes this work is a single algebraic identity. The sender forms ; the recipient forms . Because and ,
so both sides are computing the same group element from different secrets. Neither the sender (which knows but not ) nor the recipient (which knows but not ) can reach it alone, yet each can reach it with its own key and the other’s public value. Hashing that shared point gives — and 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
Section titled “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
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
Section titled “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 ; the holder of the view private key recovers the value point by
and then reverse-looks-up in a table that maps each small value to :
/// 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.
Encrypted payloads
Section titled “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
Section titled “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
Section titled “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
Section titled “Summary”- Two layers: confidential hides amounts; stealth hides amounts and ownership.
- Amounts are Pedersen commitments , hiding but homomorphic.
- Bulletproofs+ range proofs prove values are in range, aggregated for efficiency.
- Stealth addresses use Diffie-Hellman: , , .
- A statement-level balance proof proves without revealing values.
- ElGamal viewable balances () 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.