diff --git a/docs/findings.md b/docs/findings.md new file mode 100644 index 0000000..c6e15fd --- /dev/null +++ b/docs/findings.md @@ -0,0 +1,236 @@ + +# LEE privacy + +Similar to public accounts, private accounts can be regular (generated using based on user generated keys) and PDA. Additionally, private accounts can be shared by a group. + +## Overview of (regular) private accounts + +### Private account initialization +Regular private accounts initialization with or without knowledge of the account's nullifier secret key `nsk`. + +- `PrivateUnauthorized` + A special case for initializing private accounts using only `npk` and `vpk`. + + Use cases; + Private donations. A user publishes public keys (`npk`, `vpk`) associated to a set of private account keys. A third party initializes a fresh private account using these keys (and some `identifier`). This initialization transaction does not require the corresponding `nsk`. Any future transactions with this private account must be performed by the account owner (using the `nsk`). +- `PrivateAuthorizedInit` + Private account initialized using the account's `nsk` (and some `identifier`). + +### Private account update (`PrivateAuthorizedUpdate`) +Regular private accounts are updated the same way. Knowledge of the account's `nsk` and other data that is used for the + +### Summary + +|type | authorized | who can use | +|----|----|----| +| `PrivateUnauthorized`| ❌ | anyone | +| `PrivateAuthorizedInit` | ✅ | owner | +| `PrivateAuthorizedUpdate` | ✅ | owner | + +Only the account owner can (1) update their initialized account, and (2) use functions that require authorization with their account. + +## Private PDA + +### Private PDA vs public PDA +- `AccountId` formulas are different: + - Public: `hash(prefix || program_id || seed)` + - Private: `hash(prefix || program_id || seed || npk || identifier)` + +The difference in these PDA `AccountId` formulas prevents programs from being privacy agnostic for PDAs. + +## Group-shared (multi-party) private accounts (TODO) + +A single private account can be jointly controlled by two or more parties without either one +handing over their actual secret key. The mechanism is a **Group Master Secret (GMS)**, +distributed via a real seal/unseal handshake (ML-KEM-768), not key reuse: + +1. One party ("Alice") creates a `GroupKeyHolder` and derives the shared account's `npk`/`vpk` + from it (`derive_keys_for_shared_account(seed)`). +2. Alice **seals** the GMS against a second party's ("Bob's") own sealing public key + (`seal_for`) and hands over only the sealed bytes. +3. Bob **unseals** it with his own sealing secret key (`GroupKeyHolder::unseal`), then + independently re-derives the *identical* `nsk`/`npk` from the same seed — without ever + touching Alice's `GroupKeyHolder` object directly. + +Bob's re-derived `nsk` then works in `PrivateAuthorizedInit`/`PrivateAuthorizedUpdate` exactly +like a personally-held key — confirmed indistinguishable from a personal account for every +instruction tried (spend, sign, self-initialize), across Token, ATA, and Stablecoin. + +# Privacy testing objectives for LEZ programs (TODO) + +- [ ] **Private PDAs used as program inputs across the above flows.** + **Not achieved — structurally blocked, not a test gap.** Every program with PDAs (ATA, + AMM, Stablecoin) derives them via `for_public_pda(program_id, seed)` only. The private + formula, `for_private_pda(program_id, seed, npk, identifier)`, additionally requires an + `npk` — but none of `ata_core`/`amm_core`/`stablecoin_core`'s seed-computation functions + accept an `npk` today, so it's never reachable through these programs as coded. Confirmed + empirically not-expressible for ATA (`ata_create_private_ata_holding_is_not_expressible`); + the same root cause applies to AMM and Stablecoin (identical `for_public_pda`-only + pattern, verified directly in their `*_core` crates). Token has no PDAs at all — N/A at + that layer, not a gap. + *Re: "could we compose a test program that uses private PDAs with these pre-existing?"* — + no. None of the four existing programs can be made to produce a `for_private_pda` address + through a test alone, since the formula choice is hardcoded in their source. Demonstrating + the mechanism at all would require either changing one of the `*_core` crates to derive via + `for_private_pda`, or standing up a small purpose-built program whose only job is to + exercise it — both are source changes, not test-writing. **This is the single most + actionable item to feed back to the protocol team.** + +- Group owned shared private account as input to programs. + +- [x] **Sending funds to an existing private account.** + **Achieved, with one real condition: cooperation is required.** Confirmed across Token + (`Transfer`, `Mint`), ATA (`Transfer`, including through a nested chained call into + Token), and Stablecoin (`WithdrawCollateral`). Every path that touches an *existing* + private account (`PrivateAuthorizedUpdate`) requires that account's own `nsk` plus a + membership proof, supplied in the same transaction — there is no blind-credit analog to + `PrivateUnauthorized` for existing accounts (only *fresh* accounts can be credited by a + stranger). This isn't partial — it's a clean, fully-confirmed yes with one unavoidable, + real-world condition: the recipient must be reachable to supply their `nsk` (online or + pre-coordinated). That's a protocol/wallet-UX property to design around, not a bug or an + untested edge. + +- [~] **Multiple private accounts in one transaction, and private accounts carried through + chained calls.** This is two separate sub-objectives with different status — worth + splitting: + - **Multiple private accounts in one tx — Achieved.** `token_private_transfer` (sender + + recipient, both private, zero public accounts anywhere) and + `token_private_transfer_into_existing_private_holding` (same, recipient already + existing). + - **Carried through a chained call — Achieved, but only single-hop so far.** + `ata_transfer_to_existing_private_recipient` proves a private identity survives one + chained call (ATA → Token) — the first test in the whole exercise to prove this works + at all. Every private Stablecoin `WithdrawCollateral`/`RepayDebt` test also carries a + private account through exactly one chained call (Stablecoin → Token). **Not yet + tested:** deeper, multi-hop chaining — an instruction issuing more than one chained + call with a private account threaded through it (e.g. AMM's `SwapExactInput` chains + into *both* Token and the TWAP oracle in one instruction). That case is currently + unreachable: AMM is blocked entirely by a separate, privacy-unrelated circuit bug (see + the AMM section) before any chaining depth can even be exercised. So: not unclear — + genuinely proven for the single-hop case, with the deeper case blocked pending AMM. + + +# LEZ programs (TODO) + +## AMM program +TODO + +## ATA program + +ATA program offers limited usage with private accounts. Private accounts can be used as the `owner` (or as a recipient to transactions). But, ATA program can only generate public PDAs. The `owner` account can be public/private/shared and have any `program_owner`. + +| Function tested | Test name | Category | Description of objective | Result | +|---|---|---|---|---| +| 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 | ✅ | +| 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_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 four categories and are worth noting separately: +`ata_burn_with_private_owner_signing` and `ata_transfer_with_private_owner_signing` (a +*personal*, non-group private owner signing `Burn`/`Transfer`). They were the key discovery that +`owner` must be a *signer* for these two instructions (unlike `Create`) — a real finding, just +not one of the four Q2 checkboxes, so it's omitted here the same way Token's `BASE` rows were. + +## 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_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 | ✅ | + +**`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. + +## Token program + +| Function tested | Test name | Category | Description of objective | Result | +|---|---|---|---|---| +| Transfer | `token_transfer_into_existing_private_holding` | EXIST | Second transfer into an already-shielded recipient — confirms crediting an existing private account requires the recipient's own cooperation (`nsk`), not just their public key | ✅ | +| Transfer | `token_private_transfer_into_existing_private_holding` | EXIST, CHAIN | Both legs private (sender + recipient) in one transaction, and the recipient is already existing rather than fresh | ✅ | +| Transfer | `token_group_owned_holding_shared_control_transfer` | GROUP | Group-owned sender (real GMS seal/unseal handshake) spends outward via `Transfer` to a fresh private recipient | ✅ | +| Transfer | `token_private_transfer` | CHAIN | Pre-existing test; two private accounts (sender + fresh recipient) compose in a single transaction with no public account at all — fulfills the "multiple private accounts in one tx" half of `CHAIN` | ✅ | +| Mint | `token_mint_into_existing_private_holding` | EXIST | Mint once to establish a private holding, mint again into it via `PrivateAuthorizedUpdate` — crediting an existing private account | ✅ | +| 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_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` | ✅ | + +**`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. + +# Conclusions + +## Group shared private accounts +- Group shared accounts are authorized + +# 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`) \ No newline at end of file diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index c5d2faf..64d4936 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -472,6 +472,7 @@ vault) are `for_public_pda` only, per the ATA `PDA` finding. | RepayDebt | `CHAIN` | `stablecoin_repay_debt_private_stablecoin_holding` | Pass | | RepayDebt | `CHAIN` + `GROUP` | `stablecoin_repay_debt_group_owned_stablecoin_holding` | Pass | | WithdrawCollateral (owner identity) | `GROUP` | `stablecoin_group_owned_position_owner` | Pass | +| WithdrawCollateral | new: destination must pre-exist | `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible` | **Not-expressible — confirmed** | **Finding (`OpenPosition`, confirmed 2026-07-08 — the headline finding for this program, and arguably the whole exercise): `OpenPosition` cannot be executed through the privacy-preserving @@ -531,6 +532,19 @@ via `PrivateAuthorizedInit`, then withdraws collateral through it. Passed on the This is the correct, expressible version of "joint control over a CDP": shared control of the *authority* over a PDA-locked resource, not shared privacy of the resource itself. +**Finding (`stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`, +confirmed 2026-07-09 — second, unrelated not-expressible result for this program):** +`WithdrawCollateral` cannot pay out to a brand-new private destination. `withdraw_collateral.rs` +hard-asserts `destination.account != Account::default()` before the chained `Token::Transfer` is +even constructed — a plain host-side program precondition, unrelated to the `OpenPosition` +authorization-bookkeeping bug above. It fires regardless of privacy: a brand-new *public* +destination would be rejected identically. Confirmed by attempting `WithdrawCollateral` with a +`PrivateUnauthorized` destination (fresh `Account::default()` pre-state, only `npk` known) and +observing the exact `"Destination must be initialized"` panic surface as the circuit-execution +error. Consequence: every `WithdrawCollateral` test in this phase necessarily uses +`PrivateAuthorizedUpdate` (`nsk` known) for the destination — a pre-existing private destination +is the *only* expressible shape, not a coverage choice. + `ProtocolParameters` remains out of scope — not yet consumed by any instruction (no freeze/admin logic wired up), nothing to test. @@ -542,13 +556,14 @@ freeze/admin logic wired up), nothing to test. `integration_tests/Cargo.toml` pinned to the same repo/tag as `nssa`/`nssa_core`. Unblocks the remaining `GROUP` rows in ATA/AMM/Stablecoin; each still needs its own program-specific test (PDA-based group ownership, not just the regular-account path proven for Token). -- Build the shared privacy test kit in `integration_tests/src/lib.rs` — **partially done** +- Build the shared privacy test kit in `integration_tests/src/lib.rs` — **mostly done** (2026-07-08): `private_unauthorized_identity`/`private_authorized_init_identity`/ `private_authorized_update_identity` (build an `InputAccountIdentity` from just the key - material) and `setup_group_shared_account` (the Alice-creates/Bob-unseals GMS handshake) now - live there and are used throughout `token.rs`. `ata.rs`/`stablecoin.rs` still have their own - independent copies of the same patterns — not yet migrated, since that was out of scope for - the token.rs-focused cleanup pass. Revisit migrating them before/during AMM. + material) and `GroupOwner` (the Alice-creates/Bob-admitted GMS handshake, via `::new(seed)` + + `.admit_member()`) now live there and are used throughout `token.rs`, `stablecoin.rs` (fully + migrated), and the newer `ata.rs` group tests. Only the original `ata_group_owned_owner_signing` + still has its own independent inline copy — not yet migrated. Low priority; revisit + before/during AMM if it's still outstanding then. **Implementation technique worth carrying into AMM/Stablecoin (found 2026-07-07):** private account preconditions don't need a real proven transaction to set up. `V03State::with_private_accounts(impl IntoIterator)` @@ -571,4 +586,4 @@ heavier (chained calls, multiple accounts) than a single shield. | Token | 16 (3 pre-existing + 13 new: 12 pass + 1 confirmed not-expressible by design) — phase complete | 0 | 5 | | ATA | 8 (7 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 | | AMM | 0 (2 rows now predicted not-expressible pending confirmation) | 10 | 5 | -| Stablecoin | 6 (5 pass + 1 confirmed not-expressible — phase complete) | 0 | 1 | +| Stablecoin | 7 (5 pass + 2 confirmed not-expressible — phase complete) | 0 | 1 | diff --git a/programs/integration_tests/tests/ata.rs b/programs/integration_tests/tests/ata.rs index 3d35c21..ebffe47 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -968,131 +968,6 @@ fn ata_burn_with_private_owner_signing() { .is_some()); } -/// TODO: remove, this is essentially same as burn test. Worth noting though that -/// any member can sign. -#[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()); - - // Alice creates the group and derives the shared owner identity's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [13_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let owner_npk = alice_keys.generate_nullifier_public_key(); - let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake. - let bob_sealing_keys = SecretSpendingKey([17_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - - // Bob independently re-derives the same shared owner keys and is the one who signs below. - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - let bob_vpk = bob_keys.generate_viewing_public_key(); - assert_eq!( - bob_keys.generate_nullifier_public_key(), - owner_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); - - // 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 shared_secret = SharedSecretKey::encapsulate_deterministic(&bob_vpk, &[0u8; 32], 0).0; - - 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![ - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &bob_vpk), - ssk: shared_secret, - nsk: bob_nsk, - identifier: 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), - } - ); - - 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 165214b..0d16648 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -use key_protocol::key_management::{ - group_key_holder::{GroupKeyHolder, SealingPublicKey}, - secret_holders::SecretSpendingKey, +use integration_tests::{ + private_authorized_init_identity, private_authorized_update_identity, + private_unauthorized_identity, GroupOwner, }; use nssa::{ execute_and_prove, @@ -11,13 +11,12 @@ use nssa::{ }, 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}, - Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, - NullifierSecretKey, + encryption::ViewingPublicKey, + Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, }; use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position}; use token_core::{TokenDefinition, TokenHolding}; @@ -465,23 +464,9 @@ fn stablecoin_with_token_deps() -> ProgramWithDependencies { ) } -// Marvin-todo -/// `OpenPosition` cannot execute through the privacy-preserving transaction type *at all* — -/// confirmed here with every single account `Public` and zero private accounts involved. Root -/// cause traced in `lee_core`'s `execution_state.rs`: `authorized_accounts` is a monotonic/sticky -/// set — once an account is authorized via one chained call's `pda_seeds` match, every later -/// occurrence of that same account must also declare `is_authorized: true`, or -/// `assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")` -/// fails. `open_position.rs` issues two chained calls that both reuse `vault`: the first -/// (`Token::InitializeAccount`) authorizes it via `pda_seeds`, sticking `vault` as authorized; -/// the second (`Token::Transfer`) then deliberately constructs `post_init_vault` with -/// `is_authorized: false` (a legitimate choice on the public-transaction path — "the recipient -/// is already initialized, so no second PDA claim is needed" per that file's own comment) — but -/// the privacy circuit rejects that as inconsistent. This is not a privacy-dimension gap; it -/// blocks `OpenPosition` from ever being expressed as a `PrivacyPreservingTransaction`, so every -/// other instruction that depends on having *opened* a position privately is affected too (see -/// `stablecoin_group_owned_position_owner`, which routes around it by seeding the position/vault -/// directly instead of calling `OpenPosition`). + +/// `OpenPosition` is blocked by the `privacy_preserving_circuit` due to the handling of +/// sibling chain calls of (uninitialized) private accounts. #[test] fn stablecoin_open_position_via_privacy_transaction_is_not_expressible() { let mut state = V03State::new(); @@ -545,14 +530,7 @@ fn stablecoin_open_position_via_privacy_transaction_is_not_expressible() { ); } -// Marvin-todo -/// `WithdrawCollateral` has only *one* chained call (`Token::Transfer`, reusing `vault` exactly -/// once), unlike `OpenPosition`'s two — so it should avoid the authorization-consistency -/// blocker confirmed above. Position/vault are seeded directly via `force_insert_account` -/// (public accounts, no real `OpenPosition` call needed, and none is possible per the finding -/// above). `withdraw_collateral.rs` hard-asserts `destination.account != Account::default()`, -/// so `destination` must already exist — same `EXIST` shape as ATA's Transfer, requiring the -/// destination's cooperation via `PrivateAuthorizedUpdate`. +/// `WithdrawCollateral` to private account (`PrivateAuthorized`; `nsk` is known). #[test] fn stablecoin_withdraw_collateral_private_destination() { let mut state = V03State::new(); @@ -597,7 +575,6 @@ fn stablecoin_withdraw_collateral_private_destination() { state.force_insert_account(vault_id, vault_account); let destination_nsk = PrivateKeys::destination_nsk(); - let destination_npk = PrivateKeys::destination_npk(); let destination_vpk = PrivateKeys::destination_vpk(); let destination_id = PrivateKeys::destination_id(); let destination_initial_balance = 100_000_u128; @@ -629,9 +606,6 @@ fn stablecoin_withdraw_collateral_private_destination() { amount: withdraw_amount, }; - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&destination_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![owner_pre, position_pre, vault_pre, destination_pre], Program::serialize_instruction(instruction).unwrap(), @@ -639,17 +613,12 @@ fn stablecoin_withdraw_collateral_private_destination() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &destination_npk, - &destination_vpk, - ), - ssk: shared_secret, - nsk: destination_nsk, + private_authorized_update_identity( + destination_nsk, + &destination_vpk, membership_proof, - identifier: 0, - }, + 0, + ), ], &stablecoin_with_token_deps(), ) @@ -703,12 +672,89 @@ fn stablecoin_withdraw_collateral_private_destination() { .is_some()); } -// Marvin-todo -/// `GROUP` variance on `stablecoin_withdraw_collateral_private_destination`: the destination is -/// group-owned instead of personal. The GMS is distributed through the real seal/unseal -/// handshake (as in `token_group_owned_holding_shared_control_burn`); "Bob" — who only ever -/// receives the sealed GMS — independently re-derives the shared destination's keys and -/// supplies its `PrivateAuthorizedUpdate` cooperation to receive the withdrawn collateral. +/// `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()` +#[test] +fn stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + + let owner_id = Ids::owner(); + let position_id = compute_position_pda( + Ids::stablecoin_program(), + owner_id, + Ids::collateral_definition(), + ); + let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id); + + let position_collateral = 500_000_u128; + let withdraw_amount = 200_000_u128; + + let position_account = Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&Position { + collateral_vault_id: vault_id, + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: position_collateral, + debt_amount: 0, + }), + nonce: Nonce(0), + }; + let vault_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: position_collateral, + }), + nonce: Nonce(0), + }; + state.force_insert_account(position_id, position_account); + state.force_insert_account(vault_id, vault_account); + + let destination_npk = PrivateKeys::destination_npk(); + let destination_vpk = PrivateKeys::destination_vpk(); + let destination_id = PrivateKeys::destination_id(); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = + AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id); + let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id); + let destination_pre = AccountWithMetadata::new(Account::default(), false, destination_id); + + let instruction = stablecoin_core::Instruction::WithdrawCollateral { + amount: withdraw_amount, + }; + + let result = execute_and_prove( + vec![owner_pre, position_pre, vault_pre, destination_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + private_unauthorized_identity(destination_npk, &destination_vpk, 0), + ], + &stablecoin_with_token_deps(), + ); + + let err = result.expect_err( + "WithdrawCollateral must be rejected: destination is a brand-new (default) private \ + account, but withdraw_collateral.rs requires the destination to already be initialized", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Destination must be initialized"), + "expected the destination-must-be-initialized rejection, got a different error: {message}" + ); +} + #[test] fn stablecoin_withdraw_collateral_group_owned_destination() { let mut state = V03State::new(); @@ -752,30 +798,12 @@ fn stablecoin_withdraw_collateral_group_owned_destination() { state.force_insert_account(position_id, position_account); state.force_insert_account(vault_id, vault_account); - // Alice creates the group and derives the shared destination's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [7_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let destination_npk = alice_keys.generate_nullifier_public_key(); - let destination_vpk = alice_keys.generate_viewing_public_key(); - let destination_id = AccountId::for_regular_private_account(&destination_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake. - let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - assert_eq!( - bob_keys.generate_nullifier_public_key(), - destination_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); + // Alice creates the group and derives the shared destination's keys; Bob is admitted via + // the real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let bob_nsk = alice.admit_member(); + let destination_vpk = alice.vpk; + let destination_id = alice.id; let destination_initial_balance = 100_000_u128; let destination_account = Account { @@ -806,9 +834,6 @@ fn stablecoin_withdraw_collateral_group_owned_destination() { amount: withdraw_amount, }; - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&destination_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![owner_pre, position_pre, vault_pre, destination_pre], Program::serialize_instruction(instruction).unwrap(), @@ -816,17 +841,7 @@ fn stablecoin_withdraw_collateral_group_owned_destination() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &destination_npk, - &destination_vpk, - ), - ssk: shared_secret, - nsk: bob_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(bob_nsk, &destination_vpk, membership_proof, 0), ], &stablecoin_with_token_deps(), ) @@ -870,14 +885,6 @@ fn stablecoin_withdraw_collateral_group_owned_destination() { .is_some()); } -// Marvin-todo -/// `user_stablecoin_holding` is private, burned via `RepayDebt`'s single chained `Token::Burn`. -/// Unlike ATA's own holdings (structurally locked to public PDAs), Stablecoin's stablecoin -/// holding is a regular user-controlled token holding with no PDA involved at all, so it's free -/// to be private with no structural obstacle. Position/stablecoin-definition are seeded -/// directly, matching the pre-existing public -/// `stablecoin_repay_debt_burns_stablecoins_and_decreases_debt` test's fixture approach (no real -/// `OpenPosition` call, consistent with the finding above). #[test] fn stablecoin_repay_debt_private_stablecoin_holding() { let mut state = V03State::new(); @@ -917,7 +924,6 @@ fn stablecoin_repay_debt_private_stablecoin_holding() { state.force_insert_account(position_id, position_account); let stablecoin_holding_nsk = PrivateKeys::stablecoin_holding_nsk(); - let stablecoin_holding_npk = PrivateKeys::stablecoin_holding_npk(); let stablecoin_holding_vpk = PrivateKeys::stablecoin_holding_vpk(); let stablecoin_holding_id = PrivateKeys::stablecoin_holding_id(); let initial_stablecoin_balance = Balances::user_stablecoin_holding_init(); @@ -959,9 +965,6 @@ fn stablecoin_repay_debt_private_stablecoin_holding() { amount: repay_amount, }; - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&stablecoin_holding_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![ owner_pre, @@ -974,17 +977,12 @@ fn stablecoin_repay_debt_private_stablecoin_holding() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &stablecoin_holding_npk, - &stablecoin_holding_vpk, - ), - ssk: shared_secret, - nsk: stablecoin_holding_nsk, + private_authorized_update_identity( + stablecoin_holding_nsk, + &stablecoin_holding_vpk, membership_proof, - identifier: 0, - }, + 0, + ), ], &stablecoin_with_token_deps(), ) @@ -1041,11 +1039,6 @@ fn stablecoin_repay_debt_private_stablecoin_holding() { .is_some()); } -// Marvin-todo -/// `GROUP` variance on `stablecoin_repay_debt_private_stablecoin_holding`: the stablecoin -/// holding being burned from is group-owned instead of personal. Same real seal/unseal -/// distribution as every other group test in this exercise; Bob independently re-derives the -/// shared holding's keys and supplies `PrivateAuthorizedUpdate` cooperation for the burn. #[test] fn stablecoin_repay_debt_group_owned_stablecoin_holding() { let mut state = V03State::new(); @@ -1084,30 +1077,12 @@ fn stablecoin_repay_debt_group_owned_stablecoin_holding() { }; state.force_insert_account(position_id, position_account); - // Alice creates the group and derives the shared stablecoin holding's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [7_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let holding_npk = alice_keys.generate_nullifier_public_key(); - let holding_vpk = alice_keys.generate_viewing_public_key(); - let holding_id = AccountId::for_regular_private_account(&holding_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake. - let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - assert_eq!( - bob_keys.generate_nullifier_public_key(), - holding_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); + // Alice creates the group and derives the shared stablecoin holding's keys; Bob is + // admitted via the real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let bob_nsk = alice.admit_member(); + let holding_vpk = alice.vpk; + let holding_id = alice.id; let initial_stablecoin_balance = Balances::user_stablecoin_holding_init(); let holding_account = Account { @@ -1141,8 +1116,6 @@ fn stablecoin_repay_debt_group_owned_stablecoin_holding() { amount: repay_amount, }; - let shared_secret = SharedSecretKey::encapsulate_deterministic(&holding_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![owner_pre, position_pre, definition_pre, holding_pre], Program::serialize_instruction(instruction).unwrap(), @@ -1150,14 +1123,7 @@ fn stablecoin_repay_debt_group_owned_stablecoin_holding() { InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&holding_npk, &holding_vpk), - ssk: shared_secret, - nsk: bob_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(bob_nsk, &holding_vpk, membership_proof, 0), ], &stablecoin_with_token_deps(), ) @@ -1198,18 +1164,6 @@ fn stablecoin_repay_debt_group_owned_stablecoin_holding() { .is_some()); } -// Marvin-todo -/// Reframes what "group-owned position" actually means, given the findings above: the -/// *position/vault themselves* can never be private or group-owned (the `PDA` finding), and -/// they can't even be opened through a privacy-preserving transaction at all (the -/// authorization-consistency finding above). But `owner` is just an `AccountId` used for PDA -/// seed derivation and signer verification — it doesn't need to be a plain public keypair. So -/// the real, well-motivated test is: a group-derived `owner` identity controls a PDA-locked -/// position, even though the position/vault stay public. Position/vault are seeded directly -/// (bypassing the blocked `OpenPosition`); "Bob" — who only ever receives the sealed GMS — -/// self-initializes *and* signs the owner identity in one transaction via `PrivateAuthorizedInit` -/// (since this owner has never proven control before), then withdraws collateral through it. -/// Directly mirrors `ata_group_owned_owner_signing`'s precedent for a PDA-locked resource. #[test] fn stablecoin_group_owned_position_owner() { let mut state = V03State::new(); @@ -1220,32 +1174,11 @@ fn stablecoin_group_owned_position_owner() { ); state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init()); - // Alice creates the group and derives the shared owner identity's keys. - let alice_holder = GroupKeyHolder::new(); - let derivation_seed = [7_u8; 32]; - let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed); - let owner_npk = alice_keys.generate_nullifier_public_key(); - let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake. - let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); - let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key(); - let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key; - let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes( - bob_sealing_vpk.to_bytes().to_vec(), - )); - let bob_holder = - GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS"); - - // Bob independently re-derives the same shared owner keys. - let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed); - let bob_nsk = bob_keys.nullifier_secret_key; - let bob_vpk = bob_keys.generate_viewing_public_key(); - assert_eq!( - bob_keys.generate_nullifier_public_key(), - owner_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); + // Alice creates the group and derives the shared owner identity's keys; Bob is admitted + // via the real seal/unseal handshake and independently re-derives the same keys. + let alice = GroupOwner::new([7_u8; 32]); + let bob_nsk = alice.admit_member(); + let owner_id = alice.id; // Position/vault addresses are derived from the group-owned owner_id — still ordinary // public PDAs (the seed formula doesn't care whether owner_id is public or private), seeded @@ -1296,19 +1229,11 @@ fn stablecoin_group_owned_position_owner() { amount: withdraw_amount, }; - let shared_secret = SharedSecretKey::encapsulate_deterministic(&bob_vpk, &[0u8; 32], 0).0; - let (output, proof) = execute_and_prove( vec![owner_pre, position_pre, vault_pre, destination_pre], Program::serialize_instruction(instruction).unwrap(), vec![ - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &bob_vpk), - ssk: shared_secret, - nsk: bob_nsk, - identifier: 0, - }, + private_authorized_init_identity(bob_nsk, &alice.vpk, 0), InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index 3a9231a..c39c0cf 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -7,12 +7,14 @@ use nssa::{ privacy_preserving_transaction::{Message, PrivacyPreservingTransaction, WitnessSet}, program::Program, program_deployment_transaction::{self, ProgramDeploymentTransaction}, - public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, + public_transaction, PrivateKey, PublicKey, PublicTransaction, SharedSecretKey, V03State, }; use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, - encryption::ViewingPublicKey, - Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, + encryption::{EphemeralPublicKey, ViewingPublicKey}, + program::PdaSeed, + Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, + NullifierSecretKey, }; use token_core::{TokenDefinition, TokenHolding}; @@ -603,6 +605,75 @@ 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. @@ -2037,3 +2108,96 @@ 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()); +}