diff --git a/docs/findings.md b/docs/findings.md index cfd280c..434b8f6 100644 --- a/docs/findings.md +++ b/docs/findings.md @@ -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` 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 diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index a3e369a..861899d 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -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: , ..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 diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index 1110caf..6f2b607 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -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, diff --git a/programs/integration_tests/tests/ata.rs b/programs/integration_tests/tests/ata.rs index ebffe47..32c45e9 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -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() { diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 0d16648..d67de42 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -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(); diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index 8e82003..5ecd9d7 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -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()); -}