mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-26 14:41:12 +00:00
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:
+178
-83
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user