# Selective Disclosure on Aztec: Private Tax Reporting
There are many who use privacy infrastructure who argue for total financial privacy. It is an idea worth fighting for, but one that incurs great personal and potentially societal risk. Whether you like it or not, you owe taxes on your capital gains. The way tax reporting works today, at least in the United States you're expected to fill out something called Form 8949 itemizing every single trade you've ever made: the asset, the date, the cost basis, the proceeds, the gain or loss. All of it.
But the whole point of that itemization is to provide evidence for a single number at the bottom of Schedule D: your total capital gains. The government doesn't inherently need to know you bought 3 ETH at $2,400 and sold at $3,100. It needs confidence that when you report $2,100 in gains, that number was computed correctly under FIFO lot-based accounting. Form 8949 exists because, until now, there was no other way to establish that trust. Aztec and ZK Proofs change this.
We start with the PrivPNL decentralized AMM on Aztec. This is a demo AMM with nothing special happening (other than the privacy we already expect out of Aztec contracts). By doing ZK Proofs over Aztec's state and logs, users can generate a client side proof that their capital gains were computed correctly — without revealing any individual position, any trade size, or any asset held. Just the final number, provably correct.
This is what real selective disclosure looks like.
## 1. Selective Disclosure
Many privacy solutions talk about "selective disclosure". In practice, however, there are very few real demonstrations to talk about.
ZKEmail and ZKPassport are among the only "household name" ZK solutions that actually enable selective disclosure for their users. With ZK Email, you could selectively disclose the company you work for, or redact parts of an email's text. ZK Passport of course allows you to prove a country of residency or age without revealing your identity.
Using "view keys" is an especially egregious misrepresentation of selective disclosure - you aren't selecting what to disclose, you're disclosing everything. That's why the PrivPNL demo on Aztec is so important - it highlights an underutilized dimension of programmable cryptography.
While PrivPNL may not be strictly legal for tax reporting purposes, it should be. This rudimentary demo only applies a flat 20% tax rate - however, we could apply progressive taxation tied to income brackets, NIIT, multi-year lot tracking, differentiation between short term and long term capital gains, and capital loss carryover - all done without revealing anything new.
Therefore, PrivPNL is less of a production implementation and more of a call-to-action for builders to explore what kinds of applications they couldn't build without onchain selective disclosure enabled by Aztec.
## 2. Encrypted Swap Events
### 2.1 What the AMM Emits
We've taken the AMM reference contract from [aztec-packages](https://github.com/AztecProtocol/aztec-packages/tree/next/noir-projects/noir-contracts/contracts/app/amm_contract) and made one key addition: adding a ["Swap" event](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/contracts/amm_contract/src/main.nr#L63-L70) that is emitted on every trade:
```rust
#[event]
struct Swap {
token_in: AztecAddress,
token_out: AztecAddress,
amount_in: u128,
amount_out: u128,
is_exact_input: bool,
}
```
This event is encrypted and stored onchain. The existence of this note visible to everyone, but it is otherwise useless information. Only the recipient (in this case the sender themselves) of the log will be able to decrypt it.
Crucially, the event emission must use **constrained onchain delivery**. Without constraining, a user could substitute any sort of information they want to into the message. Without delivery, of course, we have no public record to build from in the future.
### 2.2 Event Retrieval via Tagging
Retrieving events from the chain uses Aztec's note-tagging system. Each encrypted log is tagged with `poseidon2(secret, index)`, siloed by contract address. For everyone else, this tag is just more indecipherable noise, but for senders and recipients who derive `secret` via ECDH, they can find eachother's notes to eachother though this protocol.
Tagging secrets are not viewing keys! Where viewing keys would allow anyone to actually DECRYPT the ciphertexts on top of finding them (via trial decryption), tagging secrets only reveal the existence of certain notes. Normally, this is used for a user to find their own notes. However, co-opting this mechanic is the foundation of our selective disclosure scheme.
Alongside constrained delivery of notes, there is a second requirement for the selective disclosure scheme to work - the user can hand their tagging secrets to an "auditor" without surrendering their viewing key. Currently, the Aztec wallet type doesn't natively support exporting tagging secrets, so we [forked it](https://github.com/jp4g/aztec-packages-note-discovery) to add an [`exportTaggingSecrets()`](https://github.com/jp4g/aztec-packages-note-discovery/commit/20a499c202) method. We aim to get some form of this natively supported in the future for everyone to extend.
### 2.3 The Auditor Model
The auditor takes the exported tagging secrets and independently [retrieves all encrypted events](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/proof/src/auditor/event-reader.ts#L20) from the chain. They cannot decrypt the events - they don't have the viewing key. But they serve a critical role of guaranteeing inclusion of all notes in the proof. There are of course games that can be played here - the auditor chooses not to return all notes and griefs the prover's tree - but we could get around this by proving the existence of notes that were not included by the auditor in the future.
The auditor's first job is to build a merkle tree of all the notes our proof needs to include. For each retrieved ciphertext, they compute `poseidon2_hash_with_separator(ciphertext, 0)` to get the leaf hash. They sort events chronologically by block number and build a binary incremental merkle tree over these leaves. Later on, the prover will iterate over each of the leaves, proving the preimage and surfacing insights.
When the user presents their final proof, the auditor checks that the proof's public merkle root output matches the root they independently computed. If it matches, every event the auditor found onchain was included in the proof. If it doesn't, the user omitted or reordered something.
The auditor has perfect confidence in this verification - they retrieved the events themselves, they built the tree themselves, and they checked the root themselves. There's no trust assumption on their end. But the proof doesn't necessarily stop at the auditor.
Your tax authority may want to act as the auditor themselves to get this information perfectly accurate. However, much like notaries in zkTLS schemes, perhaps you want to present this information elsewhere. The auditor could simply sign the merkle root to indicate the correctness of the proof. This of course transforms the trust mode from a trustless proof to an attestation. There are schemes that can be devised to make this process fully trustless, but this comes at considerable cost to both UX and DX of Aztec smart contracts.
---
## 3. The Individual Swap Circuit
The [`individual_swap`](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/individual_swap/src/main.nr#L25) circuit is the foundational proof. It takes a single encrypted swap event and proves three things: that the prover knows the plaintext, that the realized PnL is correctly computed, and that the lot state tree was employed and updated honestly.
### 3.1 Encryption & Pricing
The circuit re-derives the AES ciphertext from the plaintext and the prover's app-scoped viewing key ([`verify_encryption`](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/individual_swap/src/utils.nr#L129-L159)). By proving knowledge of the plaintext that goes to our ciphertext, we can guarantee correctness of the data in the event when we aggregate it together. Of course, we still need to anchor this to the merkle tree by re-hashing the ciphertext with poseidon.
As a PnL/tax reporting proof, we ultimately need to price everything in some base currency - in this case, we use the United States Dollar. While we could attempt to derive prices against AMM values, ultimately we need to assert the value of our swaps against some external pricing mechanism. Therefore, the circuit reads token prices from an onchain price feed contract by verifying [public state](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/individual_swap/src/utils.nr#L58-L78) at the block the swap occurred. The circuit asserts that both sell and buy prices are non-zero, ensuring the price feed has a value for each token at that block.
### 3.2 Lot Accounting & Outputs
[Lot tracking](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/individual_swap/src/utils.nr#L169-L172) is one of the most rigid areas of this demo. Currently, cost basis is tracked via a "lot state tree". This is a depth-3 merkle tree with 8 leaves, meaning we can only support up to 8 different tokens. Adding support for more tokens is trivial, however, we just increase the depth of the tree!
Each leaf is also a flat array of up to 32 FIFO lots:
```rust
pub struct Lot {
pub amount: Field,
pub cost_per_unit: Field,
}
```
A lot records "I bought X units at price Y." The full leaf preimage is `[token_address, num_lots, lot0.amount, lot0.cost, lot1.amount, lot1.cost, ...]` - 66 fields total, [hashed in a single Poseidon2 call](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/individual_swap/src/utils.nr#L174-L187). We use a flat array rather than a subtree because Poseidon2 hashing 66 elements (~22 permutations) is cheaper than maintaining a 32-leaf merkle subtree (31 hashes + sibling path verification). However, this is again an area of rigidity, and is potentially less extensible than using a tree. In the future, of course, we could just switch this to being a merkle tree if it was important.
When selling, [lots are consumed oldest-first](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/individual_swap/src/main.nr#L96-L111). For each consumed lot, realized PnL is `consumed * (sell_price - cost_basis)`:
```rust
for j in 0..MAX_LOTS {
let mut lot_amount: i64 = sell_lots[j].amount as i64;
let lot_cost: i64 = sell_lots[j].cost_per_unit as i64;
if (remaining != 0) & (lot_amount != 0) {
let consumed: i64 = if remaining < lot_amount { remaining } else { lot_amount };
pnl += consumed * (sell_price_i64 - lot_cost);
remaining -= consumed;
lot_amount -= consumed;
}
}
```
After consumption, the remaining lots are compacted and the sell leaf is rehashed. On the buy side, a new lot is simply [appended at the oracle buy price](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/individual_swap/src/main.nr#L137).
Since both the sell and buy leaves live in the same tree, the circuit does a two-step merkle update: first it updates the sell leaf (producing an intermediate root), then it updates the buy leaf against that intermediate root to get the final root.
The circuit returns 6 public outputs:
| Output | Purpose |
|---|---|
| `leaf` | Hash of the ciphertext -- used to build the completeness merkle tree |
| `pnl` | Signed i64 realized PnL for this swap (negative = loss) |
| `final_root` | Lot state tree root after this swap |
| `initial_lot_state_root` | Lot state tree root before this swap |
| `price_feed_address` | Oracle contract used (must be consistent across all proofs) |
| `block_number` | Block this swap occurred in (enforces chronological ordering) |
The `initial_lot_state_root -> final_root` transition is the state link that chains proofs together in the next stage.
---
## 4. The Summary Tree
With individual swap proofs in hand, we need to aggregate them into a single proof that says "here is my total PnL across all swaps, and I haven't omitted anything".
The [`swap_summary_tree`](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/swap_summary_tree/src/main.nr#L12) circuit combines two child proofs at a time, used recursively to build a binary tree:
```
[root proof]
/ \
[summary] [summary]
/ \ / \
proof_0 proof_1 proof_2 zero
```
For an odd number of proofs, the missing right child is padded with a zero leaf. The same circuit handles every level of the tree -- it distinguishes between level 0 (verifying individual swap proofs) and higher levels (verifying summary proofs) using the verification key hash. This includes the top -- if we wanted a pure PnL proof (not the tax proof), we'd use this exact circuit for the final proof.
At each node, the circuit does the following:
```
for each pair of child proofs:
1. verify both child proofs (UltraHonk recursive verification)
2. assert left.final_lot_root == right.initial_lot_root (state chaining)
3. assert left.block_number <= right.block_number (chronological order)
4. assert matching price feed address (consistent oracle)
5. root = hash(left.leaf, right.leaf) (completeness tree)
6. pnl = left.pnl + right.pnl (aggregate)
```
Step 2 is the key integrity mechanism -- the lot state chain. The final proof carries the chain endpoints: `left_initial_lots` from the very first swap and `right_remaining_lots` from the very last. Starting from an empty tree (root = 0), the entire lot state evolution is deterministic. There's no way to fabricate a cost basis or skip a trade without breaking the chain.
Step 5 builds the completeness merkle tree. At the leaves, each node is a ciphertext hash. At higher levels, each node is a hash of its children. The auditor independently retrieves all onchain swap events, hashes each ciphertext, builds the same binary tree, and checks that the root matches the proof's public output. If the user omits even one swap, the merkle root won't match.
Two mechanisms work together:
- **Merkle root of ciphertexts** -- "you included the right set of trades"
- **Lot state chain** -- "you accounted for them honestly"
---
## 5. The Tax Wrapper
The [`capital_gains_tax`](https://github.com/jp4g/aztec-pnl-proof/blob/main/packages/circuits/capital_gains_tax/src/main.nr) circuit is a thin wrapper around the summary proof. It verifies the summary proof recursively and computes a flat 20% tax on positive PnL.
For the most part, the tax proof wrapper is a placeholder. However, we have discussed the lot tracking infrastructure. We have what we need to track long term vs. short term cap gains. We could add additional infrastructure to track accumulated losses from previous years to facilitate capital loss carryover. We could add progressive levels of taxation based on income level.
All of this can be built on top of the existing PnL infrastructure described above and implemented in our demo.
---
## 6. Caveats & What's Next
This is a demo, and it comes with honest caveats. The lot state chain starts from an empty tree -- for subsequent tax periods, the auditor must verify the initial root against the prior period's final root, an out-of-band check not enforced by the circuit. The price feed contract is trusted to report honest prices; the circuit verifies merkle membership but not oracle integrity. And as discussed, the lot state tree's fixed dimensions (8 tokens, 32 lots each) are engineering constraints, not fundamental limitations.
But Form 8949 exists because, until now, there was no other way to establish trust in a reported number. Now there is. A user can prove their capital gains were computed correctly under FIFO accounting, against real onchain prices, over a complete set of trades -- without revealing a single position. The auditor gets confidence. The user gets privacy. The math is the bridge.
What we've built here is intentionally minimal. The lot tracking infrastructure already supports everything needed for long-term vs. short-term capital gains differentiation, progressive taxation, capital loss carryover, and NIIT. These aren't hypothetical extensions -- they're straightforward additions to the existing circuit architecture.
PrivPNL is a call-to-action. Selective disclosure on Aztec isn't limited to tax reporting -- any application where a user needs to prove aggregate properties over private state without revealing the underlying data is now possible. We hope this demo inspires builders to explore what else can be built.