Aleo privacy architecture and patterns
1. Overview
Aleo lets you build applications where inputs, outputs, and state stay private while remaining provably correct. This skill covers the architectural patterns for doing that, and the mistakes that quietly undo it.
Version and canonical syntax
Target: Leo compiler >= 4.4.0.
Entry points are fn. On-chain state changes go in a final { } block inside the entry point. Mapping and storage work is confined to final code; private record logic lives in the proof half. Context accessors are std::ctx::caller() and std::ctx::signer().
2. Key concepts
- Ciphertext: the encrypted form of a record stored on-chain. Only the owner can decrypt it with their view key.
- View key: a derived key that decrypts records owned by the matching address without the ability to spend them. Shareable with auditors for read-only access.
- Nullifier (serial number): published on-chain when a record is consumed. Prevents double-spending without revealing which record was spent.
- Private input: a parameter not visible on-chain. Record fields and unmarked parameters are private by default.
- Public input: a parameter marked
public, visible on-chain. - Shielding: converting a public mapping balance into a private record.
- Unshielding: converting a private record back into a public mapping balance.
3. The privacy boundary
Knowing exactly what an observer sees is the whole game.
| Component | Visibility | Notes |
|---|---|---|
| Program ID | public | everyone sees which program was called |
| Function name | public | everyone sees which entry point ran |
| Transaction metadata | public | timestamp, fee, block inclusion |
public parameters | public | explicitly marked |
| Unmarked parameters | private | never leaves the caller's device |
| Record fields | private | encrypted on-chain, owner-only decryption |
Record fields marked public | public | visible in the transaction |
| Mapping keys and values | public | all mapping data is globally readable |
| Storage variables and vectors | public | same as mappings |
| Serial numbers | public | published to prevent double-spend, not linkable to a specific record |
Mapping and storage values are always public. A balance in a mapping keyed by address is a balance anyone can look up, forever. Private balances need records.
4. Record lifecycle
1. CREATION → an entry point outputs a new record
2. ENCRYPTION → the record is encrypted to the owner's address
3. ON-CHAIN → the ciphertext is stored in the ledger
4. DISCOVERY → the owner scans the chain for their records using the view key
5. DECRYPTION → the owner decrypts to read the contents
6. CONSUMPTION → the record is passed into an entry point and destroyed
7. NULLIFY → a serial number is published, preventing replay
8. NEW RECORDS → the entry point creates fresh records as outputs
Records are one-time-use. After consumption a record no longer exists. This is Bitcoin's UTXO model with encryption on top, and the same consequences follow: you track your own state, and change outputs are normal.
5. Pattern 1: hybrid public and private token
This is the pattern most Aleo applications end up building on. Public balances give you composability, private balances give you privacy, and the bridges move value between them. The full implementation is in aleo_smart_contracts section 17, with six entry points: mint_public, mint_private, transfer_public, transfer_private, transfer_public_to_private (shielding), and transfer_private_to_public (unshielding).
The bridges are where privacy is won and lost. Shielding reveals the amount leaving the public balance; unshielding reveals the amount arriving. What stays hidden is everything the record does in between.
6. Pattern 2: sealed-bid auction (record as capability)
Holding a particular record authorizes an action. The record is the capability.
program auction.aleo {
@noupgrade
constructor() {}
record Bid {
owner: address, // the auctioneer holds every bid
bidder: address, // who placed it
amount: u64, // hidden from other bidders
}
// The bid record is owned by the auctioneer, so no bidder can read another's
fn place_bid(bidder: address, amount: u64, auctioneer: address) -> Bid {
assert_eq(std::ctx::signer(), bidder);
return Bid { owner: auctioneer, bidder: bidder, amount: amount };
}
// Only the record owner can consume it, so only the auctioneer compares
fn resolve(first: Bid, second: Bid) -> Bid {
assert_eq(std::ctx::caller(), first.owner);
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 };
}
}
// Hand the winning bid record to the winner
fn finish(winning_bid: Bid) -> Bid {
assert_eq(std::ctx::caller(), winning_bid.owner);
return Bid {
owner: winning_bid.bidder,
bidder: winning_bid.bidder,
amount: winning_bid.amount,
};
}
}
Bidders cannot see each other's bids, because the records belong to the auctioneer. Only the auctioneer can compare them. The winning amount can stay private. Transaction metadata reveals that the program was called and nothing about the values.
The trust assumption is real: the auctioneer can lie about which bid won, because the comparison happens in a proof only they can generate. For a trustless auction, use commit-reveal instead and let the chain do the comparing.
7. Pattern 3: private voting with a public tally
Private ticket records authorize a ballot; the tally is public and verifiable.
struct Proposal {
title: field,
content: field,
proposer: address,
}
program vote.aleo {
@noupgrade
constructor() {}
mapping proposals: field => Proposal;
mapping agree_votes: field => u64;
mapping disagree_votes: field => u64;
// The voting capability, held privately
record Ticket {
owner: address,
pid: field,
}
fn propose(public info: Proposal) -> Final {
let pid: field = BHP256::hash_to_field(info);
return final {
proposals.set(pid, info);
agree_votes.set(pid, 0u64);
disagree_votes.set(pid, 0u64);
};
}
fn new_ticket(public pid: field, voter: address) -> Ticket {
return Ticket { owner: voter, pid: pid };
}
// Consuming the ticket is what prevents double voting
fn agree(ticket: Ticket) -> Final {
let pid: field = ticket.pid;
return final {
let current: u64 = agree_votes.get_or_use(pid, 0u64);
agree_votes.set(pid, current + 1u64);
};
}
fn disagree(ticket: Ticket) -> Final {
let pid: field = ticket.pid;
return final {
let current: u64 = disagree_votes.get_or_use(pid, 0u64);
disagree_votes.set(pid, current + 1u64);
};
}
}
Voter identity stays private: the ticket is consumed and only a nullifier appears on-chain. Tallies are public and anyone can verify them. Each ticket votes once, because a consumed record cannot be spent again. Nobody can tell how a given voter voted.
The leak to watch is timing. If tickets are issued one at a time and votes arrive shortly after, on-chain timing correlates issuance with ballots. Issue tickets in batches, or accept the correlation deliberately.
8. Pattern 4: commit-reveal
Commit to a value without revealing it, then reveal it later and let the chain check you did not change your mind.
program commit_reveal.aleo {
@noupgrade
constructor() {}
mapping commitments: address => field;
mapping revealed_values: address => u64;
// Phase 1: publish the commitment only
fn commit(public commitment_hash: field) -> Final {
let sender: address = std::ctx::caller();
return final {
commitments.set(sender, commitment_hash);
};
}
// Phase 2: reveal the value and salt; the chain recomputes and compares
fn reveal(public secret_value: u64, public salt: scalar) -> Final {
let sender: address = std::ctx::caller();
let recomputed: field = BHP256::commit_to_field(secret_value, salt);
return final {
let stored: field = commitments.get(sender);
assert_eq(recomputed, stored);
revealed_values.set(sender, secret_value);
commitments.remove(sender);
};
}
}
The commitment is recomputed in the proof half and only the resulting field crosses into final, so the comparison is on-chain while the arithmetic is not.
Off-chain, the flow is: pick a secret, generate a random scalar salt, compute BHP256::commit_to_field(secret, salt), submit commit, and later submit reveal with the original pair. Keep the salt. Losing it means you can never reveal, and the commitment sits on-chain forever.
The salt has to be random. A commitment over a low-entropy value with no salt is a lookup table away from being public.
9. Pattern 5: view key selective disclosure
View keys grant read access without spending authority.
Private key → Compute key → View key → Address
↓ ↓ ↓ ↓
full control proving only read-only public ID
Share a view key with an auditor for compliance, a monitoring service, or a portfolio tracker. Share the private key with nobody.
View key disclosure is all-or-nothing per account and cannot be revoked: someone holding it can decrypt every record that address ever received, past and future. If you need to disclose selectively, use a separate address per relationship rather than handing out one view key.
10. Anti-patterns
Marking a sensitive parameter public
// Bad: the amount is on-chain in plain view
fn bad_transfer(token: Token, receiver: address, public amount: u64) -> (Token, Final) { ... }
// Good: unmarked parameters are private
fn good_transfer(token: Token, receiver: address, amount: u64) -> (Token, Token) { ... }
Using public state for private data
// Bad: every balance is world-readable
mapping balances: address => u64;
// Good: records keep balances private
record Token { owner: address, amount: u64 }
Passing private values into finalization
A final block runs on-chain, so anything it writes becomes public. Reading token.amount in the proof half and then storing it in a mapping publishes it just as surely as marking the parameter public. The Leo 4.x closure model makes this easier to do by accident, since the final block can reach any variable in scope without an explicit parameter list.
Using std::ctx::caller() for user authorization
// Bad: in a composed call this is the intermediate program's address
fn admin_only() {
assert_eq(std::ctx::caller(), ADMIN);
}
// Good: signer is always the account that initiated the transaction
fn admin_only() {
assert_eq(std::ctx::signer(), ADMIN);
}
The compiler warns about this: std::ctx::caller() may return a program address, which cannot spend records.
Making record fields public without needing to
// Bad: the amount is visible in the transaction
record Token { owner: address, public amount: u64 }
// Good: private is the default for a reason
record Token { owner: address, amount: u64 }
Deterministic commitments
A commitment with no randomness can be precomputed and matched. Use a random scalar salt for anything privacy-sensitive.
11. Common privacy errors
| Symptom | Cause | Fix |
|---|---|---|
| A private value shows up on-chain | parameter marked public, or written into a mapping from final | Keep it private and out of public state |
| Authorization passes for the wrong party | std::ctx::caller() used for user auth | Use std::ctx::signer() |
| Reveal transaction is rejected | commitment recomputed with the wrong value or salt | Recompute off-chain from the exact original pair |
| A vote or bid can be replayed | the capability record was not consumed | Take the record as an input so its nullifier is published |
| Users are identifiable despite private state | mapping keys or call timing correlate to identity | Separate public coordination from private records; avoid identifying keys |
12. Security checklist
- Sensitive values are private parameters, not
public. - Private data lives in records, never in mappings or storage.
std::ctx::signer()is used for user authentication.- Auditors get view keys, never private keys.
- Commitments use random salts.
- Record fields carry no unnecessary
publicannotation. - No private value crosses into a
finalblock and gets written to public state. - The program and entry-point names do not themselves reveal user intent, since both are public on every call.
- Mapping keys are not identifiers you would not publish directly.
- Public entry points are analysed for front-running. Aleo has a mempool and public inputs are visible before inclusion, so a public state change can be observed and raced. Privacy of the inputs is what removes the opportunity, not the proof system by itself.
13. Performance notes
Prefer straightforward record transformations to deep conditional branching; both branches of every if are evaluated in the circuit, so branching depth is paid for in proving time.
Reuse compact structs for repeated private payloads to keep the witness small.
Remove commitments once revealed. Public state that is never cleaned up grows without bound and someone pays to store it.
Use deterministic indexing only for data you are happy to make linkable.
Write private programs with aleo_smart_contracts. Test privacy properties with aleo_testing. Handle records in the browser with aleo_frontend. Start from working code in aleo_cookbook.
15. Agent workflow for privacy design
- Classify every value first: public, private, or revealed at a later phase. Write it down before writing code.
- Choose the state model from that classification. Records for private state, mappings and storage only for state you intend to publish.
- Model caller semantics.
std::ctx::signer()for user authorization,std::ctx::caller()only for program-to-program trust. - Threat-model linkability: mapping keys, call timing, deterministic commitments, and the program and function names themselves.
- Add negative tests for privacy regressions, including unauthorized calls and accidental public exposure.
- Compile, test, then query the public endpoints and confirm that only what you intended is visible.