mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 14:11:09 +00:00
test(privacy): add findings doc, stablecoin negative-destination test, refine ATA/token coverage
Adds ata_group_owned_owner_signing (Burn's missing GROUP variant) and two NewFungibleDefinition private-initial-holder tests to Token. Removes two experimental PDA-external-seed Token tests that had no real coverage purpose. Documents the root cause of the AMM privacy-test blocker (a spel-framework guest-wrapper filter silently drops the clock account before either transaction validator sees it) with full findings/matrix writeups, and reconciles table gaps found while cross-checking each program's privacy tests against docs/findings.md.
This commit is contained in:
+48
-163
@@ -34,7 +34,7 @@ Private account updates require knowledge of the account's `nsk`. E.g., Alice ca
|
||||
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)
|
||||
- `PrivateUnauthorized` initialization is used for account initialization. `is_authorized = false` is a protection that does not seem crucial. Artifically, blocks some functions.
|
||||
|
||||
## Private PDA
|
||||
|
||||
@@ -60,77 +60,34 @@ distributed via a real seal/unseal handshake (ML-KEM-768), not key reuse:
|
||||
|
||||
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 coverage for LEZ programs objectives (TODO)
|
||||
# Privacy coverage for LEZ programs objectives
|
||||
|
||||
In this task, we plan to add tests for e
|
||||
In this task, we extend testing for LEZ programs to cover privacy features:
|
||||
|
||||
| | description | |
|
||||
|---------|----|----|
|
||||
| PDA |
|
||||
| REGULAR |
|
||||
| EXIST |
|
||||
| GROUP |
|
||||
| CHAIN |
|
||||
| | description |
|
||||
|---------|----|
|
||||
| PDA | test checks for private PDA functionality. |
|
||||
| REGULAR | private accounts usage using `nsk` |
|
||||
| EXIST | private account initialized without `nsk`; `PrivateUnauthorized` |
|
||||
| GROUP | Shared group account |
|
||||
| CHAIN | private account used in a chain call |
|
||||
|
||||
- Regular private accounts
|
||||
- `PrivateUnauthorized` accounts; e.g., "transfer to existing accounts".
|
||||
- Group shared private accounts
|
||||
- Private PDAs.
|
||||
# LEZ programs
|
||||
|
||||
# LEZ programs (TODO)
|
||||
## AMM program (unusual issues)
|
||||
|
||||
## AMM program
|
||||
| Function tested | Test name | Category | Description of objective | Result |
|
||||
|---|---|---|---|---|
|
||||
| SwapExactInput | `amm_swap_a_to_b_private_user_holding_is_not_expressible` | REGULAR, CHAIN | Private `user_holding_a` deposit leg — confirms the circuit-level account-count bug also fires with a real private account (8 vs 7 accounts), not just the all-public control case | ❌ (confirmed not-expressible — circuit bug) |
|
||||
| SwapExactOutput | `amm_swap_exact_output_private_user_holding_is_not_expressible` | REGULAR, CHAIN | Same confirmation for `SwapExactOutput` — identical account/chained-call shape to `SwapExactInput` (8 vs 7 accounts) | ❌ (confirmed not-expressible — circuit bug) |
|
||||
| AddLiquidity | `amm_add_liquidity_private_lp_holding_is_not_expressible` | REGULAR, CHAIN | Private LP-output holding (`user_holding_lp`) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) |
|
||||
| AddLiquidity | `amm_add_liquidity_private_user_holdings_is_not_expressible` | REGULAR, CHAIN | Private deposit legs (`user_holding_a` + `user_holding_b`) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) |
|
||||
| RemoveLiquidity | `amm_remove_liquidity_private_lp_holding_is_not_expressible` | REGULAR, CHAIN | Private LP holding (the account that signs/burns to remove liquidity) — same circuit bug (10 vs 9 accounts) | ❌ (confirmed not-expressible — circuit bug) |
|
||||
| RemoveLiquidity | `amm_remove_liquidity_private_new_user_holdings_is_not_expressible` | EXIST, CHAIN | Brand-new `PrivateUnauthorized` token A/B destinations — rejected by a separate, unrelated program-level precondition (destination must already exist) before the circuit bug is even reached | ❌ (confirmed not-expressible — different reason) |
|
||||
|
||||
**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.
|
||||
### Remarks
|
||||
- `RemoveLiquidity` and `Swap`s may have issues with `PrivateUnauthorized` and `PrivateAuthorizedInit` that match issues detected in Stablecoin; e.g., explicitly requires `is_authorized = true` and non default accounts.
|
||||
- `clock` account issue: clock is silent dropped during privacy executions.
|
||||
|
||||
## ATA program
|
||||
|
||||
@@ -138,93 +95,33 @@ 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_from_private_owner` | REGULAR, EXIST | 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 | ✅ |
|
||||
| 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. | ✅ (defensive/symmetry coverage only) |
|
||||
| Transfer | `ata_transfer_to_existing_private_recipient` | REGULAR | 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 | ✅ |
|
||||
| Transfer | `ata_transfer_with_private_owner_signing` | REGULAR | Key discovery: unlike `Create` (merely `mut`), `Transfer` requires `owner` to be a *signer* (`#[account(signer)]`) — a private owner self-initializes and signs in the same transaction via `PrivateAuthorizedInit` | ✅ |
|
||||
| Transfer | `ata_transfer_with_group_owned_owner_signing` | GROUP | Group-owned owner (real GMS seal/unseal handshake) signs `ATA::Transfer` as the required authorizing party | ✅ |
|
||||
| Burn | `ata_burn_with_private_owner_signing` | REGULAR | Same signer-authorization discovery as `ata_transfer_with_private_owner_signing`, for `Burn` | ✅ |
|
||||
| Burn | `ata_group_owned_owner_signing` | GROUP | Group-owned owner signs `ATA::Burn` as the required authorizing party | ✅ |
|
||||
|
||||
**`PDA`** is confirmed not-expressible for every ATA instruction, not just `Create` — `Transfer`
|
||||
and `Burn` call the same `ata_core::verify_ata_and_get_seed` function, so the identical
|
||||
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 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.
|
||||
### Remarks
|
||||
- Transfer explicitly blocks `PrivateAuthorized`. ATA's transfer checks that the recipient's account is non-default. E.g., ATA can not transfer funds to a third-party's private account.
|
||||
- ATA does not permit the creation of private token accounts. E.g., ATA only emits public PDA accounts. This is based on the PDA `AccountId` formulas used.
|
||||
|
||||
## Stablecoin program
|
||||
|
||||
| Function tested | Test name | Category | Description of objective | Result |
|
||||
|---|---|---|---|---|
|
||||
| WithdrawCollateral | `stablecoin_withdraw_collateral_private_destination` | CHAIN, EXIST | Withdraws collateral through the single `Token::Transfer` chained call into an already-existing private destination holding | ✅ |
|
||||
| WithdrawCollateral | `stablecoin_withdraw_collateral_group_owned_destination` | CHAIN, EXIST, GROUP | Same, but the destination holding is group-owned (real GMS seal/unseal handshake) | ✅ |
|
||||
| WithdrawCollateral | `stablecoin_withdraw_collateral_private_destination` | REGULAR | Withdraws collateral through the single `Token::Transfer` chained call into an already-existing private destination holding | ✅ |
|
||||
| WithdrawCollateral | `stablecoin_withdraw_collateral_group_owned_destination` | EXIST, GROUP | Same, but the destination holding is group-owned (real GMS seal/unseal handshake) | ✅ |
|
||||
| WithdrawCollateral | `stablecoin_group_owned_position_owner` | GROUP | The position's `owner` identity itself (not the destination) is group-derived — proves shared authority over a CDP by withdrawing collateral through it | ✅ |
|
||||
| RepayDebt | `stablecoin_repay_debt_private_stablecoin_holding` | CHAIN | Burns from a private stablecoin holding through the single `Token::Burn` chained call | ✅ |
|
||||
| RepayDebt | `stablecoin_repay_debt_group_owned_stablecoin_holding` | CHAIN, GROUP | Same, group-owned holding | ✅ |
|
||||
| RepayDebt | `stablecoin_repay_debt_private_stablecoin_holding` | REGULAR | Burns from a private stablecoin holding through the single `Token::Burn` chained call | ✅ |
|
||||
| RepayDebt | `stablecoin_repay_debt_group_owned_stablecoin_holding` | GROUP | Same, group-owned holding | ✅ |
|
||||
|
||||
**`PDA`** has no rows, and can't even be isolated as its own question for this program: position
|
||||
and vault are only ever PDA-claimed *inside* `OpenPosition`, and — see below — that instruction
|
||||
can't reach the privacy circuit at all. The `PDA` question is subsumed by that finding rather
|
||||
than independently testable; the ATA `PDA` finding (same `for_public_pda`-only root cause,
|
||||
confirmed in `stablecoin_core`) stands as the citable reference.
|
||||
|
||||
One test sits outside this table's four categories but is the headline finding for the whole
|
||||
program, worth stating plainly rather than omitting silently:
|
||||
**`stablecoin_open_position_via_privacy_transaction_is_not_expressible`** — `OpenPosition`
|
||||
cannot be executed through a privacy-preserving transaction *at all*, for any reason connected
|
||||
to privacy. Confirmed with an all-public control case (every account `InputAccountIdentity::Public`,
|
||||
zero private accounts) that fails identically, proving it's a protocol incompatibility in the
|
||||
`PrivacyPreservingTransaction` code path itself, not a privacy bug — `owner`'s identity type is
|
||||
irrelevant. Every test above routes around it by seeding position/vault directly rather than
|
||||
calling `OpenPosition` for real.
|
||||
|
||||
**Root cause, precisely traced:** `open_position.rs` returns two *sibling* chained calls in one
|
||||
shot (`vec![initialize_call, transfer_call]` — both discovered at once from a single execution of
|
||||
`open_position`, neither nested inside the other) that both touch `vault`: `InitializeAccount`
|
||||
declares it `is_authorized: true` (claimed via its PDA seed), `Transfer` then declares the *same*
|
||||
account_id `is_authorized: false` (a hand-predicted post-`InitializeAccount` state, not a value
|
||||
threaded through by the framework — the program author is predicting what call 1 will produce,
|
||||
not observing it). This reuse of one account across two sibling calls with differing declared
|
||||
authorization is the *only* thing that matters here — contrast with AMM's `remove_liquidity`,
|
||||
which also returns multiple sibling chained calls at once (4: token A/B withdraw, LP burn, TWAP
|
||||
tick update) but never reuses one account across two of them, so it never exercises this code
|
||||
path at all.
|
||||
|
||||
Both transaction-type validators re-derive `is_authorized` per occurrence and assert it matches
|
||||
the declared value — but they scope that derivation differently. `validated_state_diff.rs` (the
|
||||
plain `PublicTransaction` validator) computes a fresh `authorized_accounts` set once per parent
|
||||
call and clones it independently for each sibling *before* any sibling runs — so `Transfer`'s
|
||||
view of `vault` never sees `InitializeAccount`'s PDA-based authorization, re-derives `false`,
|
||||
matches. This is why the pre-existing public `stablecoin_open_position_then_withdraw_collateral`
|
||||
test works. `execution_state.rs` (the `PrivacyPreservingTransaction`/circuit validator) instead
|
||||
keeps one mutable `authorized_accounts: HashSet<AccountId>` on `self`, threaded with no
|
||||
per-branch scoping through the entire flat call queue — `InitializeAccount` processing inserts
|
||||
`vault` into it, and when `Transfer` is processed next, `resolve_authorization_and_record_bindings`
|
||||
short-circuits via `if authorized_accounts.contains(&pre_account_id) { return true; }`, re-deriving
|
||||
`true` — which conflicts with the declared `false` and fails
|
||||
`assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")`.
|
||||
|
||||
**This means `OpenPosition` is fixable two ways**: either scope `execution_state.rs`'s
|
||||
`authorized_accounts` per sibling branch to match `validated_state_diff.rs`'s behavior (a circuit
|
||||
fix, benefits every program with this pattern), or change `open_position.rs` to not re-declare
|
||||
`vault` unauthorized on its second occurrence (a one-line fix local to this program, routing
|
||||
around the bug rather than fixing it).
|
||||
|
||||
A second, unrelated negative result:
|
||||
**`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`** —
|
||||
`WithdrawCollateral` cannot pay out to a brand-new private destination (`PrivateUnauthorized`,
|
||||
only `npk` known, no `nsk`). `withdraw_collateral.rs` hard-asserts
|
||||
`destination.account != Account::default()` before the chained `Token::Transfer` is even
|
||||
constructed, so the destination must already exist — this is a plain program precondition, not a
|
||||
privacy-circuit artifact, and would equally reject a withdraw to a brand-new *public*
|
||||
destination. It's why every `WithdrawCollateral` test above uses `PrivateAuthorizedUpdate`
|
||||
(`nsk` known) rather than `PrivateUnauthorized` for the destination.
|
||||
### Remarks
|
||||
- `OpenPosition` is blocked for use in privacy transactions due to the chained calls usage. `OpenPosition` calls `Token::InitializeAccount` and `Token::Transfer` for the same vault account which is disallowed behavior in privacy preserving circuit. Demonstrated with test `stablecoin_open_position_via_privacy_transaction_is_not_expressible`.
|
||||
- `WithdrawCollateral` does not support withdrawals to `PrivateAuthorized` and `PrivateAuthorizedInit`; explicitly checks that the destination account is not default. Demonstrated with teh test `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`.
|
||||
- Vault is explicitly public PDA by formula requirement.
|
||||
|
||||
## Token program
|
||||
|
||||
@@ -243,37 +140,25 @@ destination. It's why every `WithdrawCollateral` test above uses `PrivateAuthori
|
||||
| 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_initialize_private_account_without_nsk_is_not_expressible` | EXIST | `InitializeAccount`'s target is `#[account(init, signer)]` — a third party cannot initialize a private holding via `PrivateUnauthorized` (no `nsk`); rejected by the SPEL macro ("must be a signer") before the program's own logic runs | ❌ (confirmed not-expressible by design) |
|
||||
| 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
|
||||
holding is wrapped by another program's PDA (ATA/AMM/Stablecoin).
|
||||
|
||||
**`CHAIN`**'s "carried through chained calls" half also has no Token-layer rows: Token issues no
|
||||
`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 |
|
||||
|
||||
|
||||
|
||||
| NewFungibleDefinition | `token_new_fungible_definition_private_initial_holder` | REGULAR | Public token definition, private initial holder that self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) — same self-service shape as `InitializeAccount`'s target | ✅ |
|
||||
| NewFungibleDefinition | `token_new_fungible_definition_private_holder_without_nsk_is_not_expressible` | EXIST | The initial holder cannot be created via `PrivateUnauthorized` — rejected by the SPEL macro before the program's own logic runs | ❌ (confirmed not-expressible by design) |
|
||||
|
||||
|
||||
### Remarks
|
||||
- `Initialization` is not possible for `PrivateUnauthorized` accounts due to `is_authorized = false`.
|
||||
- New token definition is not permitted for `PrivateAuthorized` as Token holding due to `is_authorized = false`.E.g., both Token Definition and Token Holding for a new Token must be from an authorized account.
|
||||
|
||||
# Conclusions
|
||||
|
||||
## Group shared private accounts
|
||||
- Group shared accounts are authorized
|
||||
Privacy coverage for LEZ program tests is greatly improved from the added tests. Though, there are a few noticable gaps:
|
||||
- `PrivateUnauthorized` accounts can be blocked by programs with a check `is_authorized = true`. However, th
|
||||
|
||||
|
||||
# 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`)
|
||||
|
||||
# TODO
|
||||
|
||||
|
||||
@@ -418,12 +418,11 @@ computes). Confirmed with every account `Public`.
|
||||
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.
|
||||
explicitly passes `clock.clone()`. Root cause of *why* it's dropped was not yet found at this
|
||||
point — **since resolved, see "Root cause found" below**: it's a `spel-framework` guest-wrapper
|
||||
filter, not the circuit's own processing. 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`
|
||||
@@ -459,6 +458,90 @@ validation loop in `validate_and_sync_states` runs — that would localize the d
|
||||
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.
|
||||
|
||||
### ✅ Root cause found (2026-07-14) — it's in `spel-framework`, not `lee_core`, and not AMM's own code
|
||||
|
||||
Investigated (via a Fable 5 subagent, source-reading only — no instrumentation needed this time)
|
||||
by comparing the two transaction validators side by side and checking the guest-wrapper code
|
||||
that sits between AMM's own functions and either validator. Fully verified by direct inspection
|
||||
afterward (both citations below reproduced and confirmed independently).
|
||||
|
||||
**The account is deleted before it ever reaches either validator.** The `#[lez_program]` macro's
|
||||
generated `main()` — `spel-framework-macros/src/lib.rs:303-329`, in the pinned
|
||||
`spel-393b37c2cff64018` checkout at rev `91023c9115bf88173b0d25d2e905f2a55ef0313b` — post-processes
|
||||
every guest function's returned `(pre_states, post_states)` pairs before writing the
|
||||
`ProgramOutput`:
|
||||
|
||||
```rust
|
||||
// Filter out non-program-owned, non-default-state accounts from the output.
|
||||
//
|
||||
// LEZ validate_execution rule 7: if post.program_owner == DEFAULT_PROGRAM_ID
|
||||
// and pre.account != Account::default(), validation fails. This would happen
|
||||
// for signer accounts (e.g., proposer/executor) whose nonce has been incremented
|
||||
// by a prior transaction — they are not owned by the program and must not be
|
||||
// returned in the program's post-states.
|
||||
.filter(|(pre, post)| {
|
||||
let is_default_owner = pre.account.program_owner == DEFAULT_PROGRAM_ID;
|
||||
let pre_is_default = pre.account == Account::default();
|
||||
let has_claim = post.required_claim().is_some();
|
||||
!is_default_owner || pre_is_default || has_claim
|
||||
})
|
||||
```
|
||||
|
||||
This was written to solve a real, narrow problem: drop *signer* accounts (proposer/executor)
|
||||
whose nonce got bumped by a prior transaction, since they're not owned by the program and
|
||||
`validate_execution`'s rule 7 would otherwise reject the output. But the predicate is broader
|
||||
than that one case, and `clock` happens to satisfy it too:
|
||||
|
||||
- `is_default_owner = true` — the clock account is seeded via `force_insert_account` with
|
||||
`Account { data: <real clock bytes>, ..Account::default() }` (`advance_clock` in `amm.rs`),
|
||||
so its `program_owner` stays `DEFAULT_PROGRAM_ID` — it's never claimed by any program.
|
||||
- `pre_is_default = false` — its `data` field holds real, non-default clock bytes.
|
||||
- `has_claim = false` — AMM never issues a `Claim` for clock; it only reads it.
|
||||
|
||||
`!true || false || false` = `false` → the `(pre, post)` pair for `clock` is silently dropped from
|
||||
`ProgramOutput.pre_states`/`post_states`, every single time, for every AMM instruction that
|
||||
touches it — and for TWAP's `UpdateCurrentTick` too, since it's built with the exact same macro
|
||||
at the exact same pin. This is exactly why the earlier `eprintln!` trace never saw `clock` at
|
||||
*any* call depth, including inside the nested TWAP call: it was gone before the circuit ever got
|
||||
the chance to see it, not dropped by the circuit itself.
|
||||
|
||||
**Why the public-transaction path never noticed**: `ValidatedStateDiff::from_public_transaction`
|
||||
(`lee/state_machine/src/validated_state_diff.rs`) only ever iterates whatever the program's
|
||||
*output* actually contains (`program_output.pre_states`) and zips it against
|
||||
`program_output.post_states` to build the state diff. There is no check anywhere that the
|
||||
output covers every account the *caller* originally supplied — a silently-dropped, unmodified
|
||||
account just never appears in the diff, and nothing asserts it should have. `validate_execution`
|
||||
(the rule 7 the filter comment refers to) only checks `pre_states.len() == post_states.len()`
|
||||
*within* the already-filtered output (7 == 7 — passes trivially, since both sides of the pair
|
||||
were dropped together).
|
||||
|
||||
**Why the privacy-preserving path panics**: the circuit builds its own account-tracking state as
|
||||
the union of every `ProgramOutput.pre_states` it sees across the whole call tree — 7 accounts,
|
||||
no clock. But the *caller* (the test, or in production a real wallet/client) must supply one
|
||||
`InputAccountIdentity` per account it believes is involved — 8, including clock, since nothing
|
||||
told the caller clock would be dropped. `compute_circuit_output`'s
|
||||
`assert_eq!(account_identities.len(), states_iter.len())` (`output.rs:27`) then fails: `8 != 7`.
|
||||
The public path tolerates exactly this same silent drop; only the private path's stricter
|
||||
1:1 correspondence check turns it into a hard failure.
|
||||
|
||||
**This is a `spel-framework` bug, not a `lez_core`/circuit bug, and not an AMM program bug.**
|
||||
Neither this repo's own code nor the pinned LEZ dependency is at fault — the defect is in the
|
||||
`0x-r4bbit/spel` proc-macro crate's generated wrapper, one layer removed from both. Fix options
|
||||
belong upstream: scope the filter to only the specific signer-nonce-bump case it was written for
|
||||
(e.g. keep any pair the handler's own logic explicitly returned, rather than blanket-filtering
|
||||
by ownership), or have the circuit tolerate identities without a corresponding output pre-state.
|
||||
The trigger condition is narrow but real: any account with `program_owner == DEFAULT_PROGRAM_ID`
|
||||
that a program reads but never claims will hit this — not just clock, and not just AMM. It just
|
||||
happens to be clock here because every pool-mutating AMM instruction reads it.
|
||||
|
||||
**Soundness implication, not just a test-writing inconvenience**: because `clock` never reaches
|
||||
`public_pre_states` on the privacy-preserving path, the host validator
|
||||
(`check_privacy_preserving_circuit_proof_is_valid`) never checks the clock data a proof was
|
||||
generated against against real chain state. A malicious prover could in principle supply an
|
||||
arbitrary timestamp as a private witness and no check anywhere would catch it. Worth escalating
|
||||
to the LEZ/SPEL maintainers independent of whether/when the AMM test-writing blocker itself gets
|
||||
prioritized.
|
||||
|
||||
### Existing
|
||||
|
||||
6 private tests out of 33 pre-existing public + 6 = 39. No test can yet demonstrate an
|
||||
|
||||
@@ -3109,7 +3109,8 @@ fn token_program_instance() -> Program {
|
||||
}
|
||||
|
||||
fn twap_oracle_program_instance() -> Program {
|
||||
Program::new(twap_oracle_methods::TWAP_ORACLE_ELF.to_vec().into()).expect("valid twap oracle ELF")
|
||||
Program::new(twap_oracle_methods::TWAP_ORACLE_ELF.to_vec().into())
|
||||
.expect("valid twap oracle ELF")
|
||||
}
|
||||
|
||||
fn amm_with_deps() -> ProgramWithDependencies {
|
||||
@@ -3159,10 +3160,16 @@ fn amm_swap_a_to_b_private_user_holding_is_not_expressible() {
|
||||
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 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());
|
||||
@@ -3256,10 +3263,16 @@ fn amm_swap_exact_output_private_user_holding_is_not_expressible() {
|
||||
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 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());
|
||||
@@ -3351,10 +3364,16 @@ fn amm_add_liquidity_private_lp_holding_is_not_expressible() {
|
||||
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 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,
|
||||
@@ -3457,10 +3476,16 @@ fn amm_remove_liquidity_private_lp_holding_is_not_expressible() {
|
||||
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 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,
|
||||
@@ -3560,10 +3585,16 @@ fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() {
|
||||
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 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,
|
||||
@@ -3571,8 +3602,11 @@ fn amm_remove_liquidity_private_new_user_holdings_is_not_expressible() {
|
||||
);
|
||||
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 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,
|
||||
@@ -3695,10 +3729,16 @@ fn amm_add_liquidity_private_user_holdings_is_not_expressible() {
|
||||
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 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,
|
||||
@@ -3706,8 +3746,11 @@ fn amm_add_liquidity_private_user_holdings_is_not_expressible() {
|
||||
);
|
||||
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 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,
|
||||
|
||||
@@ -4,10 +4,6 @@ use ata_core::{compute_ata_seed, get_associated_token_account_id};
|
||||
use integration_tests::{
|
||||
private_authorized_init_identity, private_unauthorized_identity, GroupOwner,
|
||||
};
|
||||
use key_protocol::key_management::{
|
||||
group_key_holder::{GroupKeyHolder, SealingPublicKey},
|
||||
secret_holders::SecretSpendingKey,
|
||||
};
|
||||
use nssa::{
|
||||
execute_and_prove,
|
||||
privacy_preserving_transaction::{
|
||||
@@ -968,6 +964,116 @@ fn ata_burn_with_private_owner_signing() {
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// Group-owned variant of `ata_burn_with_private_owner_signing`: the GMS is distributed through
|
||||
/// the real seal/unseal handshake, and it's Bob — not Alice, who created the group — who
|
||||
/// self-initializes and signs the owner identity in the same transaction via
|
||||
/// `PrivateAuthorizedInit`, then burns from the ATA holding through it.
|
||||
#[test]
|
||||
fn ata_group_owned_owner_signing() {
|
||||
let mut state = V03State::new();
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
|
||||
let alice = GroupOwner::new([97_u8; 32]);
|
||||
let owner_id = alice.id;
|
||||
let bob_nsk = alice.admit_member();
|
||||
|
||||
// The ATA holding must stay public (per the confirmed PDA finding), so it's seeded
|
||||
// directly rather than via a real `Create` transaction.
|
||||
let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition());
|
||||
let ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed);
|
||||
let ata_account = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
};
|
||||
state.force_insert_account(ata_id, ata_account.clone());
|
||||
|
||||
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
|
||||
let ata_pre = AccountWithMetadata::new(ata_account, false, ata_id);
|
||||
let def_pre = AccountWithMetadata::new(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
false,
|
||||
Ids::token_definition(),
|
||||
);
|
||||
|
||||
let burn_amount = 300_000_u128;
|
||||
let instruction = ata_core::Instruction::Burn {
|
||||
token_program_id: Ids::token_program(),
|
||||
amount: burn_amount,
|
||||
};
|
||||
|
||||
let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap();
|
||||
let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap();
|
||||
let program_with_deps = ProgramWithDependencies::new(
|
||||
ata_program,
|
||||
HashMap::from([(Ids::token_program(), token_program)]),
|
||||
);
|
||||
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![owner_pre, ata_pre, def_pre],
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
vec![
|
||||
private_authorized_init_identity(bob_nsk, &alice.vpk, 0),
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::Public,
|
||||
],
|
||||
&program_with_deps,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message =
|
||||
Message::try_from_circuit_output(vec![ata_id, Ids::token_definition()], vec![], output)
|
||||
.unwrap();
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[]);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(
|
||||
&PrivacyPreservingTransaction::new(message, witness_set),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(ata_id),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128 - burn_amount,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128 - burn_amount,
|
||||
metadata_id: None,
|
||||
authority: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
|
||||
let owner_expected = Account {
|
||||
nonce: Nonce::private_account_nonce_init(&owner_id),
|
||||
..Account::default()
|
||||
};
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// Private owner
|
||||
#[test]
|
||||
fn ata_transfer_with_private_owner_signing() {
|
||||
|
||||
@@ -464,7 +464,6 @@ fn stablecoin_with_token_deps() -> ProgramWithDependencies {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/// `OpenPosition` is blocked by the `privacy_preserving_circuit` due to the handling of
|
||||
/// sibling chain calls of (uninitialized) private accounts.
|
||||
#[test]
|
||||
@@ -674,7 +673,8 @@ fn stablecoin_withdraw_collateral_private_destination() {
|
||||
|
||||
/// `WithdrawCollateral` blocks withdraws to private accounts (via private donations);
|
||||
/// `PrivateUnauthorized` account initialization (e.g., `nsk` is not known) is not permitted
|
||||
/// due to the assertion in `withdraw_collateral.rs` asserts `destination.account != Account::default()`
|
||||
/// due to the assertion in `withdraw_collateral.rs` asserts `destination.account !=
|
||||
/// Account::default()`
|
||||
#[test]
|
||||
fn stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible() {
|
||||
let mut state = V03State::new();
|
||||
|
||||
@@ -7,14 +7,12 @@ use nssa::{
|
||||
privacy_preserving_transaction::{Message, PrivacyPreservingTransaction, WitnessSet},
|
||||
program::Program,
|
||||
program_deployment_transaction::{self, ProgramDeploymentTransaction},
|
||||
public_transaction, PrivateKey, PublicKey, PublicTransaction, SharedSecretKey, V03State,
|
||||
public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State,
|
||||
};
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
|
||||
encryption::{EphemeralPublicKey, ViewingPublicKey},
|
||||
program::PdaSeed,
|
||||
Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey,
|
||||
NullifierSecretKey,
|
||||
encryption::ViewingPublicKey,
|
||||
Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey,
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
@@ -605,75 +603,6 @@ fn token_program() -> Program {
|
||||
Program::new(token_methods::TOKEN_ELF.to_vec().into()).expect("valid token ELF")
|
||||
}
|
||||
|
||||
/// TODO
|
||||
/// EXPERIMENTAL — investigating whether `PrivatePdaInit`'s `seed: Some((seed,
|
||||
/// authority_program_id))` external-derivation-check path lets a private-PDA account be used as an
|
||||
/// input to an *existing* program's flow (Token) without any chained call, `Claim::Pda`, or
|
||||
/// awareness from the `authority_program_id` itself. Per `lee_core`'s
|
||||
/// `circuit_io.rs`/`execution_state.rs`, this path binds the position purely via
|
||||
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) ==
|
||||
/// pre_state.account_id`, checked directly against the top-level `account_identities` — no chained
|
||||
/// call needed. Using `Ids::token_program()` as the `authority_program_id` here, but per the
|
||||
/// circuit source this is not required to correspond to anything Token itself is aware of; it's
|
||||
/// purely a hash input.
|
||||
#[test]
|
||||
fn token_shield_into_private_pda_via_external_seed() {
|
||||
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 sender_pre = AccountWithMetadata::new(sender_account, true, sender_id);
|
||||
|
||||
let authority_program_id = Ids::token_program();
|
||||
let pda_seed = PdaSeed::new([77u8; 32]);
|
||||
let recipient_nsk: NullifierSecretKey = [123u8; 32];
|
||||
let recipient_npk = NullifierPublicKey::from(&recipient_nsk);
|
||||
let recipient_vpk = ViewingPublicKey::from_seed(&[124u8; 32], &[125u8; 32]);
|
||||
let recipient_id =
|
||||
AccountId::for_private_pda(&authority_program_id, &pda_seed, &recipient_npk, 0);
|
||||
|
||||
let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id);
|
||||
|
||||
let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0;
|
||||
|
||||
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,
|
||||
InputAccountIdentity::PrivatePdaInit {
|
||||
epk: EphemeralPublicKey(Vec::new()),
|
||||
view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk),
|
||||
npk: recipient_npk,
|
||||
ssk: shared_secret,
|
||||
identifier: 0,
|
||||
seed: Some((pda_seed, authority_program_id)),
|
||||
},
|
||||
],
|
||||
&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 =
|
||||
Accounts::token_holding(amount, Nonce::private_account_nonce_init(&recipient_id));
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// Performs a shielded transfer (public → private) of `amount` tokens from
|
||||
/// `Ids::holder()` to a new private account keyed by `PrivateKeys::recipient_*`.
|
||||
/// Returns the resulting private recipient account.
|
||||
@@ -782,7 +711,11 @@ fn token_private_transfer() {
|
||||
shielded_amount,
|
||||
&mut state,
|
||||
false,
|
||||
private_unauthorized_identity(PrivateKeys::recipient_npk(), &PrivateKeys::recipient_vpk(), 0),
|
||||
private_unauthorized_identity(
|
||||
PrivateKeys::recipient_npk(),
|
||||
&PrivateKeys::recipient_vpk(),
|
||||
0,
|
||||
),
|
||||
);
|
||||
let sender_nsk = PrivateKeys::recipient_nsk();
|
||||
let sender_vpk = PrivateKeys::recipient_vpk();
|
||||
@@ -852,7 +785,11 @@ fn token_deshielded_transfer() {
|
||||
shielded_amount,
|
||||
&mut state,
|
||||
false,
|
||||
private_unauthorized_identity(PrivateKeys::recipient_npk(), &PrivateKeys::recipient_vpk(), 0),
|
||||
private_unauthorized_identity(
|
||||
PrivateKeys::recipient_npk(),
|
||||
&PrivateKeys::recipient_vpk(),
|
||||
0,
|
||||
),
|
||||
);
|
||||
let sender_nsk = PrivateKeys::recipient_nsk();
|
||||
let sender_vpk = PrivateKeys::recipient_vpk();
|
||||
@@ -1457,6 +1394,112 @@ fn token_initialize_private_account_without_nsk_is_not_expressible() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_new_fungible_definition_private_initial_holder() {
|
||||
let mut state = V03State::new();
|
||||
deploy_token(&mut state);
|
||||
|
||||
let holder_nsk = PrivateKeys::holder_nsk();
|
||||
let holder_vpk = PrivateKeys::holder_vpk();
|
||||
let holder_id = PrivateKeys::holder_id();
|
||||
|
||||
let definition_nonce = state.get_account_by_id(Ids::token_definition()).nonce;
|
||||
let definition_pre =
|
||||
AccountWithMetadata::new(Account::default(), true, Ids::token_definition());
|
||||
let holder_pre = AccountWithMetadata::new(Account::default(), true, holder_id);
|
||||
|
||||
let instruction = token_core::Instruction::NewFungibleDefinition {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
mint_authority: None,
|
||||
};
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![definition_pre, holder_pre],
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::Public,
|
||||
private_authorized_init_identity(holder_nsk, &holder_vpk, 0),
|
||||
],
|
||||
&token_program().into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![Ids::token_definition()],
|
||||
vec![definition_nonce],
|
||||
output,
|
||||
)
|
||||
.unwrap();
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::def_key()]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 0, 0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
metadata_id: None,
|
||||
authority: None,
|
||||
}),
|
||||
nonce: Nonce(1),
|
||||
}
|
||||
);
|
||||
|
||||
let holder_account = Accounts::token_holding(
|
||||
1_000_000_u128,
|
||||
Nonce::private_account_nonce_init(&holder_id),
|
||||
);
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&holder_id, &holder_account))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_new_fungible_definition_private_holder_without_nsk_is_not_expressible() {
|
||||
let mut state = V03State::new();
|
||||
deploy_token(&mut state);
|
||||
|
||||
let holder_npk = PrivateKeys::holder_npk();
|
||||
let holder_vpk = PrivateKeys::holder_vpk();
|
||||
let holder_id = PrivateKeys::holder_id();
|
||||
|
||||
let definition_pre =
|
||||
AccountWithMetadata::new(Account::default(), true, Ids::token_definition());
|
||||
let holder_pre = AccountWithMetadata::new(Account::default(), false, holder_id);
|
||||
|
||||
let instruction = token_core::Instruction::NewFungibleDefinition {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128,
|
||||
mint_authority: None,
|
||||
};
|
||||
let result = execute_and_prove(
|
||||
vec![definition_pre, holder_pre],
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::Public,
|
||||
private_unauthorized_identity(holder_npk, &holder_vpk, 0),
|
||||
],
|
||||
&token_program().into(),
|
||||
);
|
||||
|
||||
let err = result.expect_err(
|
||||
"creating the initial holder via PrivateUnauthorized must be rejected: \
|
||||
NewFungibleDefinition requires is_authorized == true for holding_target_account, but \
|
||||
PrivateUnauthorized forces is_authorized == false",
|
||||
);
|
||||
let message = format!("{err:?}");
|
||||
assert!(
|
||||
message.contains("must be a signer"),
|
||||
"expected the self-service-only rejection, got a different error: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two independent parties (Alice and Bob) control a private Token holding (via `GroupKeyHolder`).
|
||||
/// Alice initializes the private Token account, and Bob burns tokens from the shared account.
|
||||
#[test]
|
||||
@@ -2102,96 +2145,3 @@ fn token_mint_with_authority_to_private_holding() {
|
||||
.get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// TODO
|
||||
/// EXPERIMENTAL — follow-up to `token_shield_into_private_pda_via_external_seed`: proves the
|
||||
/// *update* half of the same mechanism (crediting an *existing* private PDA, not just creating
|
||||
/// one), completing a genuine round trip rather than a one-shot creation. `PrivatePdaUpdate`'s
|
||||
/// external seed path has a different pre-condition than `Init`: `execution_state.rs` asserts
|
||||
/// `pre_state.is_authorized ^ external_seed.is_some()` — with an external seed supplied, the
|
||||
/// pre-state must be *unauthorized*, even though we're touching it with a real `nsk` +
|
||||
/// `membership_proof`. That's incompatible with `Transfer`'s sender role, which requires a
|
||||
/// framework-level `#[account(signer)]` (`is_authorized: true`) — confirmed empirically: using
|
||||
/// the private-PDA holder as `Transfer`'s sender fails at the SPEL macro's own validation
|
||||
/// ("must be a signer"), before Token's own logic is ever reached. `Mint`'s
|
||||
/// `user_holding_account` has no such requirement (`mint_inner` never asserts `is_authorized` on
|
||||
/// it, crediting an existing holding or not), so it's used here instead — mirroring the `EXIST`
|
||||
/// dimension's existing-account-crediting pattern (`token_mint_into_existing_private_holding`),
|
||||
/// just with a private-PDA holder instead of a regular private account.
|
||||
#[test]
|
||||
fn token_mint_into_existing_private_pda_via_external_seed() {
|
||||
let mut state = state_for_token_tests_without_recipient();
|
||||
let holding_balance = 500_000_u128;
|
||||
let amount_to_mint = 200_000_u128;
|
||||
|
||||
let authority_program_id = Ids::token_program();
|
||||
let pda_seed = PdaSeed::new([88u8; 32]);
|
||||
let holder_nsk: NullifierSecretKey = [131u8; 32];
|
||||
let holder_npk = NullifierPublicKey::from(&holder_nsk);
|
||||
let holder_vpk = ViewingPublicKey::from_seed(&[132u8; 32], &[133u8; 32]);
|
||||
let holder_id = AccountId::for_private_pda(&authority_program_id, &pda_seed, &holder_npk, 0);
|
||||
|
||||
// Seed the private-PDA holding directly (established technique — no real transaction
|
||||
// needed). Its eligibility as a private PDA is re-derived independently by the update-side
|
||||
// check below; nothing about how it was seeded matters to that check.
|
||||
let holder_account = Accounts::token_holding(
|
||||
holding_balance,
|
||||
Nonce::private_account_nonce_init(&holder_id),
|
||||
);
|
||||
let holder_commitment = Commitment::new(&holder_id, &holder_account);
|
||||
state = state.with_private_accounts([(
|
||||
holder_commitment.clone(),
|
||||
Nullifier::for_account_initialization(&holder_id),
|
||||
)]);
|
||||
let membership_proof = state
|
||||
.get_proof_for_commitment(&holder_commitment)
|
||||
.expect("seeded holder's commitment must be in the set");
|
||||
|
||||
let definition_account = state.get_account_by_id(Ids::token_definition());
|
||||
let definition_nonce = definition_account.nonce;
|
||||
let definition_pre =
|
||||
AccountWithMetadata::new(definition_account, true, Ids::token_definition());
|
||||
let holder_pre = AccountWithMetadata::new(holder_account, false, holder_id);
|
||||
|
||||
let shared_secret = SharedSecretKey::encapsulate_deterministic(&holder_vpk, &[0u8; 32], 0).0;
|
||||
|
||||
let instruction = token_core::Instruction::Mint { amount_to_mint };
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![definition_pre, holder_pre],
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::PrivatePdaUpdate {
|
||||
epk: EphemeralPublicKey(Vec::new()),
|
||||
view_tag: EncryptedAccountData::compute_view_tag(&holder_npk, &holder_vpk),
|
||||
ssk: shared_secret,
|
||||
nsk: holder_nsk,
|
||||
membership_proof,
|
||||
identifier: 0,
|
||||
seed: Some((pda_seed, authority_program_id)),
|
||||
},
|
||||
],
|
||||
&token_program().into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message = Message::try_from_circuit_output(
|
||||
vec![Ids::token_definition()],
|
||||
vec![definition_nonce],
|
||||
output,
|
||||
)
|
||||
.unwrap();
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::def_key()]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 0, 0)
|
||||
.unwrap();
|
||||
|
||||
let holder_nonce_after =
|
||||
Nonce::private_account_nonce_init(&holder_id).private_account_nonce_increment(&holder_nsk);
|
||||
let new_holder_account =
|
||||
Accounts::token_holding(holding_balance + amount_to_mint, holder_nonce_after);
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&holder_id, &new_holder_account))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user