test(privacy): confirm AMM circuit bug blocks Swap/AddLiquidity/RemoveLiquidity privacy tests

Adds private-account tests for AMM's SwapExactInput/SwapExactOutput, AddLiquidity, and
RemoveLiquidity confirming the "Invalid account_identities length" circuit bug also fires
with real private accounts, not just the all-public control case, plus a distinct
RemoveLiquidity finding (destination must already exist). Also deduplicates the
shielded_token_transfer test helper and updates findings/matrix docs accordingly.
This commit is contained in:
Marvin Jones
2026-07-13 17:30:13 -04:00
parent ce0a8fe324
commit 1c65011264
4 changed files with 1025 additions and 158 deletions
+178 -83
View File
@@ -1,23 +1,27 @@
# LEE privacy
# Privacy coverage in LEZ programs
Similar to public accounts, private accounts can be regular (generated using based on user generated keys) and PDA. Additionally, private accounts can be shared by a group.
LEZ programs, ideally, are privacy agnostic. E.g., a program should work the same for public and private accounts. Currently, LEZ program integration tests only cover public accounts. This task, we expand the tests for LEZ programs to determine how adaptable (TODO-probably wrong word) LEZ programs are to selective privacy.
# Private account variants in LEE
LEE's private state supports (regular) accounts, PDAs and group owned accounts.
## Overview of (regular) private accounts
### Private account initialization
Regular private accounts initialization with or without knowledge of the account's nullifier secret key `nsk`.
Regular private accounts can be initialized with or without knowledge of the account's nullifier secret key `nsk`. This results in two initialization "types": `PrivateUnauthorized` and `PrivateAuthorizedInit`.
- `PrivateUnauthorized`
A special case for initializing private accounts using only `npk` and `vpk`.
Use cases;
Private donations. A user publishes public keys (`npk`, `vpk`) associated to a set of private account keys. A third party initializes a fresh private account using these keys (and some `identifier`). This initialization transaction does not require the corresponding `nsk`. Any future transactions with this private account must be performed by the account owner (using the `nsk`).
A special case for private accounts initialization that uses only public keys `npk` and `vpk`. Example: Alice can use Bob's keys (`npk`, `vpk`) and an `identifier` to send Bob a private transaction. Since Alice does not know the corresponding `nsk`, she is spend the resulting private account. E.g., Alice cannot authorize the transaction.
- `PrivateAuthorizedInit`
Private account initialized using the account's `nsk` (and some `identifier`).
Private account initialized using the account's `nsk` (and some `identifier`). This operation cannot be done by the a third-party (an entity that does not possess spending authority of the account).
### Private account update (`PrivateAuthorizedUpdate`)
Regular private accounts are updated the same way. Knowledge of the account's `nsk` and other data that is used for the
Private account updates require knowledge of the account's `nsk`. E.g., Alice cannot update the private account that she initialized for Bob.
### Summary
@@ -29,91 +33,104 @@ Regular private accounts are updated the same way. Knowledge of the account's `n
Only the account owner can (1) update their initialized account, and (2) use functions that require authorization with their account.
### Remark
- `PrivateUnauthorized` initialization is used for account initialization. `is_authorized = false` is a protection that does not seem crucial. Artifically, blocks some functions. (TODO: return to and shift to conclusions)
## Private PDA
### Private PDA vs public PDA
- `AccountId` formulas are different:
Private PDAs spending is restrict by a specific program. E.g., an AMM pool has PDAs for liquidity definition and vaults (for Token A and Token B). A program sets `is_authorized = true` for an account (purported PDA) by checking the correctness of its `AccountId`.
- `AccountId` formulas:
- Public: `hash(prefix || program_id || seed)`
- Private: `hash(prefix || program_id || seed || npk || identifier)`
The difference in these PDA `AccountId` formulas prevents programs from being privacy agnostic for PDAs.
## Group-shared (multi-party) private accounts (TODO)
## Group-shared (multi-party) private accounts
A single private account can be jointly controlled by two or more parties without either one
handing over their actual secret key. The mechanism is a **Group Master Secret (GMS)**,
distributed via a real seal/unseal handshake (ML-KEM-768), not key reuse:
1. One party ("Alice") creates a `GroupKeyHolder` and derives the shared account's `npk`/`vpk`
from it (`derive_keys_for_shared_account(seed)`).
2. Alice **seals** the GMS against a second party's ("Bob's") own sealing public key
(`seal_for`) and hands over only the sealed bytes.
3. Bob **unseals** it with his own sealing secret key (`GroupKeyHolder::unseal`), then
independently re-derives the *identical* `nsk`/`npk` from the same seed — without ever
touching Alice's `GroupKeyHolder` object directly.
1. Alice creates a `GroupKeyHolder` and derives the shared account's keys (`nsk`, `vsk`)
from it.
2. Alice **seals** the GMS against Bob's sealing public key and hands over only the sealed bytes.
3. Bob **unseals** it with his own sealing secret key, then
independently re-derives the account's keys from the same seed.
Bob's re-derived `nsk` then works in `PrivateAuthorizedInit`/`PrivateAuthorizedUpdate` exactly
like a personally-held key — confirmed indistinguishable from a personal account for every
instruction tried (spend, sign, self-initialize), across Token, ATA, and Stablecoin.
This ensures that any member of the group can execute programs on shared accounts using either `PrivateAuthorizedInit` or `PrivateAuthorizedUpdate`. From a program's perspective, shared accounts should behave the same as regular public accounts.
# Privacy testing objectives for LEZ programs (TODO)
# Privacy coverage for LEZ programs objectives (TODO)
- [ ] **Private PDAs used as program inputs across the above flows.**
**Not achieved — structurally blocked, not a test gap.** Every program with PDAs (ATA,
AMM, Stablecoin) derives them via `for_public_pda(program_id, seed)` only. The private
formula, `for_private_pda(program_id, seed, npk, identifier)`, additionally requires an
`npk` — but none of `ata_core`/`amm_core`/`stablecoin_core`'s seed-computation functions
accept an `npk` today, so it's never reachable through these programs as coded. Confirmed
empirically not-expressible for ATA (`ata_create_private_ata_holding_is_not_expressible`);
the same root cause applies to AMM and Stablecoin (identical `for_public_pda`-only
pattern, verified directly in their `*_core` crates). Token has no PDAs at all — N/A at
that layer, not a gap.
*Re: "could we compose a test program that uses private PDAs with these pre-existing?"*
no. None of the four existing programs can be made to produce a `for_private_pda` address
through a test alone, since the formula choice is hardcoded in their source. Demonstrating
the mechanism at all would require either changing one of the `*_core` crates to derive via
`for_private_pda`, or standing up a small purpose-built program whose only job is to
exercise it — both are source changes, not test-writing. **This is the single most
actionable item to feed back to the protocol team.**
In this task, we plan to add tests for e
- Group owned shared private account as input to programs.
- [x] **Sending funds to an existing private account.**
**Achieved, with one real condition: cooperation is required.** Confirmed across Token
(`Transfer`, `Mint`), ATA (`Transfer`, including through a nested chained call into
Token), and Stablecoin (`WithdrawCollateral`). Every path that touches an *existing*
private account (`PrivateAuthorizedUpdate`) requires that account's own `nsk` plus a
membership proof, supplied in the same transaction — there is no blind-credit analog to
`PrivateUnauthorized` for existing accounts (only *fresh* accounts can be credited by a
stranger). This isn't partial — it's a clean, fully-confirmed yes with one unavoidable,
real-world condition: the recipient must be reachable to supply their `nsk` (online or
pre-coordinated). That's a protocol/wallet-UX property to design around, not a bug or an
untested edge.
- [~] **Multiple private accounts in one transaction, and private accounts carried through
chained calls.** This is two separate sub-objectives with different status — worth
splitting:
- **Multiple private accounts in one tx — Achieved.** `token_private_transfer` (sender +
recipient, both private, zero public accounts anywhere) and
`token_private_transfer_into_existing_private_holding` (same, recipient already
existing).
- **Carried through a chained call — Achieved, but only single-hop so far.**
`ata_transfer_to_existing_private_recipient` proves a private identity survives one
chained call (ATA → Token) — the first test in the whole exercise to prove this works
at all. Every private Stablecoin `WithdrawCollateral`/`RepayDebt` test also carries a
private account through exactly one chained call (Stablecoin → Token). **Not yet
tested:** deeper, multi-hop chaining — an instruction issuing more than one chained
call with a private account threaded through it (e.g. AMM's `SwapExactInput` chains
into *both* Token and the TWAP oracle in one instruction). That case is currently
unreachable: AMM is blocked entirely by a separate, privacy-unrelated circuit bug (see
the AMM section) before any chaining depth can even be exercised. So: not unclear —
genuinely proven for the single-hop case, with the deeper case blocked pending AMM.
| | description | |
|---------|----|----|
| PDA |
| REGULAR |
| EXIST |
| GROUP |
| CHAIN |
- Regular private accounts
- `PrivateUnauthorized` accounts; e.g., "transfer to existing accounts".
- Group shared private accounts
- Private PDAs.
# LEZ programs (TODO)
## AMM program
TODO
**Headline finding: no privacy-preserving test can be written for AMM's pool-mutating
instructions at all right now — not because of privacy, but a distinct circuit-level bug.**
Before any private-account test, an all-public control test through `execute_and_prove` (same
discipline that caught Stablecoin's `OpenPosition` bug) turned up a second, unrelated
circuit-level issue specific to AMM: `SwapExactInput` fails inside `execute_and_prove` with
`"Invalid account_identities length"` — we supply 8 account identities, the circuit's
`states_iter` only computes 7 — with every account `Public` and zero private accounts involved.
The same pattern reproduces on `SyncReserves` (6 vs 5). The account that silently vanishes from
the circuit trace is `CLOCK_01_PROGRAM_ACCOUNT_ID` — present in the top-level input and in the
AMM program's own returned `post_states` (confirmed in `sync.rs`/`swap.rs` source), but never
seen by the circuit at any call depth. Root cause not yet found.
Five tests confirm this **also blocks real private-account attempts**, not just the all-public
control case — `amm_swap_a_to_b_private_user_holding_is_not_expressible` and
`amm_swap_exact_output_private_user_holding_is_not_expressible` (private `user_holding_a`, 8 vs
7), `amm_add_liquidity_private_lp_holding_is_not_expressible` (private `user_holding_lp`, 10 vs
9), `amm_add_liquidity_private_user_holdings_is_not_expressible` (private `user_holding_a` +
`user_holding_b` deposit legs, 10 vs 9), `amm_remove_liquidity_private_lp_holding_is_not_expressible`
(private `user_holding_lp`, 10 vs 9) — all five fail with the identical
`"Invalid account_identities length"` panic, always exactly one account short. **Consequence**:
Swap (both variants), AddLiquidity, and RemoveLiquidity cannot be tested for any Q2 privacy
dimension until this circuit bug is fixed — every planned AMM privacy test is blocked on it. See
`docs/privacy-test-matrix.md`'s AMM section for the full bisection log.
**⚠ To track down later — confirmed `clock` is the account that vanishes, root cause still
open**: instrumented tracing (`eprintln!`s in the pinned `lee_core` checkout's
`execution_state.rs`, exact `Display`-string matching against `CLOCK_01_PROGRAM_ACCOUNT_ID`)
confirmed the circuit's internal per-account processing (`states_iter`) never contains an entry
for `clock`, at any call depth — not the top-level AMM call, not even inside the TWAP
`UpdateCurrentTick` chained call, which itself explicitly re-passes `clock.clone()`. Ruled out a
coincidental `AccountId` collision. **Still unknown**: whether the entry is dropped inside the
AMM guest's own execution, inside the SPEL-macro-generated `#[lez_program]` wrapper code, or
inside the circuit's own bookkeeping before `validate_and_sync_states`'s per-account loop even
runs. **Next concrete step**: check whether `pre_states.len()`/`post_states.len()` already
differ from N/N *before* that loop runs — that single check localizes the bug to one side or the
other and was never executed before this investigation was paused.
**A second, distinct finding for `RemoveLiquidity`, unrelated to the circuit bug above:**
`remove_liquidity` requires `user_holding_a`/`user_holding_b` to already exist and already be
owned by the configured Token Program (`remove.rs`'s
`assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`) — unlike
`token::transfer`'s recipient handling, which tolerates `Account::default()` and self-initializes
it. So `RemoveLiquidity` can never pay out to a brand-new private destination
(`PrivateUnauthorized` — only `npk` known, no `nsk`): the attempt
(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) fails inside the AMM
program's own precondition check, *before* any chained call or the privacy-preserving circuit is
ever reached — and would equally reject a brand-new *public* destination. Same shape of finding
as Stablecoin's `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`:
a plain program-level precondition that predates privacy entirely, not a circuit artifact.
## ATA program
@@ -121,6 +138,7 @@ ATA program offers limited usage with private accounts. Private accounts can be
| Function tested | Test name | Category | Description of objective | Result |
|---|---|---|---|---|
| Create | `ata_create_from_private_owner` | BASE (private owner only; ATA account + definition public) | Any third party can bootstrap another owner's ATA using only that owner's public key material (`PrivateUnauthorized``npk`/`vpk` only, no `nsk`) — `Create` never asserts `owner.is_authorized` | ✅ |
| Create | `ata_create_private_ata_holding_is_not_expressible` | PDA | Attempts to make the ATA holding itself a private account via `PrivatePdaInit`/`PrivatePdaUpdate` — confirms the public-form PDA match ATA authorizes with and the private-form binding those variants require are mutually exclusive for the same account id | ❌ (confirmed not-expressible) |
| Create | `ata_create_from_group_owned_owner` | GROUP | Group-derived owner identity used to create an ATA — **weaker than the other `GROUP` rows**: `Create` never requires `owner` to prove control, so this can't demonstrate genuine shared control the way the `Transfer`/`Burn` rows below do; it only confirms `Create` doesn't secretly care where `npk`/`vpk` came from | ✅ (defensive/symmetry coverage only) |
| Transfer | `ata_transfer_to_existing_private_recipient` | EXIST, CHAIN | Sends more into an already-shielded private recipient through ATA's *nested* chained call into Token — the first test in the whole exercise proving a private identity survives a chained call at all | ✅ |
@@ -132,11 +150,13 @@ and `Burn` call the same `ata_core::verify_ata_and_get_seed` function, so the id
public-form/private-form conflict applies to them too, even though only `Create` has a dedicated
test asserting it.
Two tests exist outside this table's four categories and are worth noting separately:
`ata_burn_with_private_owner_signing` and `ata_transfer_with_private_owner_signing` (a
*personal*, non-group private owner signing `Burn`/`Transfer`). They were the key discovery that
`owner` must be a *signer* for these two instructions (unlike `Create`) — a real finding, just
not one of the four Q2 checkboxes, so it's omitted here the same way Token's `BASE` rows were.
Two tests exist outside this table's categories (not `PDA`/`GROUP`/`EXIST`/`CHAIN`, and not
`BASE` either — tagged `new: signer-authorization` in `docs/privacy-test-matrix.md`) and are
worth noting separately: `ata_burn_with_private_owner_signing` and
`ata_transfer_with_private_owner_signing` (a *personal*, non-group private owner signing
`Burn`/`Transfer`). They were the key discovery that `owner` must be a *signer* for these two
instructions (unlike `Create`) — a real finding, just a distinct dimension from any tag used
elsewhere in this table.
## Stablecoin program
@@ -210,13 +230,21 @@ destination. It's why every `WithdrawCollateral` test above uses `PrivateAuthori
| Function tested | Test name | Category | Description of objective | Result |
|---|---|---|---|---|
| Transfer | `token_transfer_into_existing_private_holding` | EXIST | Second transfer into an already-shielded recipient — confirms crediting an existing private account requires the recipient's own cooperation (`nsk`), not just their public key | ✅ |
| Transfer | `token_private_transfer_into_existing_private_holding` | EXIST, CHAIN | Both legs private (sender + recipient) in one transaction, and the recipient is already existing rather than fresh | ✅ |
| Transfer | `token_group_owned_holding_shared_control_transfer` | GROUP | Group-owned sender (real GMS seal/unseal handshake) spends outward via `Transfer` to a fresh private recipient | ✅ |
| Transfer | `token_private_transfer` | CHAIN | Pre-existing test; two private accounts (sender + fresh recipient) compose in a single transaction with no public account at all — fulfills the "multiple private accounts in one tx" half of `CHAIN` | ✅ |
| Mint | `token_mint_into_existing_private_holding` | EXIST | Mint once to establish a private holding, mint again into it via `PrivateAuthorizedUpdate` — crediting an existing private account | ✅ |
| Transfer | `token_shielded_transfer` | EXIST | A public sender shields tokens into a fresh private recipient (`PrivateUnauthorized` — only `npk`/`vpk` known, no `nsk`) | ✅ |
| Transfer | `token_private_transfer` | REGULAR -> EXIST | Two private accounts (sender via `PrivateAuthorizedUpdate` + fresh recipient via `PrivateUnauthorized`) compose in a single transaction with no public account at all — fulfills the "multiple private accounts in one tx" | ✅ |
| Transfer | `token_deshielded_transfer` | REGULAR | A private sender (`PrivateAuthorizedUpdate`) transfers out to a public recipient | ✅ |
| Transfer | `token_shielded_transfer_authorized_private_init` | REGULAR | Fresh recipient self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) instead of being passively credited via `PrivateUnauthorized` | ✅ |
| Transfer | `token_transfer_into_existing_private_holding` | REGULAR | Similar to `token_shielded_transfer_authorized_private_init`, but this shielded transaction does not initialize the private account. Second transfer into an already-shielded recipient — confirms crediting an existing private account requires the recipient's own cooperation (`nsk`), not just their public key | ✅ |
| Transfer | `token_private_transfer_into_existing_private_holding` | REGULAR -> REGULAR | Both legs private (sender + recipient) in one transaction, and the recipient is already existing rather than fresh | ✅ |
| Transfer | `token_group_owned_holding_shared_control_transfer` | GROUP -> EXIST | Group-owned sender (real GMS seal/unseal handshake) spends outward via `Transfer` to a fresh private recipient (`PrivateUnauthorized`) | ✅ |
| Mint | `token_mint_private_unauthorized` | EXIST | Mint directly to a fresh private recipient (self-authority signer + `PrivateUnauthorized` recipient) | ✅ |
| Mint | `token_mint_authorized_private_init` | REGULAR (authorized variant) | Mint to a fresh recipient that self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) instead of being passively credited | ✅ |
| Mint | `token_mint_into_existing_private_holding` | REGULAR | Mint once to establish a private holding, mint again into it via `PrivateAuthorizedUpdate` — crediting an existing private account | ✅ |
| Burn | `token_private_burn` | REGULAR | Burn from an existing private holding via a single `PrivateAuthorizedUpdate` | ✅ |
| Burn | `token_group_owned_holding_shared_control_burn` | GROUP | Shield tokens into a GMS-derived shared holding, then burn from it using an independently re-derived key | ✅ |
| InitializeAccount | `token_initialize_private_account_succeeds_for_canonical_definition` | REGULAR | Self-init of a private holding via `PrivateAuthorizedInit` | ✅ |
| InitializeAccount | `token_group_owned_holding_shared_control_initialize` | GROUP | A group member — not the party who created the group — self-initializes the shared holding directly via `PrivateAuthorizedInit` | ✅ |
| MintWithAuthority | `token_mint_with_authority_to_private_holding` | EXIST | External-authority mint (distinct signer from the definition) directly to a fresh private recipient | ✅ |
**`PDA`** has no Token-layer rows: Token holdings are addressed by an arbitrary `AccountId`, not
a program-derived one — there's no PDA to make private at this layer. Only testable once a
@@ -226,6 +254,18 @@ holding is wrapped by another program's PDA (ATA/AMM/Stablecoin).
`ChainedCall`s of its own (only ATA/AMM/Stablecoin do) — that half is exercised for the first
time in the ATA section instead.
| | coverage? | explanation |
|----|---------|----------------|
| REGULAR | full | REGULAR private accounts are used as sender/recipient for initialize, transfer, mint and burn |
| GROUP | full | Tested with initialize, transfer, mint and burn |
| EXIST | partial | EXIST (`PrivateUnauthorized`) cannot be used with initialize due to `is_authorize = false` |
| PDA | N/A | Token program does not use PDAs |
# Conclusions
## Group shared private accounts
@@ -233,4 +273,59 @@ time in the ATA section instead.
# Observations
- Programs can be made privacy agnostic for PDAs by adjusting private PDA `AccountId` formula to match the public variant. Unclear how to precisely handle this to ensure `AMM program` generates unique pools for token pairs (in public PDA case).
- A private PDA can be initialized and used for a program without using traditional PDA lifecycle. E.g., TODO(provide example from `token.rs`)
- A private PDA can be initialized and used for a program without using traditional PDA lifecycle. E.g., TODO(provide example from `token.rs`)
# TODO
- [ ] **Private PDAs used as program inputs across the above flows.**
**Not achieved — structurally blocked, not a test gap.** Every program with PDAs (ATA,
AMM, Stablecoin) derives them via `for_public_pda(program_id, seed)` only. The private
formula, `for_private_pda(program_id, seed, npk, identifier)`, additionally requires an
`npk` — but none of `ata_core`/`amm_core`/`stablecoin_core`'s seed-computation functions
accept an `npk` today, so it's never reachable through these programs as coded. Confirmed
empirically not-expressible for ATA (`ata_create_private_ata_holding_is_not_expressible`);
the same root cause applies to AMM and Stablecoin (identical `for_public_pda`-only
pattern, verified directly in their `*_core` crates). Token has no PDAs at all — N/A at
that layer, not a gap.
*Re: "could we compose a test program that uses private PDAs with these pre-existing?"*
no. None of the four existing programs can be made to produce a `for_private_pda` address
through a test alone, since the formula choice is hardcoded in their source. Demonstrating
the mechanism at all would require either changing one of the `*_core` crates to derive via
`for_private_pda`, or standing up a small purpose-built program whose only job is to
exercise it — both are source changes, not test-writing. **This is the single most
actionable item to feed back to the protocol team.**
- Group owned shared private account as input to programs.
- [x] **Sending funds to an existing private account.**
**Achieved, with one real condition: cooperation is required.** Confirmed across Token
(`Transfer`, `Mint`), ATA (`Transfer`, including through a nested chained call into
Token), and Stablecoin (`WithdrawCollateral`). Every path that touches an *existing*
private account (`PrivateAuthorizedUpdate`) requires that account's own `nsk` plus a
membership proof, supplied in the same transaction — there is no blind-credit analog to
`PrivateUnauthorized` for existing accounts (only *fresh* accounts can be credited by a
stranger). This isn't partial — it's a clean, fully-confirmed yes with one unavoidable,
real-world condition: the recipient must be reachable to supply their `nsk` (online or
pre-coordinated). That's a protocol/wallet-UX property to design around, not a bug or an
untested edge.
- [~] **Multiple private accounts in one transaction, and private accounts carried through
chained calls.** This is two separate sub-objectives with different status — worth
splitting:
- **Multiple private accounts in one tx — Achieved.** `token_private_transfer` (sender +
recipient, both private, zero public accounts anywhere) and
`token_private_transfer_into_existing_private_holding` (same, recipient already
existing).
- **Carried through a chained call — Achieved, but only single-hop so far.**
`ata_transfer_to_existing_private_recipient` proves a private identity survives one
chained call (ATA → Token) — the first test in the whole exercise to prove this works
at all. Every private Stablecoin `WithdrawCollateral`/`RepayDebt` test also carries a
private account through exactly one chained call (Stablecoin → Token). **Not yet
tested:** deeper, multi-hop chaining — an instruction issuing more than one chained
call with a private account threaded through it (e.g. AMM's `SwapExactInput` chains
into *both* Token and the TWAP oracle in one instruction). That case is currently
unreachable: AMM is blocked entirely by a separate, privacy-unrelated circuit bug (see
the AMM section) before any chaining depth can even be exercised. So: not unclear —
genuinely proven for the single-hop case, with the deeper case blocked pending AMM.
+76 -25
View File
@@ -402,44 +402,95 @@ computes). Confirmed with every account `Public`.
actual private account, or use `PublicTransaction` instead) — not a bug, but worth noting:
**the "all-public control" methodology needs at least one trivial private leg to get past
this check for future control tests**, not just all-`Public` identities.
- **Leading structural lead, not yet confirmed**: every AMM instruction that hits the length
- **Leading structural lead, superseded below**: every AMM instruction that hits the length
mismatch passes a *post-update* copy of `pool` (`pool_price_source`, holding `pool_post`
the already-mutated state, not the original pre-state) into its chained TWAP call. This
"pass what's about to become the post-state as the next call's own pre-state" pattern is
proven correct on the public-transaction path (33 passing tests) but nothing in
Token/ATA/Stablecoin ever exercised it under the privacy circuit. Not yet confirmed as *the*
cause — only the clearest outlier found.
Token/ATA/Stablecoin ever exercised it under the privacy circuit. This was the leading lead
at the time, but is likely **not** the real cause — see the more precise finding below, which
identifies the specific missing account directly.
- **Precisely identified the missing account (2026-07-13)**: instrumented `execution_state.rs`'s
per-account loop in `validate_and_sync_states` with `eprintln!` tracing (see below for how this
was made to actually take effect) and confirmed via exact string-level `AccountId` matching
that `CLOCK_01_PROGRAM_ACCOUNT_ID` is the account that vanishes — it's supplied as a top-level
input and is clearly present in the AMM program's own returned `post_states` (confirmed
directly in `sync.rs`'s `sync_reserves` and `swap.rs`'s `finalize_swap`, both of which
explicitly include `AccountPostState::new(clock.account...)`), yet it never appears in the
circuit-level trace at any call depth, not even inside the TWAP chained call which also
explicitly passes `clock.clone()`. Root cause of *why* it's dropped is still not found — the
next diagnostic step (checking whether `pre_states.len()`/`post_states.len()` already differ
from 8/8 before the per-account validation loop runs, which would localize the drop to either
the AMM guest/SPEL-macro layer or the circuit's own processing) was planned but not executed.
Instrumentation was fully reverted afterward (verified byte-identical to the original checkout
and original artifact) rather than left in place.
- **Confirmed this also blocks real private-account attempts, not just the all-public control
case (2026-07-13)**: three tests — `amm_swap_a_to_b_private_user_holding_is_not_expressible`
(private `user_holding_a`, 8 vs 7 accounts), `amm_add_liquidity_private_lp_holding_is_not_expressible`
(private `user_holding_lp`, 10 vs 9), `amm_remove_liquidity_private_lp_holding_is_not_expressible`
(private `user_holding_lp`, 10 vs 9) — all fail with the identical
`"Invalid account_identities length"` panic, always exactly one account short. This rules out
"the bug only manifests because there are zero private accounts" as an explanation; it's a
structural property of these instructions' account/chained-call shape, independent of privacy
entirely.
**Why this wasn't root-caused further**: attempted source-level instrumentation
(`eprintln!` tracing added directly to the pinned `lee_core` checkout's `execution_state.rs`)
to watch the exact bookkeeping live. Confirmed `lee`/`lee_core` genuinely recompiled
(`cargo clean -p lee -p lee_core` + fresh compile logs), but the added prints never
surfaced, while the original panic still fired from the same file/line. This means the actual
executed code path isn't rebuilt by a normal `cargo clean`/`cargo test` cycle — almost
certainly because real guest execution runs a separately cross-compiled RISC-V ELF
(`risc0_build::embed_methods!`), which per this repo's own `CLAUDE.md` needs the Docker-based
`make build-programs` pipeline to rebuild, not plain cargo. Instrumentation was cleanly
reverted (`git status` clean in the checkout; all 52 other tests reconfirmed passing
afterward) rather than sunk further into standing up that Docker toolchain just for tracing.
**How the instrumentation was made to actually take effect (2026-07-08 attempt failed, 2026-07-13
attempt succeeded)**: `eprintln!` tracing added directly to the pinned `lee_core` checkout's
`execution_state.rs` first appeared to have no effect — prints never surfaced, and the original
panic kept firing from the same file/line even after `cargo clean -p lee -p lee_core` and a fresh
compile. Root cause: real guest execution runs a separately cross-compiled RISC-V ELF
(`risc0_build::embed_methods!`), and the pinned `PRIVACY_PRESERVING_CIRCUIT_ELF` artifact is a
**pre-built, checked-in binary** (`artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin`
in the checkout) embedded via `build_utils::include_artifacts` — editing the `.rs` source alone
never touches that binary. Fix: rebuild the guest ELF directly with
`cargo risczero build -p privacy_preserving_circuit_program --manifest-path <checkout>/Cargo.toml`
(matching the checkout's own `Justfile` `build-artifacts` recipe) and copy the result over the
checked-in `.bin`**plus** `cargo clean -p lee -p lee_core` again afterward, since
`cargo:rerun-if-changed` was scoped to the artifacts *directory*, and overwriting a file's
content in place doesn't change the directory's own mtime, so cargo's incremental build silently
kept using the old compiled rlib (with the old bytes baked in via `include_bytes!`) even after
the file swap. Once both steps were done, the `eprintln!` output finally appeared and led
directly to the `CLOCK_01_PROGRAM_ACCOUNT_ID` finding above. All instrumentation (source edits,
rebuilt artifact) was fully reverted afterward and verified byte-identical to the original.
**Next step when this is picked back up**: either (a) stand up the guest-rebuild pipeline to
finish the trace, or (b) construct a minimal synthetic instruction (not part of the real AMM
program) that isolates the "post-state passed as next call's pre-state" pattern alone, without
needing to modify any pinned dependency.
**Next step when this is picked back up**: check whether `output_pre_states.len()`/
`output_post_states.len()` already differ from 8/8 (or 6/6, etc.) *before* the per-account
validation loop in `validate_and_sync_states` runs — that would localize the drop to either the
AMM guest/SPEL-macro layer or the circuit's own processing, and is the next concrete step now
that instrumentation is confirmed to work end-to-end.
### Existing
0 private tests out of 33 public. (No private test-writing attempted yet — blocked above.)
6 private tests out of 33 pre-existing public + 6 = 39. No test can yet demonstrate an
actually-working AMM privacy path — five exist purely to confirm the circuit bug also blocks
real private accounts (not just the all-public control case), and one
(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) found a second, distinct,
earlier blocker specific to `RemoveLiquidity`.
**Second finding, unrelated to the circuit bug (2026-07-13)**: `remove_liquidity` requires
`user_holding_a`/`user_holding_b` to already exist and already be owned by the configured Token
Program (`remove.rs`'s `assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`)
— unlike `token::transfer`'s recipient handling, which tolerates `Account::default()` and
self-initializes it. So `RemoveLiquidity` can never pay out to a brand-new private destination
(`PrivateUnauthorized` — only `npk` known, no `nsk`, the pattern
`token_mint_shielded_to_private_unauthorized` uses): the attempt
(`amm_remove_liquidity_private_new_user_holdings_is_not_expressible`) fails inside the AMM
program's own precondition check (`"User Token A holding must be owned by the configured Token
Program"`), *before* any chained call or the privacy-preserving circuit is ever reached — and
would equally reject a brand-new *public* destination. Same shape of finding as Stablecoin's
`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`: a plain
program-level precondition that predates privacy entirely, not a circuit artifact.
### Planned
| Instruction | Dimension | Test | Priority | Depends on | Status |
|---|---|---|---|---|---|
| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above |
| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above |
| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above |
| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | **Blocked** — see above |
| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above |
| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — private `user_holding_a`, fails identically to the all-public control (8 vs 7 accounts) |
| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — identical 8-account/chained-call shape to `SwapExactInput`, fails identically (8 vs 7 accounts) |
| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings_is_not_expressible` — private deposit legs (`user_holding_a`/`user_holding_b`) | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — fails identically (10 vs 9 accounts) |
| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding_is_not_expressible` — private LP output holding | P1 | Token | **Confirmed not-expressible** — private `user_holding_lp`, fails identically (10 vs 9 accounts) |
| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding_is_not_expressible` | P1 | Token, TWAP oracle (public leg) | **Confirmed not-expressible** — private `user_holding_lp`, fails identically (10 vs 9 accounts) |
| RemoveLiquidity | `EXIST` (negative) | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` — brand-new `PrivateUnauthorized` token A/B destinations | P1 | Token | **Confirmed not-expressible for a different reason** — AMM's own precondition requires the destination to already be owned by the Token Program; fails before the circuit bug is even reached |
| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | **Blocked** — see above |
| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | **Blocked** — see above (also issues chained calls reusing `pool`-derived accounts; check on resolution) |
| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started (also behind the blocker above) |
@@ -585,5 +636,5 @@ heavier (chained calls, multiple accounts) than a single shield.
|---|---|---|---|
| Token | 16 (3 pre-existing + 13 new: 12 pass + 1 confirmed not-expressible by design) — phase complete | 0 | 5 |
| ATA | 8 (7 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 |
| AMM | 0 (2 rows now predicted not-expressible pending confirmation) | 10 | 5 |
| AMM | 6 (all confirmed not-expressible: Swap/SwapExactOutput/AddLiquidity (both LP and deposit legs)/RemoveLiquidity blocked by the same circuit bug, plus RemoveLiquidity's separate new-destination precondition; 2 further rows predicted not-expressible pending confirmation via the `PDA` finding) | 5 | 5 |
| Stablecoin | 7 (5 pass + 2 confirmed not-expressible — phase complete) | 0 | 1 |
+728 -1
View File
@@ -3,23 +3,84 @@
reason = "integration fixtures use fixed balances to assert AMM state transitions"
)]
use std::collections::HashMap;
use amm_core::{
PoolDefinition, FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, FEE_TIER_BPS_5,
MINIMUM_LIQUIDITY,
};
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
use integration_tests::{private_authorized_update_identity, private_unauthorized_identity};
use nssa::{
error::LeeError,
execute_and_prove,
privacy_preserving_transaction::circuit::ProgramWithDependencies,
program::Program,
program_deployment_transaction::{self, ProgramDeploymentTransaction},
public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State,
};
use nssa_core::account::{Account, AccountId, Data, Nonce};
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
encryption::ViewingPublicKey,
Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey,
};
use token_core::{TokenDefinition, TokenHolding};
struct Keys;
struct Ids;
struct Balances;
struct Accounts;
struct PrivateKeys;
impl PrivateKeys {
fn user_a_nsk() -> NullifierSecretKey {
[161; 32]
}
fn user_a_npk() -> NullifierPublicKey {
NullifierPublicKey::from(&Self::user_a_nsk())
}
fn user_a_vpk() -> ViewingPublicKey {
ViewingPublicKey::from_seed(&[171; 32], &[172; 32])
}
fn user_a_id() -> AccountId {
AccountId::for_regular_private_account(&Self::user_a_npk(), 0)
}
fn user_lp_nsk() -> NullifierSecretKey {
[162; 32]
}
fn user_lp_npk() -> NullifierPublicKey {
NullifierPublicKey::from(&Self::user_lp_nsk())
}
fn user_lp_vpk() -> ViewingPublicKey {
ViewingPublicKey::from_seed(&[173; 32], &[174; 32])
}
fn user_lp_id() -> AccountId {
AccountId::for_regular_private_account(&Self::user_lp_npk(), 0)
}
fn user_b_nsk() -> NullifierSecretKey {
[163; 32]
}
fn user_b_npk() -> NullifierPublicKey {
NullifierPublicKey::from(&Self::user_b_nsk())
}
fn user_b_vpk() -> ViewingPublicKey {
ViewingPublicKey::from_seed(&[175; 32], &[176; 32])
}
fn user_b_id() -> AccountId {
AccountId::for_regular_private_account(&Self::user_b_npk(), 0)
}
}
impl Keys {
fn user_a() -> PrivateKey {
@@ -3038,3 +3099,669 @@ fn amm_add_liquidity_after_fee_accrual() {
6_437
);
}
fn amm_program_instance() -> Program {
Program::new(amm_methods::AMM_ELF.to_vec().into()).expect("valid amm ELF")
}
fn token_program_instance() -> Program {
Program::new(token_methods::TOKEN_ELF.to_vec().into()).expect("valid token ELF")
}
fn twap_oracle_program_instance() -> Program {
Program::new(twap_oracle_methods::TWAP_ORACLE_ELF.to_vec().into()).expect("valid twap oracle ELF")
}
fn amm_with_deps() -> ProgramWithDependencies {
ProgramWithDependencies::new(
amm_program_instance(),
HashMap::from([
(Ids::token_program(), token_program_instance()),
(Ids::twap_oracle_program(), twap_oracle_program_instance()),
]),
)
}
// Marvin-todo
/// Confirms the AMM circuit-level `"Invalid account_identities length"` bug (bisected with an
/// all-`Public` control case in `docs/privacy-test-matrix.md`'s AMM section) also fires when a
/// real private account is involved in `SwapExactInput`, not just the all-public control shape —
/// i.e. this isn't an artifact of using zero private accounts, the bug blocks a genuine private
/// swap identically.
#[test]
fn amm_swap_a_to_b_private_user_holding_is_not_expressible() {
let mut state = state_for_amm_tests();
let user_a_nsk = PrivateKeys::user_a_nsk();
let user_a_vpk = PrivateKeys::user_a_vpk();
let user_a_id = PrivateKeys::user_a_id();
let user_a_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_a_definition(),
balance: Balances::user_a_init(),
}),
nonce: Nonce::private_account_nonce_init(&user_a_id),
};
state = state.with_private_accounts([(
Commitment::new(&user_a_id, &user_a_account),
Nullifier::for_account_initialization(&user_a_id),
)]);
let membership_proof = state
.get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account))
.expect("user_a's commitment must be in the set");
let config_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config());
let pool_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::pool_definition()),
false,
Ids::pool_definition(),
);
let vault_a_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a());
let vault_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b());
let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id);
let user_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b());
let current_tick_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::current_tick_account()),
false,
Ids::current_tick_account(),
);
let clock_pre = AccountWithMetadata::new(
state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID),
false,
CLOCK_01_PROGRAM_ACCOUNT_ID,
);
let instruction = amm_core::Instruction::SwapExactInput {
swap_amount_in: Balances::swap_amount_in(),
min_amount_out: Balances::swap_min_out(),
token_definition_id_in: Ids::token_a_definition(),
deadline: u64::MAX,
};
let result = execute_and_prove(
vec![
config_pre,
pool_pre,
vault_a_pre,
vault_b_pre,
user_a_pre,
user_b_pre,
current_tick_pre,
clock_pre,
],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
private_authorized_update_identity(user_a_nsk, &user_a_vpk, membership_proof, 0),
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&amm_with_deps(),
);
let err = result.expect_err(
"SwapExactInput must be rejected by the privacy-preserving circuit: the same \
'Invalid account_identities length' bug confirmed with an all-public control case \
also fires with a real private user holding",
);
let message = format!("{err:?}");
assert!(
message.contains("Invalid account_identities length"),
"expected the known circuit-level length-mismatch bug, got a different error: {message}"
);
}
// Marvin-todo
/// Same confirmation as `amm_swap_a_to_b_private_user_holding_is_not_expressible`, for
/// `SwapExactOutput` — identical 8-account/chained-call shape to `SwapExactInput`, so the same
/// circuit-level bug is expected to fire identically.
#[test]
fn amm_swap_exact_output_private_user_holding_is_not_expressible() {
let mut state = state_for_amm_tests();
let user_a_nsk = PrivateKeys::user_a_nsk();
let user_a_vpk = PrivateKeys::user_a_vpk();
let user_a_id = PrivateKeys::user_a_id();
let user_a_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_a_definition(),
balance: Balances::user_a_init(),
}),
nonce: Nonce::private_account_nonce_init(&user_a_id),
};
state = state.with_private_accounts([(
Commitment::new(&user_a_id, &user_a_account),
Nullifier::for_account_initialization(&user_a_id),
)]);
let membership_proof = state
.get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account))
.expect("user_a's commitment must be in the set");
let config_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config());
let pool_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::pool_definition()),
false,
Ids::pool_definition(),
);
let vault_a_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a());
let vault_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b());
let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id);
let user_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b());
let current_tick_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::current_tick_account()),
false,
Ids::current_tick_account(),
);
let clock_pre = AccountWithMetadata::new(
state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID),
false,
CLOCK_01_PROGRAM_ACCOUNT_ID,
);
let instruction = amm_core::Instruction::SwapExactOutput {
exact_amount_out: Balances::swap_min_out(),
max_amount_in: Balances::swap_amount_in(),
token_definition_id_in: Ids::token_a_definition(),
deadline: u64::MAX,
};
let result = execute_and_prove(
vec![
config_pre,
pool_pre,
vault_a_pre,
vault_b_pre,
user_a_pre,
user_b_pre,
current_tick_pre,
clock_pre,
],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
private_authorized_update_identity(user_a_nsk, &user_a_vpk, membership_proof, 0),
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&amm_with_deps(),
);
let err = result.expect_err(
"SwapExactOutput must be rejected by the privacy-preserving circuit: the same \
'Invalid account_identities length' bug also fires with a real private user holding",
);
let message = format!("{err:?}");
assert!(
message.contains("Invalid account_identities length"),
"expected the known circuit-level length-mismatch bug, got a different error: {message}"
);
}
// Marvin-todo
/// Same confirmation as `amm_swap_a_to_b_private_user_holding_is_not_expressible`, for
/// `AddLiquidity` with a private LP-output holding.
#[test]
fn amm_add_liquidity_private_lp_holding_is_not_expressible() {
let mut state = state_for_amm_tests();
let user_lp_nsk = PrivateKeys::user_lp_nsk();
let user_lp_vpk = PrivateKeys::user_lp_vpk();
let user_lp_id = PrivateKeys::user_lp_id();
let user_lp_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_lp_definition(),
balance: 500,
}),
nonce: Nonce::private_account_nonce_init(&user_lp_id),
};
state = state.with_private_accounts([(
Commitment::new(&user_lp_id, &user_lp_account),
Nullifier::for_account_initialization(&user_lp_id),
)]);
let membership_proof = state
.get_proof_for_commitment(&Commitment::new(&user_lp_id, &user_lp_account))
.expect("user_lp's commitment must be in the set");
let config_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config());
let pool_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::pool_definition()),
false,
Ids::pool_definition(),
);
let vault_a_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a());
let vault_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b());
let token_lp_definition_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::token_lp_definition()),
false,
Ids::token_lp_definition(),
);
let user_a_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), true, Ids::user_a());
let user_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), true, Ids::user_b());
let user_lp_pre = AccountWithMetadata::new(user_lp_account, true, user_lp_id);
let current_tick_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::current_tick_account()),
false,
Ids::current_tick_account(),
);
let clock_pre = AccountWithMetadata::new(
state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID),
false,
CLOCK_01_PROGRAM_ACCOUNT_ID,
);
let instruction = amm_core::Instruction::AddLiquidity {
min_amount_liquidity: Balances::add_min_lp(),
max_amount_to_add_token_a: Balances::add_max_a(),
max_amount_to_add_token_b: Balances::add_max_b(),
deadline: u64::MAX,
};
let result = execute_and_prove(
vec![
config_pre,
pool_pre,
vault_a_pre,
vault_b_pre,
token_lp_definition_pre,
user_a_pre,
user_b_pre,
user_lp_pre,
current_tick_pre,
clock_pre,
],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
private_authorized_update_identity(user_lp_nsk, &user_lp_vpk, membership_proof, 0),
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&amm_with_deps(),
);
let err = result.expect_err(
"AddLiquidity must be rejected by the privacy-preserving circuit: the same \
'Invalid account_identities length' bug also fires with a real private LP holding",
);
let message = format!("{err:?}");
assert!(
message.contains("Invalid account_identities length"),
"expected the known circuit-level length-mismatch bug, got a different error: {message}"
);
}
// Marvin-todo
/// Same confirmation as the two tests above, for `RemoveLiquidity` with a private LP holding
/// (the account that signs/burns to remove liquidity).
#[test]
fn amm_remove_liquidity_private_lp_holding_is_not_expressible() {
let mut state = state_for_amm_tests();
let user_lp_nsk = PrivateKeys::user_lp_nsk();
let user_lp_vpk = PrivateKeys::user_lp_vpk();
let user_lp_id = PrivateKeys::user_lp_id();
let user_lp_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_lp_definition(),
balance: Balances::remove_lp(),
}),
nonce: Nonce::private_account_nonce_init(&user_lp_id),
};
state = state.with_private_accounts([(
Commitment::new(&user_lp_id, &user_lp_account),
Nullifier::for_account_initialization(&user_lp_id),
)]);
let membership_proof = state
.get_proof_for_commitment(&Commitment::new(&user_lp_id, &user_lp_account))
.expect("user_lp's commitment must be in the set");
let config_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config());
let pool_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::pool_definition()),
false,
Ids::pool_definition(),
);
let vault_a_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a());
let vault_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b());
let token_lp_definition_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::token_lp_definition()),
false,
Ids::token_lp_definition(),
);
let user_a_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::user_a()), false, Ids::user_a());
let user_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::user_b()), false, Ids::user_b());
let user_lp_pre = AccountWithMetadata::new(user_lp_account, true, user_lp_id);
let current_tick_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::current_tick_account()),
false,
Ids::current_tick_account(),
);
let clock_pre = AccountWithMetadata::new(
state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID),
false,
CLOCK_01_PROGRAM_ACCOUNT_ID,
);
let instruction = amm_core::Instruction::RemoveLiquidity {
remove_liquidity_amount: Balances::remove_lp(),
min_amount_to_remove_token_a: Balances::remove_min_a(),
min_amount_to_remove_token_b: Balances::remove_min_b(),
deadline: u64::MAX,
};
let result = execute_and_prove(
vec![
config_pre,
pool_pre,
vault_a_pre,
vault_b_pre,
token_lp_definition_pre,
user_a_pre,
user_b_pre,
user_lp_pre,
current_tick_pre,
clock_pre,
],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
private_authorized_update_identity(user_lp_nsk, &user_lp_vpk, membership_proof, 0),
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&amm_with_deps(),
);
let err = result.expect_err(
"RemoveLiquidity must be rejected by the privacy-preserving circuit: the same \
'Invalid account_identities length' bug also fires with a real private LP holding",
);
let message = format!("{err:?}");
assert!(
message.contains("Invalid account_identities length"),
"expected the known circuit-level length-mismatch bug, got a different error: {message}"
);
}
// Marvin-todo
/// A distinct, earlier finding from `amm_remove_liquidity_private_lp_holding_is_not_expressible`:
/// `remove_liquidity` requires `user_holding_a`/`user_holding_b` to already exist and already be
/// owned by the configured Token Program (`remove.rs`'s
/// `assert_eq!(user_holding_a.account.program_owner, token_program_id, ...)`), unlike
/// `token::transfer`'s recipient handling (which tolerates `Account::default()` and
/// self-initializes it). So `RemoveLiquidity` can never pay out to a brand-new private
/// destination (`PrivateUnauthorized` — only `npk`/identifier known, no `nsk`, matching how
/// `token_mint_shielded_to_private_unauthorized` credits a fresh private account it doesn't
/// control) — this fails inside the AMM guest's own precondition check, before any chained call
/// or the privacy-preserving circuit is ever reached, and would equally reject a brand-new
/// *public* destination. Same shape of finding as Stablecoin's
/// `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`.
#[test]
fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() {
let state = state_for_amm_tests();
let user_a_npk = PrivateKeys::user_a_npk();
let user_a_vpk = PrivateKeys::user_a_vpk();
let user_a_id = PrivateKeys::user_a_id();
let user_b_npk = PrivateKeys::user_b_npk();
let user_b_vpk = PrivateKeys::user_b_vpk();
let user_b_id = PrivateKeys::user_b_id();
let config_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config());
let pool_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::pool_definition()),
false,
Ids::pool_definition(),
);
let vault_a_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a());
let vault_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b());
let token_lp_definition_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::token_lp_definition()),
false,
Ids::token_lp_definition(),
);
let user_a_pre = AccountWithMetadata::new(Account::default(), false, user_a_id);
let user_b_pre = AccountWithMetadata::new(Account::default(), false, user_b_id);
let user_lp_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::user_lp()), true, Ids::user_lp());
let current_tick_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::current_tick_account()),
false,
Ids::current_tick_account(),
);
let clock_pre = AccountWithMetadata::new(
state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID),
false,
CLOCK_01_PROGRAM_ACCOUNT_ID,
);
let instruction = amm_core::Instruction::RemoveLiquidity {
remove_liquidity_amount: Balances::remove_lp(),
min_amount_to_remove_token_a: Balances::remove_min_a(),
min_amount_to_remove_token_b: Balances::remove_min_b(),
deadline: u64::MAX,
};
let result = execute_and_prove(
vec![
config_pre,
pool_pre,
vault_a_pre,
vault_b_pre,
token_lp_definition_pre,
user_a_pre,
user_b_pre,
user_lp_pre,
current_tick_pre,
clock_pre,
],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
private_unauthorized_identity(user_a_npk, &user_a_vpk, 0),
private_unauthorized_identity(user_b_npk, &user_b_vpk, 1),
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&amm_with_deps(),
);
let err = result.expect_err(
"RemoveLiquidity must be rejected by the AMM program itself: user_holding_a/b must \
already be initialized and owned by the configured Token Program before any chained \
call or the privacy-preserving circuit is ever reached",
);
let message = format!("{err:?}");
assert!(
message.contains("User Token A holding must be owned by the configured Token Program"),
"expected the AMM program's own initialized-destination precondition, got a different \
error: {message}"
);
}
// Marvin-todo
/// Same confirmation as `amm_add_liquidity_private_lp_holding_is_not_expressible`, but for the
/// deposit side instead of the LP-mint side: `user_holding_a`/`user_holding_b` (the accounts
/// debited to fund the deposit) are existing private holdings (`PrivateAuthorizedUpdate` — `nsk`
/// known, matching how `swap`'s deposit leg is tested), while `user_holding_lp` (the mint
/// destination) stays public, as in the base `amm_add_liquidity` test. Expected to hit the same
/// circuit-level `"Invalid account_identities length"` bug regardless of which accounts are
/// private.
#[test]
fn amm_add_liquidity_private_user_holdings_is_not_expressible() {
let mut state = state_for_amm_tests();
let user_a_nsk = PrivateKeys::user_a_nsk();
let user_a_vpk = PrivateKeys::user_a_vpk();
let user_a_id = PrivateKeys::user_a_id();
let user_a_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_a_definition(),
balance: Balances::user_a_init(),
}),
nonce: Nonce::private_account_nonce_init(&user_a_id),
};
let user_b_nsk = PrivateKeys::user_b_nsk();
let user_b_vpk = PrivateKeys::user_b_vpk();
let user_b_id = PrivateKeys::user_b_id();
let user_b_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_b_definition(),
balance: Balances::user_b_init(),
}),
nonce: Nonce::private_account_nonce_init(&user_b_id),
};
state = state.with_private_accounts([
(
Commitment::new(&user_a_id, &user_a_account),
Nullifier::for_account_initialization(&user_a_id),
),
(
Commitment::new(&user_b_id, &user_b_account),
Nullifier::for_account_initialization(&user_b_id),
),
]);
let user_a_membership_proof = state
.get_proof_for_commitment(&Commitment::new(&user_a_id, &user_a_account))
.expect("user_a's commitment must be in the set");
let user_b_membership_proof = state
.get_proof_for_commitment(&Commitment::new(&user_b_id, &user_b_account))
.expect("user_b's commitment must be in the set");
let config_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::config()), false, Ids::config());
let pool_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::pool_definition()),
false,
Ids::pool_definition(),
);
let vault_a_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_a()), false, Ids::vault_a());
let vault_b_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::vault_b()), false, Ids::vault_b());
let token_lp_definition_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::token_lp_definition()),
false,
Ids::token_lp_definition(),
);
let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id);
let user_b_pre = AccountWithMetadata::new(user_b_account, true, user_b_id);
let user_lp_pre =
AccountWithMetadata::new(state.get_account_by_id(Ids::user_lp()), false, Ids::user_lp());
let current_tick_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::current_tick_account()),
false,
Ids::current_tick_account(),
);
let clock_pre = AccountWithMetadata::new(
state.get_account_by_id(CLOCK_01_PROGRAM_ACCOUNT_ID),
false,
CLOCK_01_PROGRAM_ACCOUNT_ID,
);
let instruction = amm_core::Instruction::AddLiquidity {
min_amount_liquidity: Balances::add_min_lp(),
max_amount_to_add_token_a: Balances::add_max_a(),
max_amount_to_add_token_b: Balances::add_max_b(),
deadline: u64::MAX,
};
let result = execute_and_prove(
vec![
config_pre,
pool_pre,
vault_a_pre,
vault_b_pre,
token_lp_definition_pre,
user_a_pre,
user_b_pre,
user_lp_pre,
current_tick_pre,
clock_pre,
],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
private_authorized_update_identity(user_a_nsk, &user_a_vpk, user_a_membership_proof, 0),
private_authorized_update_identity(user_b_nsk, &user_b_vpk, user_b_membership_proof, 1),
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&amm_with_deps(),
);
let err = result.expect_err(
"AddLiquidity must be rejected by the privacy-preserving circuit: the same \
'Invalid account_identities length' bug also fires with real private deposit holdings",
);
let message = format!("{err:?}");
assert!(
message.contains("Invalid account_identities length"),
"expected the known circuit-level length-mismatch bug, got a different error: {message}"
);
}
+43 -49
View File
@@ -678,17 +678,21 @@ fn token_shield_into_private_pda_via_external_seed() {
/// `Ids::holder()` to a new private account keyed by `PrivateKeys::recipient_*`.
/// Returns the resulting private recipient account.
#[cfg(test)]
fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account {
fn shielded_token_transfer(
amount: u128,
state: &mut V03State,
recipient_is_authorized: bool,
recipient_identity: InputAccountIdentity,
) -> Account {
let sender_id = Ids::holder();
let sender_account = state.get_account_by_id(sender_id);
let sender_nonce = sender_account.nonce;
let recipient_npk = PrivateKeys::recipient_npk();
let recipient_vpk = PrivateKeys::recipient_vpk();
let recipient_id = PrivateKeys::recipient_id();
let sender = AccountWithMetadata::new(sender_account, true, sender_id);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id);
let recipient =
AccountWithMetadata::new(Account::default(), recipient_is_authorized, recipient_id);
let instruction = token_core::Instruction::Transfer {
amount_to_transfer: amount,
@@ -696,10 +700,7 @@ fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account {
let (output, proof) = execute_and_prove(
vec![sender, recipient],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
private_unauthorized_identity(recipient_npk, &recipient_vpk, 0),
],
vec![InputAccountIdentity::Public, recipient_identity],
&token_program().into(),
)
.unwrap();
@@ -721,7 +722,14 @@ fn token_shielded_transfer() {
let mut state = state_for_token_tests();
let amount = 500_000_u128;
let recipient_account = shielded_token_transfer(amount, &mut state);
let recipient_npk = PrivateKeys::recipient_npk();
let recipient_vpk = PrivateKeys::recipient_vpk();
let recipient_account = shielded_token_transfer(
amount,
&mut state,
false,
private_unauthorized_identity(recipient_npk, &recipient_vpk, 0),
);
assert_eq!(
state.get_account_by_id(Ids::holder()),
@@ -741,49 +749,25 @@ fn token_shielded_transfer_authorized_private_init() {
let mut state = state_for_token_tests();
let amount = 500_000_u128;
let sender_id = Ids::holder();
let sender_account = state.get_account_by_id(sender_id);
let sender_nonce = sender_account.nonce;
let recipient_nsk = PrivateKeys::recipient_nsk();
let recipient_vpk = PrivateKeys::recipient_vpk();
let recipient_id = PrivateKeys::recipient_id();
let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id);
let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_id);
let instruction = token_core::Instruction::Transfer {
amount_to_transfer: amount,
};
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient_pre],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
private_authorized_init_identity(recipient_nsk, &recipient_vpk, 0),
],
&token_program().into(),
)
.unwrap();
let message =
Message::try_from_circuit_output(vec![sender_id], vec![sender_nonce], output).unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::holder_key()]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
state
.transition_from_privacy_preserving_transaction(&tx, 0, 0)
.unwrap();
let recipient_account = shielded_token_transfer(
amount,
&mut state,
true,
private_authorized_init_identity(
PrivateKeys::recipient_nsk(),
&PrivateKeys::recipient_vpk(),
0,
),
);
assert_eq!(
state.get_account_by_id(sender_id),
state.get_account_by_id(Ids::holder()),
Accounts::token_holding(1_000_000 - amount, Nonce(1))
);
let recipient_account =
Accounts::token_holding(amount, Nonce::private_account_nonce_init(&recipient_id));
let recipient_commitment = Commitment::new(&PrivateKeys::recipient_id(), &recipient_account);
assert!(state
.get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account))
.get_proof_for_commitment(&recipient_commitment)
.is_some());
}
@@ -794,7 +778,12 @@ fn token_private_transfer() {
let transfer_amount = 200_000_u128;
// Shield tokens into a private account (becomes the sender for the private transfer).
let sender_account = shielded_token_transfer(shielded_amount, &mut state);
let sender_account = shielded_token_transfer(
shielded_amount,
&mut state,
false,
private_unauthorized_identity(PrivateKeys::recipient_npk(), &PrivateKeys::recipient_vpk(), 0),
);
let sender_nsk = PrivateKeys::recipient_nsk();
let sender_vpk = PrivateKeys::recipient_vpk();
let sender_id = PrivateKeys::recipient_id();
@@ -859,7 +848,12 @@ fn token_deshielded_transfer() {
let deshield_amount = 300_000_u128;
// Shield tokens into a private account, then deshield some back to a public account.
let sender_account = shielded_token_transfer(shielded_amount, &mut state);
let sender_account = shielded_token_transfer(
shielded_amount,
&mut state,
false,
private_unauthorized_identity(PrivateKeys::recipient_npk(), &PrivateKeys::recipient_vpk(), 0),
);
let sender_nsk = PrivateKeys::recipient_nsk();
let sender_vpk = PrivateKeys::recipient_vpk();
let sender_id = PrivateKeys::recipient_id();
@@ -917,7 +911,7 @@ fn token_deshielded_transfer() {
/// Mints directly to a new recipient private holding (`PrivateUnauthorized`).
/// The recipient's cooperation is unnecessary; only known of the recipient's `npk`, `vpk`.
#[test]
fn token_mint_shielded_to_private_unauthorized() {
fn token_mint_private_unauthorized() {
let mut state = state_for_token_tests_without_recipient();
let amount_to_mint = 500_000_u128;