Aleo Privacy Architecture & Patterns
1. Overview
Aleo's core value proposition is programmable privacy. Unlike traditional blockchains where all state is public, Aleo allows developers to create applications where inputs, outputs, and state can be kept private while still being provably correct. This skill teaches the architectural patterns for building privacy-preserving applications.
Version & Canonical Syntax
- Target: Leo compiler >= 3.5.0
- Canonical syntax: Entry points use
transition(orasync transitionwhen on-chain state is needed), with on-chain state updates in a separateasync function finalize_NAME(...)block - Privacy boundary rule: All mapping reads and writes happen in the
async function finalize_*block, while private record logic happens in the transition body - Async pattern: An
async transitionreturns aFutureand callsreturn finalize_NAME(args...);— the correspondingasync function finalize_NAME(...)runs on-chain - When docs conflict: Prefer current Leo repository behavior and this skill's examples
2. Key Concepts & Glossary
- Ciphertext: The encrypted form of a record stored on-chain. Only the record owner can decrypt it using their view key.
- View Key: A derived key that allows decrypting records owned by the corresponding address, WITHOUT the ability to spend/consume them. Can be shared with auditors for read-only access.
- Nullifier / Serial Number: A unique value published on-chain when a record is consumed. Prevents double-spending without revealing which record was consumed.
- Private Input: A function parameter not visible on-chain. By default, record fields and unmarked parameters are private.
- Public Input: A function parameter visible on-chain. Marked with
publickeyword. - Shielding: Converting public state (mapping balance) to private state (record). Also called "privatizing."
- Unshielding: Converting private state (record) to public state (mapping balance). Also called "publicizing."
3. Privacy Boundary — What Is Public vs. Private
Understanding what observers can see is critical for correct privacy architecture:
| Component | Visibility | Notes |
|---|---|---|
| Program ID | PUBLIC | Everyone sees which program was called |
| Function name | PUBLIC | Everyone sees which function was called |
| Transaction metadata | PUBLIC | Timestamp, fee amount, block inclusion |
Public inputs (public params) | PUBLIC | Explicitly marked values |
| Private inputs (default params) | PRIVATE | Never leaves the user's device |
| Record contents (private fields) | PRIVATE | Encrypted on-chain, only owner decrypts |
Record field (public annotation) | PUBLIC | Visible in transaction |
| Mapping keys and values | PUBLIC | All mapping data is globally readable |
| Serial numbers (nullifiers) | PUBLIC | Published to prevent double-spend, but unlinkable to specific records |
Key insight: Mapping values are ALWAYS public. If you store a balance in a mapping keyed by address, everyone can see that address's balance. Use records for private balances.
4. The Record Lifecycle (UTXO Model)
1. CREATION → A function outputs a new record
2. ENCRYPTION → Record is encrypted to the owner's address
3. ON-CHAIN → Encrypted ciphertext stored in the ledger
4. DISCOVERY → Owner scans the chain to find their records (using view key)
5. DECRYPTION → Owner decrypts the record to see its contents
6. CONSUMPTION → Record is used as input to a function (destroyed)
7. NULLIFY → A serial number is published (prevents replay)
8. NEW RECORDS → Function creates new records as outputs
Critical: Records are one-time-use. After consumption, a record no longer exists. This is analogous to Bitcoin's UTXO model, but with encryption.
5. Pattern 1: Hybrid Public/Private Token
The most important privacy pattern. Allows both public balances (for DeFi interoperability) and private balances (for privacy), with bridges between them.
See the complete token program in the aleo_smart_contracts skill (Section 16) for the full 6-function implementation:
mint_public— Add to public balancemint_private— Create private recordtransfer_public— Public to publictransfer_private— Private to private (UTXO)transfer_public_to_private— Shieldingtransfer_private_to_public— Unshielding
6. Pattern 2: Sealed-Bid Auction (Record-as-Capability)
In this pattern, owning a specific record cryptographically authorizes the holder to take a future action. The record acts as a "capability token."
program auction.aleo {
@noupgrade
async constructor() {}
record Bid {
owner: address, // The auctioneer holds all bids
bidder: address, // Who placed the bid
amount: u64, // Bid amount (hidden from others)
}
// Place a bid — creates a record owned by the AUCTIONEER
// The bidder's amount is hidden from other bidders
transition place_bid(
bidder: address,
amount: u64,
auctioneer: address,
) -> Bid {
// Verify the caller is the bidder
assert_eq(self.caller, bidder);
return Bid {
owner: auctioneer, // Auctioneer holds the bid record
bidder: bidder,
amount: amount,
};
}
// Resolve two bids — auctioneer compares them privately
transition resolve(
first: Bid,
second: Bid,
) -> Bid {
// Only the auctioneer (record owner) can call this
assert_eq(self.caller, first.owner);
// Return the higher bid, now owned by the auctioneer
if first.amount >= second.amount {
return Bid {
owner: first.owner,
bidder: first.bidder,
amount: first.amount,
};
} else {
return Bid {
owner: second.owner,
bidder: second.bidder,
amount: second.amount,
};
}
}
// Finish auction — transfer winning bid record to the winner
transition finish(winning_bid: Bid) -> Bid {
// Only the auctioneer can finish
assert_eq(self.caller, winning_bid.owner);
// Transfer ownership to the winner
return Bid {
owner: winning_bid.bidder,
bidder: winning_bid.bidder,
amount: winning_bid.amount,
};
}
}
Privacy properties:
- Bidders cannot see each other's bids (records owned by auctioneer)
- Only the auctioneer can compare bids
- The winning bid amount can remain private
- Transaction metadata shows the program was called, but not the bid values
Trust assumption: The auctioneer must be honest. They could lie about which bid won. For trustless auctions, use commit-reveal pattern instead.
7. Pattern 3: Private Voting with Public Tally
This pattern uses private "ticket" records for ballot casting, while vote tallies are publicly verifiable.
program vote.aleo {
@noupgrade
async constructor() {}
// Public proposal state
struct Proposal {
title: field,
content: field,
proposer: address,
}
mapping proposals: field => Proposal;
mapping agree_votes: field => u64;
mapping disagree_votes: field => u64;
// Private voting capability
record Ticket {
owner: address,
pid: field, // proposal ID
}
// Create a new proposal (public)
async transition propose(public info: Proposal) -> Future {
let pid: field = BHP256::hash_to_field(info);
return finalize_propose(pid, info);
}
async function finalize_propose(pid: field, info: Proposal) {
Mapping::set(proposals, pid, info);
Mapping::set(agree_votes, pid, 0u64);
Mapping::set(disagree_votes, pid, 0u64);
}
// Issue a voting ticket to a voter (private)
transition new_ticket(public pid: field, voter: address) -> Ticket {
return Ticket {
owner: voter,
pid: pid,
};
}
// Cast an "agree" vote (consumes ticket, increments public tally)
async transition agree(ticket: Ticket) -> Future {
let pid: field = ticket.pid;
return finalize_agree(pid);
}
async function finalize_agree(pid: field) {
let current: u64 = Mapping::get_or_use(agree_votes, pid, 0u64);
Mapping::set(agree_votes, pid, current + 1u64);
}
// Cast a "disagree" vote
async transition disagree(ticket: Ticket) -> Future {
let pid: field = ticket.pid;
return finalize_disagree(pid);
}
async function finalize_disagree(pid: field) {
let current: u64 = Mapping::get_or_use(disagree_votes, pid, 0u64);
Mapping::set(disagree_votes, pid, current + 1u64);
}
}
Privacy properties:
- Voters' identities are private (ticket record is consumed, only nullifier published)
- Vote tallies are public and verifiable
- Each voter can only vote once (ticket is consumed)
- No one can determine which way a specific voter voted
8. Pattern 4: Commit-Reveal
Two-phase pattern where a user commits to a value without revealing it, then reveals it later.
program commit_reveal.aleo {
@noupgrade
async constructor() {}
mapping commitments: address => field;
mapping revealed_values: address => u64;
// Phase 1: COMMIT — hash the secret value with a random salt
async transition commit(public commitment_hash: field) -> Future {
let sender: address = self.caller;
return finalize_commit(sender, commitment_hash);
}
async function finalize_commit(sender: address, commitment_hash: field) {
// Store the commitment (hash of secret + salt)
Mapping::set(commitments, sender, commitment_hash);
}
// Phase 2: REVEAL — provide the original value and salt
async transition reveal(
public secret_value: u64,
public salt: scalar,
) -> Future {
let sender: address = self.caller;
return finalize_reveal(sender, secret_value, salt);
}
async function finalize_reveal(
sender: address,
secret_value: u64,
salt: scalar,
) {
// Recompute the commitment from the revealed values
let recomputed: field = BHP256::commit_to_field(secret_value, salt);
// Verify it matches the stored commitment
let stored: field = Mapping::get(commitments, sender);
assert_eq(recomputed, stored);
// Store the revealed value
Mapping::set(revealed_values, sender, secret_value);
// Clean up the commitment
Mapping::remove(commitments, sender);
}
}
Client-side (off-chain) preparation:
1. User chooses secret_value = 42u64
2. User generates random salt (scalar)
3. User computes commitment = BHP256::commit_to_field(42u64, salt)
4. User calls commit(commitment)
5. Later, user calls reveal(42u64, salt)
9. Pattern 5: View Key Selective Disclosure
View keys allow sharing read-only access to records without granting spending authority.
Key hierarchy:
Private Key → Compute Key → View Key → Address
↓ ↓ ↓ ↓
Full control Proving only Read-only Public ID
Use cases:
- Share view key with an auditor for compliance
- Share view key with a monitoring service
- Share view key with a portfolio tracker
- NEVER share the private key except for spending
10. Anti-Patterns (What NOT To Do)
Leaking private values into finalize via public parameters
// BAD: amount is now publicly visible on-chain!
async transition bad_transfer(
token: Token,
receiver: address,
public amount: u64, // This is PUBLIC — everyone sees it!
) -> (Token, Future) { ... }
// GOOD: keep amount private
transition good_transfer(
token: Token,
receiver: address,
amount: u64, // Private by default
) -> (Token, Token) { ... }
Using public mappings for data that should be private
// BAD: everyone can see every user's balance
mapping balances: address => u64;
// GOOD: use records for private balances
record Token { owner: address, amount: u64 }
Logging private record data in finalize
// BAD: private data becomes public mapping values
async transition bad_log(token: Token) -> Future {
let amount: u64 = token.amount;
let caller: address = self.caller;
return finalize_bad_log(caller, amount); // Amount is now PUBLIC!
}
async function finalize_bad_log(caller: address, amount: u64) {
Mapping::set(logs, caller, amount);
}
Using self.caller for user auth in cross-program calls
// BAD: self.caller could be an intermediate program, not the user
transition admin_only() {
assert_eq(self.caller, ADMIN_ADDRESS);
}
// GOOD: use self.signer for user-level access control
transition admin_only() {
assert_eq(self.signer, ADMIN_ADDRESS);
}
Making record fields public unnecessarily
// BAD: amount is visible on-chain in the transaction
record Token {
owner: address,
public amount: u64, // Anyone can see this!
}
// GOOD: keep amount private
record Token {
owner: address,
amount: u64, // Private by default
}
Reusing deterministic values as privacy-sensitive identifiers
If a commitment or hash is deterministic (no randomness), an observer can precompute and identify it. Always use random salts (scalars) for privacy-sensitive commitments.
11. Common Privacy Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
| Private value appears on-chain unexpectedly | Parameter was marked public or moved into mapping state | Keep sensitive parameters private and avoid writing them into public mappings |
| Unauthorized admin access in cross-program flow | Used self.caller for user-level auth | Use self.signer for user authorization checks |
| Reveal transaction fails | Commitment computed with wrong value or salt | Recompute commitment off-chain with exact original value + salt and submit matching reveal |
| Vote or bid can be replayed | Capability record not consumed in action function | Consume the record as an input so nullifier enforcement prevents reuse |
| Metadata deanonymizes users | Program structure leaks identity correlations | Separate public coordination data from private records and minimize identifying mapping keys |
12. Security Checklist
- All sensitive values use private inputs (not
public) - Private data is stored in records, not mappings
self.signeris used for user authentication (notself.caller)- View keys are used for monitoring (not private keys)
- Commitments use random salts (not deterministic hashes)
- Record fields are private by default (no unnecessary
publicannotations) - No private data leaks into finalize via public parameters
- No private data stored in public mappings
- Program name does not reveal sensitive user intent
- Front-running is impossible (Aleo's proof system prevents it — no mempool observation)
13. Performance Notes
- Prefer simple record transformations over deep conditional branching to keep circuits smaller
- Reuse compact structs for repeated private payload shapes to reduce witness overhead
- Keep commit-reveal state minimal and remove commitments promptly after reveal to reduce public state growth
- Use deterministic indexing only for non-sensitive data; add randomness where linkability risk exists
- Write private programs using
aleo_smart_contracts - Test privacy properties using
aleo_testing - Frontend record management using
aleo_frontend - Complete privacy recipes in
aleo_cookbook
15. Agent SOP: Privacy Design Workflow
- Classify data first: mark each value as public, private, or revealable-at-later-phase
- Choose state model: records for private state, mappings only for intentionally public state
- Model caller semantics: use
self.signerfor user auth andself.calleronly for program-to-program trust checks - Threat-model linkability: inspect mapping keys, emitted metadata, and deterministic commitments
- Add negative tests for privacy regressions such as accidental public exposure or unauthorized calls
- Run compile and tests, then validate that only intended data is visible in public endpoints