skill·2026-03-09·12 min read

Aleo Privacy Architecture & Patterns

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 (or async transition when on-chain state is needed), with on-chain state updates in a separate async 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 transition returns a Future and calls return finalize_NAME(args...); — the corresponding async 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 public keyword.
  • 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:

ComponentVisibilityNotes
Program IDPUBLICEveryone sees which program was called
Function namePUBLICEveryone sees which function was called
Transaction metadataPUBLICTimestamp, fee amount, block inclusion
Public inputs (public params)PUBLICExplicitly marked values
Private inputs (default params)PRIVATENever leaves the user's device
Record contents (private fields)PRIVATEEncrypted on-chain, only owner decrypts
Record field (public annotation)PUBLICVisible in transaction
Mapping keys and valuesPUBLICAll mapping data is globally readable
Serial numbers (nullifiers)PUBLICPublished 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 balance
  • mint_private — Create private record
  • transfer_public — Public to public
  • transfer_private — Private to private (UTXO)
  • transfer_public_to_private — Shielding
  • transfer_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."

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

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

leo
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

leo
// 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

leo
// 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

leo
// 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

leo
// 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

leo
// 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

ErrorCauseFix
Private value appears on-chain unexpectedlyParameter was marked public or moved into mapping stateKeep sensitive parameters private and avoid writing them into public mappings
Unauthorized admin access in cross-program flowUsed self.caller for user-level authUse self.signer for user authorization checks
Reveal transaction failsCommitment computed with wrong value or saltRecompute commitment off-chain with exact original value + salt and submit matching reveal
Vote or bid can be replayedCapability record not consumed in action functionConsume the record as an input so nullifier enforcement prevents reuse
Metadata deanonymizes usersProgram structure leaks identity correlationsSeparate public coordination data from private records and minimize identifying mapping keys

12. Security Checklist

  1. All sensitive values use private inputs (not public)
  2. Private data is stored in records, not mappings
  3. self.signer is used for user authentication (not self.caller)
  4. View keys are used for monitoring (not private keys)
  5. Commitments use random salts (not deterministic hashes)
  6. Record fields are private by default (no unnecessary public annotations)
  7. No private data leaks into finalize via public parameters
  8. No private data stored in public mappings
  9. Program name does not reveal sensitive user intent
  10. 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

  1. Classify data first: mark each value as public, private, or revealable-at-later-phase
  2. Choose state model: records for private state, mappings only for intentionally public state
  3. Model caller semantics: use self.signer for user auth and self.caller only for program-to-program trust checks
  4. Threat-model linkability: inspect mapping keys, emitted metadata, and deterministic commitments
  5. Add negative tests for privacy regressions such as accidental public exposure or unauthorized calls
  6. Run compile and tests, then validate that only intended data is visible in public endpoints

Sources