From 3eeb5059bb47021fd1c50206e44651d4dc4c8d7a Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Wed, 8 Jul 2026 16:54:31 -0400 Subject: [PATCH] test(privacy): extend Stablecoin/ATA privacy coverage and close Token/ATA gaps Add Stablecoin privacy-preserving tests for WithdrawCollateral and RepayDebt (personal and group-owned variants), plus a regression test confirming OpenPosition is incompatible with the privacy circuit (chained-call re-authorization). Close the last planned Token row (MintWithAuthority to a private holding) and the ATA owner-signer gap for Transfer (personal and group-owned), plus a defensive Create/group-owner test. Extract shared privacy-test helpers (identity builders, GroupOwner seal/unseal handshake) into integration_tests/src/lib.rs and use them throughout token.rs, collapsing duplicated InputAccountIdentity/account construction. Update docs/privacy-test-matrix.md with all new findings. --- docs/privacy-test-matrix.md | 313 +++++- programs/integration_tests/src/lib.rs | 120 +++ programs/integration_tests/tests/ata.rs | 348 ++++++- .../integration_tests/tests/stablecoin.rs | 964 +++++++++++++++++- programs/integration_tests/tests/token.rs | 948 +++++++---------- 5 files changed, 2030 insertions(+), 663 deletions(-) diff --git a/docs/privacy-test-matrix.md b/docs/privacy-test-matrix.md index da24050..c5d2faf 100644 --- a/docs/privacy-test-matrix.md +++ b/docs/privacy-test-matrix.md @@ -9,6 +9,42 @@ record which combinations work, fail, or cannot be expressed. Every row starting This is the tracking scaffold, not the final deliverable — `docs/privacy-gap-report.md` gets written from the resolved state of this table. +## Key findings so far (highest priority — read this before anything else) + +1. **`OpenPosition` cannot be called via a `PrivacyPreservingTransaction` at all**, for any + reason related to privacy — confirmed with an all-public control case (zero private + accounts, still fails identically). `open_position.rs` issues two chained calls that both + reuse `vault`: `Token::InitializeAccount` authorizes it via `pda_seeds`, then + `Token::Transfer` re-declares it `is_authorized: false` on its second occurrence (a + legitimate choice on the public-transaction path, per that file's own comment). The privacy + circuit's `authorized_accounts` bookkeeping is monotonic — once authorized, an account must + stay declared `is_authorized: true` on every later occurrence — so this is rejected with + `"Inconsistent authorization for account {id}"` (`lee_core`'s `execution_state.rs:301`). + Likely fixable by not re-declaring `vault` unauthorized on its second occurrence. See + `stablecoin_open_position_via_privacy_transaction_is_not_expressible` and the Stablecoin + section below for the full writeup. **Single most actionable item for the protocol team.** +2. **Private PDAs are structurally impossible under every program's current derivation** — ATA, + AMM, and Stablecoin all derive PDAs via `for_public_pda` only, which can never satisfy + `PrivatePdaInit`/`PrivatePdaUpdate`'s binding requirement (traced precisely in + `execution_state.rs`; see the ATA section). Fixable only by a source change to + `for_private_pda` in each `*_core` crate. +3. **Sending to an existing private account requires the recipient's cooperation** — no + "blind credit" path exists; confirmed across Token/ATA/Stablecoin instructions. Real + wallet-UX implication, not a bug. +4. **Group-owned (shared) accounts work identically to personal ones** wherever tried — + Transfer, Burn, InitializeAccount, and as the signing `owner` behind a PDA-locked resource + (ATA, Stablecoin) — using the real seal/unseal GMS distribution, not just key reuse. +5. **AMM cannot be privacy-tested at all yet** — a *second*, distinct circuit-level issue + blocks every pool-mutating AMM instruction (`Swap*`, `AddLiquidity`, `RemoveLiquidity`, + `SyncReserves`) from the privacy-preserving transaction type, confirmed with all-public + control tests (zero private accounts, still fails): `"Invalid account_identities length"` + inside `execute_and_prove` itself. Ruled out "two different callee programs" as the cause + (a TWAP-only instruction fails identically to a Token+TWAP one); leading unconfirmed + suspect is AMM's pattern of passing an already-mutated `pool` copy into its chained TWAP + call. Root-causing further requires the Docker-based guest rebuild pipeline (`make + build-programs`), not plain `cargo test` — parked pending that investment. See the AMM + section below for the full bisection trail. + ## Legend **Dimension** — which cross-cutting Q2 feature (or baseline coverage gap) a row exercises: @@ -41,7 +77,7 @@ Checked against the 4 Q2 checkboxes explicitly, not assumed: | Checkbox | Status | Basis | |---|---|---| | Private PDAs used as program inputs | **N/A at this layer** | `token_core` has no `for_public_pda`/`for_private_pda` calls anywhere — Token holdings are addressed by arbitrary `AccountId`, not program-derived. Only testable once wrapped by another program's PDA (ATA/AMM/Stablecoin) — correctly deferred, not a gap in Token coverage. | -| Sharing a private account (group-owned) | **Covered** | `token_group_owned_holding_shared_control` — see finding below. | +| Sharing a private account (group-owned) | **Covered** | `token_group_owned_holding_shared_control_burn`/`_transfer`/`_initialize` — see finding below. | | Sending funds to an existing private account | **Covered** | `token_transfer_into_existing_private_holding` — see finding above. | | Multiple private accounts in one tx / private accounts through chained calls | **Partially covered** | "Multiple private accounts in one tx" half: covered, but by the *pre-existing* `token_private_transfer` (two private legs, zero public), not by anything added this phase — none of the new tests this phase have more than one private leg. "Carried through chained calls" half: N/A at this layer, Token issues no `ChainedCall`s (only ATA/AMM/Stablecoin do); deferred. | @@ -50,6 +86,11 @@ pre-existing test for half of a 3rd (`CHAIN`'s multi-account half), and the rema (`PDA`) plus the other half of `CHAIN` are structurally out of reach until ATA/AMM/Stablecoin phases — not oversights specific to this phase. +**Update (2026-07-08):** the one remaining planned row, `token_mint_with_authority_to_private_holding` +(`BASE`, P3), passed — see the finding under Planned below. It doesn't move any of the 4 +checkboxes above (it's `BASE`, not `PDA`/`GROUP`/`EXIST`/`CHAIN`), but it closes the last open +instruction/private-recipient combination at this layer. **Token phase is now complete.** + ### Existing | Instruction | Dimension | Test | Status | @@ -65,8 +106,11 @@ phases — not oversights specific to this phase. | Transfer | `EXIST` + `CHAIN` (fully private) | `token_private_transfer_into_existing_private_holding` — both legs private, recipient already existing (not fresh); two distinct accounts both via `PrivateAuthorizedUpdate` in one tx | Pass | | InitializeAccount | BASE | `token_initialize_private_account` — self-init of a private holding via `PrivateAuthorizedInit` | Pass | | InitializeAccount | new: self-service-only boundary | `token_initialize_private_account_without_nsk_is_not_expressible` | **Not-expressible — confirmed by design, not a gap** | -| Transfer + Burn | `GROUP` | `token_group_owned_holding_shared_control` — shield into a GMS-derived shared holding, spend from it via an independently-derived key | Pass | +| Burn | `GROUP` | `token_group_owned_holding_shared_control_burn` — shield into a GMS-derived shared holding, burn from it via an independently-derived key | Pass | +| Transfer | `GROUP` | `token_group_owned_holding_shared_control_transfer` — group-owned sender spends outward via Transfer to a fresh private recipient, instead of destroying the funds via Burn | Pass | +| InitializeAccount | `GROUP` | `token_group_owned_holding_shared_control_initialize` — a group member (not the group's creator) self-initializes the shared holding directly via `PrivateAuthorizedInit` | Pass | | Mint | `EXIST` | `token_mint_into_existing_private_holding` — mint once to establish the holding, mint again into it via `PrivateAuthorizedUpdate` | Pass | +| MintWithAuthority | BASE | `token_mint_with_authority_to_private_holding` — external-authority mint (distinct signer from the definition) directly to a fresh private recipient | Pass | **Finding (`GROUP`, confirmed 2026-07-07):** sharing a private account genuinely works, and the test was built to prove *sharing*, not just code reuse: "Alice" creates a `GroupKeyHolder` (fresh GMS) @@ -79,6 +123,17 @@ the shared holding using his own derivation. Required adding `key_protocol` as a dependency of `lez-programs`. Passed on the first attempt; no gap found for this dimension at the Token layer. +**Finding (group-owned spend + self-init, confirmed 2026-07-07):** the `_burn` test only proved +group funds could be *destroyed*; `token_group_owned_holding_shared_control_transfer` closes +that gap by having Bob spend outward via `Transfer` to a fresh private recipient instead — +same seal/unseal rigor, both legs private (group sender via `PrivateAuthorizedUpdate`, fresh +recipient via `PrivateUnauthorized`), no public account anywhere in the transaction. +`token_group_owned_holding_shared_control_initialize` closes the other gap: a group *member* +(not the party who created the group) self-initializing the shared holding directly via +`InitializeAccount`/`PrivateAuthorizedInit`, rather than the holding only ever coming into +existence as a side effect of a shield. Both passed on the first attempt — group-owned +accounts behave identically to personal ones across every instruction tried so far. + **Finding (`EXIST`, confirmed 2026-07-07):** crediting an *existing* private account works, but only if the recipient cooperates in the same transaction. Confirmed directly against `InputAccountIdentity`'s doc comments and `output.rs` in `lee_core`: every variant that touches an existing private account @@ -110,11 +165,29 @@ Passed on the first attempt once modeled on `token_transfer_into_existing_privat ### Planned +All originally-planned Token rows are now resolved (`token_mint_with_authority_to_private_holding` +passed — moved into the `Existing` table above) — Token phase is complete. + | Instruction | Dimension | Test | Priority | Depends on | Status | |---|---|---|---|---|---| -| MintWithAuthority | BASE | `token_mint_with_authority_to_private_holding` | P3 | — | Not started | | NewFungibleDefinition, NewDefinitionWithMetadata, SetAuthority(WithAuthority), PrintNft | — | **Not planned** — these operate on canonical, publicly-resolvable definitions/authorities; a "private token definition" has no coherent meaning since holders/traders must resolve it | — | — | Out of scope | +**Finding (`token_mint_with_authority_to_private_holding`, confirmed 2026-07-08):** closes the +last open Token combination — external-authority minting (`MintWithAuthority`, distinct signer +from the definition account) composed with a private recipient. Every prior `MintWithAuthority` +coverage minted to a public holder; every prior private-recipient mint test used self/PDA +authority (plain `Mint`). `mint_inner` never asserts `is_authorized` on `user_holding_account` +regardless of authority mode, so a passive `PrivateUnauthorized` recipient works here exactly as +it does under plain `Mint`. Passed on the first attempt after correcting the `Message` +construction: with two public accounts in the same privacy transaction (`definition`, not a +signer, plus `authority`, the signer), `public_account_ids` must list *both* — in their +`execute_and_prove` input order — for the circuit's public post-states to zip correctly, while +`nonces` lists *only* the signer(s), positionally matched to the witness keys (`signer_account_ids` +is derived from the witness set's public keys, not from `public_account_ids`). This is the first +test in the file with more than one public account alongside a private one, so it's worth +carrying forward: `public_account_ids` (post-state zipping) and `nonces` (signature/nonce +verification) are two independently-sized lists, not one shared list. + **Correction (`token_initialize_private_account`, resolved 2026-07-07):** originally flagged as a plausible `Not-expressible` case because `initialize.rs` hard-asserts `is_authorized == true` while a fresh account created via `PrivateUnauthorized` must be `false`. That flag was based on picking @@ -180,6 +253,19 @@ Verified in `ata/src/create.rs`: the owner account is **not** forwarded into the appear as a top-level tx participant, but does **not** prove a private account traveling through a chained call. That gap is still open despite appearances. +**Finding (third-party bootstrap, confirmed 2026-07-07 — positive finding, not a gap):** +`Create` never asserts `owner.is_authorized`, and the only private identity variant compatible +with an unauthorized owner (`PrivateUnauthorized`) structurally has no `nsk` field at all — it's +built from `npk`/`vpk` alone. So `ata_create_from_private_owner` demonstrates something worth +stating plainly rather than leaving implicit: **any third party can bootstrap another owner's +ATA using only that owner's public key material, without the owner ever exposing (or even +needing to possess yet) their `nsk`.** This mirrors Token's finding that anyone can shield funds +into a fresh private recipient who has never been online — here a wallet provider, faucet, or +counterparty program can pre-create a user's per-token account the same way, purely from public +inputs. The boundary is exactly where signing starts: the moment an instruction needs to *move* +value or prove ongoing control (`Transfer`, `Burn`), `nsk` becomes mandatory — see the +signer-authorization finding below. + **Finding (`PDA`, confirmed 2026-07-07 — root cause, not just an observation):** the ATA holding can never be made a private account as ATA is currently coded, and this is a structural fact provable from `lee_core`'s circuit source, not empirical friction. Traced @@ -212,6 +298,9 @@ is complete. | Transfer | `CHAIN` + `EXIST` (collapsed — see finding) | `ata_transfer_to_existing_private_recipient` | Pass | | Burn | new: signer-authorization | `ata_burn_with_private_owner_signing` | Pass | | Burn | `GROUP` + signer-authorization | `ata_group_owned_owner_signing` | Pass | +| Transfer | new: signer-authorization | `ata_transfer_with_private_owner_signing` | Pass | +| Transfer | `GROUP` + signer-authorization | `ata_transfer_with_group_owned_owner_signing` | Pass | +| Create | `GROUP` (defensive/symmetry only — see finding) | `ata_create_from_group_owned_owner` | Pass | **Finding (`CHAIN` + `EXIST`, confirmed 2026-07-07):** `ata_program::transfer::transfer_from_associated_token_account` hard-asserts `recipient.account != Account::default()` ("Recipient token holding must be @@ -232,11 +321,46 @@ signer requirement). `ata_burn_with_private_owner_signing` tests whether a priva satisfy a signer requirement by self-initializing *and* signing in the same transaction via `PrivateAuthorizedInit` — it does, cleanly, on the first attempt. `ata_group_owned_owner_signing` composes this with `GROUP`: the GMS is distributed through the real seal/unseal handshake (as -in `token_group_owned_holding_shared_control`), and "Bob" — who never touches Alice's +in `token_group_owned_holding_shared_control_burn`), and "Bob" — who never touches Alice's `GroupKeyHolder` object — independently re-derives the matching nsk/npk and signs. Both pass. Worth feeding back as a positive finding: private/shared accounts can serve as full signing authorities for instructions that require it, not just as passive recipients. +**Follow-up (confirmed 2026-07-08 — closing a coverage review gap, not a new dimension):** a +review pass noticed `Burn` had both personal and group-owned signer coverage but `Transfer` +(identical `#[account(signer)]` requirement on `owner`) only had the pre-existing public-owner +test — a private owner had never actually been tried signing `ATA::Transfer`. +`ata_transfer_with_private_owner_signing` / `ata_transfer_with_group_owned_owner_signing` close +that gap directly, mirroring the `Burn` pair exactly (self-init + sign via `PrivateAuthorizedInit`, +personal and group-owned). Both passed on the first attempt, as expected given `Burn`'s identical +shape. Also added `ata_create_from_group_owned_owner` for symmetry — but **this one is a weaker +test by construction, not a gap closure**: `Create` places no signer requirement on `owner` at +all, and its only compatible private identity (`PrivateUnauthorized`) never touches `nsk`, so a +group-derived `owner` is indistinguishable from a personal one at this instruction. The test +confirms that empirically (nothing in `Create` secretly assumes anything about where `npk`/`vpk` +came from) but does **not** demonstrate genuine shared control the way the `Transfer`/`Burn` +group tests do — there is nothing for `Create` to prove sharing over, since it never asks anyone +to prove control of `owner` in the first place. Net: `Create`'s "group ownership" question isn't +an open gap, it's a category mismatch — worth stating that plainly in the gap report rather than +implying it was untested. + +**Finding (ATA cannot originate a fresh private holding, confirmed 2026-07-08 — synthesizes two +separate facts above into one conclusion worth stating plainly): no ATA instruction can bring a +new private token holding into existence, for two independent reasons covering the two accounts +involved.** (1) The ATA's own holding can never be private at all — the confirmed `PDA` finding: +`Create` authorizes it via `for_public_pda` only, which can never satisfy +`PrivatePdaInit`/`PrivatePdaUpdate`'s binding requirement. (2) Even a separate, non-ATA private +recipient can't be freshly created through `ATA::Transfer` — `transfer_from_associated_token_account` +hard-asserts `recipient.account != Account::default()`, rejecting a shield-style fresh +`PrivateUnauthorized` recipient outright; only an *already-existing* recipient can be credited +(per the `CHAIN` + `EXIST` finding above). So ATA can send value *toward* a private destination, +but only one that already exists via some other path — every private holding that appears in +these tests was originated by a direct, non-ATA `Token` call +(`ata_transfer_to_existing_private_recipient`'s setup shields the recipient via `Token::Transfer` +before the ATA transfer under test ever runs). Worth stating as its own line in the gap report: +"ATA cannot emit private token holdings" is a real, structural limitation, not a coverage gap +in the tests written here. + --- ## AMM (`amm.rs`) — depends on Token, TWAP oracle @@ -249,57 +373,166 @@ price surface (reserves must be readable to quote a swap; TWAP needs a continuou observable tick) — privatizing them fights the AMM's purpose. Vault/LP-lock are the credible middle case. User-held token/LP balances are the highest-value target. +### ⚠ Blocked pending investigation (2026-07-08) — read before starting AMM test-writing + +Before writing any private AMM test, an all-public control test through `execute_and_prove` +(the same discipline that found Stablecoin's `OpenPosition` bug) turned up a **second, +distinct circuit-level issue specific to AMM**, unrelated to any privacy dimension. No AMM +privacy tests have been written yet — this needs resolving (or explicitly working around) +first. + +**Symptom**: `SwapExactInput` (8 top-level accounts, 3 chained calls: 2×`Token::Transfer` + +1×`TWAP::UpdateCurrentTick`) fails *inside* `execute_and_prove`, before any private account is +even involved, with `"Invalid account_identities length"` (`lee_core`'s `output.rs:27`) — +`account_identities.len()` (8, what we supply) vs `states_iter.len()` (7, what the circuit +computes). Confirmed with every account `Public`. + +**Bisection done so far**: +- **Ruled out "two different callee programs"**: `SyncReserves` (6 accounts, *one* chained + call, into TWAP oracle only — zero Token calls) fails with the identical pattern (6 vs 5). + So it's not about chaining into two different programs. +- **Ruled out "any multi-account reuse in one chained call"**: Stablecoin's + `WithdrawCollateral` reuses *two* accounts (`vault`, `destination`) inside its single chained + call and works fine — so plain reuse-of-multiple-accounts isn't sufficient on its own to + trigger this. +- **Simplest AMM instruction works**: `UpdateConfig` (2 accounts, zero chained calls) gets + *past* `execute_and_prove` cleanly — it fails later, at `transition_from_privacy_preserving_transaction`, + with `InvalidInput("Empty commitments and empty nullifiers found in message")`. This looks + like an unrelated, general protocol rule (a `PrivacyPreservingTransaction` needs at least one + actual private account, or use `PublicTransaction` instead) — not a bug, but worth noting: + **the "all-public control" methodology needs at least one trivial private leg to get past + this check for future control tests**, not just all-`Public` identities. +- **Leading structural lead, not yet confirmed**: every AMM instruction that hits the length + mismatch passes a *post-update* copy of `pool` (`pool_price_source`, holding `pool_post` — + the already-mutated state, not the original pre-state) into its chained TWAP call. This + "pass what's about to become the post-state as the next call's own pre-state" pattern is + proven correct on the public-transaction path (33 passing tests) but nothing in + Token/ATA/Stablecoin ever exercised it under the privacy circuit. Not yet confirmed as *the* + cause — only the clearest outlier found. + +**Why this wasn't root-caused further**: attempted source-level instrumentation +(`eprintln!` tracing added directly to the pinned `lee_core` checkout's `execution_state.rs`) +to watch the exact bookkeeping live. Confirmed `lee`/`lee_core` genuinely recompiled +(`cargo clean -p lee -p lee_core` + fresh compile logs), but the added prints never +surfaced, while the original panic still fired from the same file/line. This means the actual +executed code path isn't rebuilt by a normal `cargo clean`/`cargo test` cycle — almost +certainly because real guest execution runs a separately cross-compiled RISC-V ELF +(`risc0_build::embed_methods!`), which per this repo's own `CLAUDE.md` needs the Docker-based +`make build-programs` pipeline to rebuild, not plain cargo. Instrumentation was cleanly +reverted (`git status` clean in the checkout; all 52 other tests reconfirmed passing +afterward) rather than sunk further into standing up that Docker toolchain just for tracing. + +**Next step when this is picked back up**: either (a) stand up the guest-rebuild pipeline to +finish the trace, or (b) construct a minimal synthetic instruction (not part of the real AMM +program) that isolates the "post-state passed as next call's pre-state" pattern alone, without +needing to modify any pinned dependency. + ### Existing -0 private tests out of 33 public. +0 private tests out of 33 public. (No private test-writing attempted yet — blocked above.) ### Planned | Instruction | Dimension | Test | Priority | Depends on | Status | |---|---|---|---|---|---| -| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | Not started | -| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | Not started | -| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` | P1 | Token, TWAP oracle (public leg) | Not started | -| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | Not started | -| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | Not started | -| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | Not started | -| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | Not started | -| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started | -| AddLiquidity / RemoveLiquidity | `GROUP` | `amm_group_owned_lp_holding` | P3 | Token, `key_protocol` | Not started | +| SwapExactInput | `CHAIN` | `amm_swap_a_to_b_private_user_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| SwapExactOutput | `CHAIN` | `amm_swap_exact_output_private_user_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| AddLiquidity | `CHAIN` | `amm_add_liquidity_private_user_holdings` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| AddLiquidity | BASE | `amm_add_liquidity_private_lp_holding` — private LP output holding | P1 | Token | **Blocked** — see above | +| RemoveLiquidity | `CHAIN` | `amm_remove_liquidity_private_lp_holding` | P1 | Token, TWAP oracle (public leg) | **Blocked** — see above | +| Swap / AddLiquidity | `EXIST` | `amm_swap_into_existing_private_holding` | P2 | Token | **Blocked** — see above | +| NewDefinition | BASE | `amm_new_definition_private_initial_lp_holder` | P2 | Token | **Blocked** — see above (also issues chained calls reusing `pool`-derived accounts; check on resolution) | +| Swap / AddLiquidity (vault) | `PDA` | `amm_swap_with_private_vault_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `amm_core`); write as a quick confirmation citing that finding, not a fresh investigation | P2 | Token | Not started (also behind the blocker above) | +| AddLiquidity / RemoveLiquidity | `GROUP` | `amm_group_owned_lp_holding` | P3 | Token, `key_protocol` | **Blocked** — see above | | Pool/Config (any) | `PDA` | `amm_attempt_private_pool_pda` — same predicted not-expressible outcome as above; low priority given the vault row already confirms the root cause for this program | P3 | Token | Not started | | Initialize, UpdateConfig, CreatePriceObservations, CreateOraclePriceAccount, SyncReserves | — | **Not planned** — admin/infra instructions over public protocol state; a private admin authority is legitimate but low value | — | — | Out of scope (for now) | Note: every Swap/AddLiquidity/RemoveLiquidity chains to *both* Token (transfers) and TWAP oracle (tick refresh) in one instruction — so every `CHAIN` row above is automatically also a "some legs private, some public" test. Call that out explicitly when the test is written, -not as an incidental detail. +not as an incidental detail. **All of these are currently blocked by the circuit-level issue +above, since it fires with zero private accounts involved — no privacy dimension can be tested +on any pool-mutating AMM instruction until it's resolved.** --- ## Stablecoin (`stablecoin.rs`) — depends on Token -Only 2 tests total today (`stablecoin_open_position_then_withdraw_collateral`, -`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt`), 0 private. Both PDAs -(position, position vault) are `for_public_pda` only. - -Arguably the most naturally privacy-motivated program of the four — a CDP's collateral/debt -is exactly what a user would want hidden — despite having the thinnest existing baseline. +2 pre-existing public tests (`stablecoin_open_position_then_withdraw_collateral`, +`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt`). Both PDAs (position, position +vault) are `for_public_pda` only, per the ATA `PDA` finding. ### Existing -0 private tests out of 2 public. +| Instruction | Dimension | Test | Status | +|---|---|---|---| +| OpenPosition | new: chained-call re-authorization | `stablecoin_open_position_via_privacy_transaction_is_not_expressible` | **Not-expressible — confirmed, root cause traced** | +| WithdrawCollateral | `CHAIN` + `EXIST` | `stablecoin_withdraw_collateral_private_destination` | Pass | +| WithdrawCollateral | `CHAIN` + `EXIST` + `GROUP` | `stablecoin_withdraw_collateral_group_owned_destination` | Pass | +| 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 | -### Planned +**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 +transaction type at all, for any reason related to privacy.** Confirmed with an all-public +control test (every account `Public`, zero private accounts) that fails with the *identical* +error as the private attempt. Root cause traced precisely 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 it 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 file's own +comment: "the recipient is already initialized, so no second PDA claim is needed here") — but +the privacy circuit rejects that as inconsistent. **This means no privacy-preserving test can +ever open a position** — not because of anything about privacy, but because the instruction +itself is incompatible with the privacy transaction machinery as currently coded. Every test +below routes around it by seeding position/vault directly via `force_insert_account` (public +accounts, no real `OpenPosition` call), matching how the pre-existing public +`stablecoin_repay_debt_burns_stablecoins_and_decreases_debt` test already worked before this +phase. This is the single most actionable, most severe finding to feed back to the protocol +team — it blocks privacy for `OpenPosition` categorically, independent of the four Q2 +dimensions, and is likely fixable by having `open_position.rs` mark `post_init_vault` as +authorized (or otherwise not re-declare it unauthorized) on its second occurrence. -| Instruction | Dimension | Test | Priority | Depends on | Status | -|---|---|---|---|---|---| -| OpenPosition | `CHAIN` | `stablecoin_open_position_private_collateral_holding` | P1 | Token | Not started | -| WithdrawCollateral | `CHAIN` | `stablecoin_withdraw_collateral_private_holding` | P1 | Token | Not started | -| RepayDebt | `CHAIN` | `stablecoin_repay_debt_private_holding` | P1 | Token | Not started | -| OpenPosition / Position + Vault | `PDA` | `stablecoin_open_position_private_pda` — predicted **not-expressible** per the ATA `PDA` finding (same `for_public_pda`-only root cause, confirmed in `stablecoin_core`); still worth writing as the clearest real-world case (a CDP position is the most natural thing to want private of anything in this whole exercise), but as a confirmation citing the root cause, not a fresh investigation | P1 (high value as *documentation* of the clearest case, even though the outcome is now predicted) | Token | Not started | -| OpenPosition / WithdrawCollateral | `EXIST` | `stablecoin_deposit_into_existing_private_holding` | P2 | Token | Not started | -| OpenPosition (joint CDP) | `GROUP` | `stablecoin_group_owned_position` | P3 | Token, `key_protocol` | Not started | -| (ProtocolParameters, any) | — | **Not planned** — not yet consumed by any instruction (no freeze/admin logic wired up); nothing to test | — | — | Out of scope | +**Consequence for the `PDA` dimension**: the originally-planned +`stablecoin_open_position_private_pda` confirmation test was dropped as redundant. Position and +vault are *only* ever claimed (via `Claim::Pda` and chained `pda_seeds` respectively) inside +`OpenPosition` — and since that instruction can't reach the privacy circuit at all, the `PDA` +question for Stablecoin can't even be isolated independently; it's subsumed by the finding +above. No separate test needed — the ATA `PDA` finding (same `for_public_pda`-only root cause) +still stands as the citable reference. + +**Finding (`stablecoin_withdraw_collateral_private_destination` / `..._group_owned_destination`, +confirmed 2026-07-08):** unlike `OpenPosition`, `WithdrawCollateral` issues only *one* chained +call (`Token::Transfer`, reusing `vault` exactly once) — it doesn't hit the re-authorization +bug, and passed on the first attempt with a private, pre-existing destination (`EXIST`, +requiring the destination's `PrivateAuthorizedUpdate` cooperation per the Token/ATA-phase +finding) and again with a group-owned destination (real seal/unseal distribution, `GROUP`). + +**Finding (`stablecoin_repay_debt_private_stablecoin_holding` / `..._group_owned_...`, confirmed +2026-07-08):** `RepayDebt` also has only one chained call (`Token::Burn`) and isn't affected by +the `OpenPosition` bug. `user_stablecoin_holding` is notably *not* PDA-locked (unlike ATA's own +holdings) — it's an ordinary user-controlled token holding — so it's free to be private with no +structural obstacle at all. Passed personal and group-owned variants on the first attempt. + +**Finding (`stablecoin_group_owned_position_owner`, confirmed 2026-07-08 — reframes what +"group-owned position" means):** the position/vault themselves can never be private or +group-owned (the `PDA` finding), and can't even be *opened* through the privacy machinery (the +finding above) — but `owner` is just an `AccountId` used for PDA seed derivation and signer +verification, so it doesn't need to be a plain public keypair. Directly mirroring +`ata_group_owned_owner_signing`'s precedent: position/vault are seeded directly (bypassing the +blocked `OpenPosition`), keyed to a group-derived `owner` identity; "Bob" — who only ever +receives the sealed GMS — self-initializes *and* signs that owner identity in one transaction +via `PrivateAuthorizedInit`, then withdraws collateral through it. Passed on the first attempt. +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. + +`ProtocolParameters` remains out of scope — not yet consumed by any instruction (no +freeze/admin logic wired up), nothing to test. --- @@ -309,9 +542,13 @@ is exactly what a user would want hidden — despite having the thinnest existin `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` (shield / spend / - private-PDA fund-spend / group-derive helpers) — still not done. Tests so far (Token and ATA - phases) are still hand-rolled per-file; revisit whether to extract shared helpers before AMM. +- Build the shared privacy test kit in `integration_tests/src/lib.rs` — **partially 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. **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)` @@ -331,7 +568,7 @@ heavier (chained calls, multiple accounts) than a single shield. | Program | Existing private / confirmed | Planned rows | Out-of-scope instructions noted | |---|---|---|---| -| Token | 13 (3 pre-existing + 10 new: 9 pass + 1 confirmed not-expressible by design) | 1 | 5 | -| ATA | 5 (4 pass + 1 confirmed not-expressible — phase complete) | 0 | 0 | +| 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 | 0 | 6 | 1 | +| Stablecoin | 6 (5 pass + 1 confirmed not-expressible — phase complete) | 0 | 1 | diff --git a/programs/integration_tests/src/lib.rs b/programs/integration_tests/src/lib.rs index 8b13789..a96269a 100644 --- a/programs/integration_tests/src/lib.rs +++ b/programs/integration_tests/src/lib.rs @@ -1 +1,121 @@ +//! Shared account/key setup helpers for privacy-preserving integration tests. +use key_protocol::key_management::{ + group_key_holder::{GroupKeyHolder, SealingPublicKey}, + secret_holders::SecretSpendingKey, +}; +use nssa::SharedSecretKey; +use nssa_core::{ + account::AccountId, + encryption::{EphemeralPublicKey, ViewingPublicKey}, + EncryptedAccountData, InputAccountIdentity, MembershipProof, NullifierPublicKey, + NullifierSecretKey, +}; + +/// Builds a `PrivateUnauthorized` identity: a third party credits a fresh private account it +/// does not control (no `nsk`, `is_authorized` must be `false` on the paired pre-state). +pub fn private_unauthorized_identity( + npk: NullifierPublicKey, + vpk: &ViewingPublicKey, + output_index: u32, +) -> InputAccountIdentity { + InputAccountIdentity::PrivateUnauthorized { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk), + npk, + ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0, + identifier: 0, + } +} + +/// Builds a `PrivateAuthorizedInit` identity: the owner self-initializes a fresh private +/// account by supplying its own `nsk` directly (`is_authorized` must be `true`). +pub fn private_authorized_init_identity( + nsk: NullifierSecretKey, + vpk: &ViewingPublicKey, + output_index: u32, +) -> InputAccountIdentity { + let npk = NullifierPublicKey::from(&nsk); + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk), + ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0, + nsk, + identifier: 0, + } +} + +/// Builds a `PrivateAuthorizedUpdate` identity: spends/credits an *existing* private account, +/// requiring its own `nsk` and a membership proof of its current committed state. +pub fn private_authorized_update_identity( + nsk: NullifierSecretKey, + vpk: &ViewingPublicKey, + membership_proof: MembershipProof, + output_index: u32, +) -> InputAccountIdentity { + let npk = NullifierPublicKey::from(&nsk); + InputAccountIdentity::PrivateAuthorizedUpdate { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk), + ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0, + nsk, + membership_proof, + identifier: 0, + } +} + +/// "Alice": creates a shared private account's `GroupKeyHolder` (Group Master Secret) and +/// derives its public identity. The GMS itself never leaves this struct — other parties only +/// ever receive it through [`GroupOwner::admit_member`]'s real seal/unseal ML-KEM-768 handshake, +/// never by handing over key material directly. +pub struct GroupOwner { + holder: GroupKeyHolder, + derivation_seed: [u8; 32], + pub npk: NullifierPublicKey, + pub vpk: ViewingPublicKey, + pub id: AccountId, +} + +impl GroupOwner { + /// Creates the group and derives the shared account's public identity from + /// `derivation_seed`. + #[must_use] + pub fn new(derivation_seed: [u8; 32]) -> Self { + let holder = GroupKeyHolder::new(); + let keys = holder.derive_keys_for_shared_account(&derivation_seed); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let id = AccountId::for_regular_private_account(&npk, 0); + Self { + holder, + derivation_seed, + npk, + vpk, + id, + } + } + + /// "Bob": distributes the GMS to a new member via the real seal/unseal handshake and + /// returns that member's independently re-derived secret key — the member never touches + /// this `GroupOwner`'s `GroupKeyHolder`, only the sealed bytes. + #[must_use] + pub fn admit_member(&self) -> NullifierSecretKey { + let member_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None); + let member_sealing_vpk = member_sealing_keys.generate_viewing_public_key(); + let member_sealing_vsk = member_sealing_keys.viewing_secret_key; + let sealed_gms = self.holder.seal_for(&SealingPublicKey::from_bytes( + member_sealing_vpk.to_bytes().to_vec(), + )); + let member_holder = GroupKeyHolder::unseal(&sealed_gms, &member_sealing_vsk) + .expect("member must unseal the GMS"); + + let member_keys = member_holder.derive_keys_for_shared_account(&self.derivation_seed); + let member_nsk = member_keys.nullifier_secret_key; + assert_eq!( + member_keys.generate_nullifier_public_key(), + self.npk, + "member must derive the identical npk as the group owner from the shared GMS" + ); + member_nsk + } +} diff --git a/programs/integration_tests/tests/ata.rs b/programs/integration_tests/tests/ata.rs index 99df64e..3d35c21 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; 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, @@ -597,21 +600,7 @@ fn ata_create_from_private_owner() { ); } -// Marvin-todo -/// Documents a confirmed protocol gap (`PDA` Q2 dimension): the ATA holding can never be made -/// a private account as ATA is currently coded. `Create`'s `ChainedCall.pda_seeds` authorizes -/// Token to mutate `for_public_pda(ata_program_id, seed)` — a *public*-form PDA match. Per -/// `resolve_authorization_and_record_bindings` in `lee_core`'s `execution_state.rs`, a -/// caller-seed match only gets recorded in `private_pda_bound_positions` when it matches under -/// `for_private_pda` (`is_private_form == true`); a public-form match authorizes the account -/// but never binds it as a private PDA. Since `PrivatePdaInit`/`PrivatePdaUpdate` require their -/// position to appear in that binding map (`execution_state.rs:211`), and ATA's own -/// `verify_ata_and_get_seed` independently requires the account id to equal -/// `for_public_pda(ata_program_id, seed)` (never `for_private_pda`'s output, by construction), -/// these two requirements can never both hold for the same account_id. This is not -/// program-specific friction — it's structural: fixing it would require `ata_core` (and -/// equally amm_core / stablecoin_core) to derive their PDAs via `for_private_pda` instead, -/// which is a source change to the program, not a test workaround. +/// ATA cannot be created as a private account. #[test] fn ata_create_private_ata_holding_is_not_expressible() { let mut state = V03State::new(); @@ -684,22 +673,7 @@ fn ata_create_private_ata_holding_is_not_expressible() { ); } -// Marvin-todo -/// Credits an *already-existing* private holding through ATA's chained call to Token, and -/// documents a structural finding along the way: -/// `ata_program::transfer::transfer_from_associated_token_account` hard-asserts `recipient.account -/// != Account::default()` ("Recipient token holding must be initialized"), so a *fresh* private -/// recipient (shield-style, `PrivateUnauthorized`) can never be created through `ATA::Transfer` — -/// only an existing account can be credited. That collapses what would otherwise be separate `BASE` -/// and `EXIST` tests into one: this test necessarily exercises both "private account through a -/// chained call" (`CHAIN`) and "sending to an existing private account" (`EXIST`, requiring the -/// recipient's cooperation via `PrivateAuthorizedUpdate`, per the finding already confirmed in -/// `token.rs`). -/// -/// The private holding is funded beforehand via a direct (non-ATA) `Token::Transfer` shield -/// from a throwaway public holder, since neither `ATA::Transfer` (blocked by the assert above) -/// nor `Token::Mint` (this test fixture's definition has `authority: None`, fixed supply) can -/// create it. +/// Verifies ATA account can be used to transfer to a private account. #[test] fn ata_transfer_to_existing_private_recipient() { let mut state = state_for_ata_tests(); @@ -878,14 +852,7 @@ fn ata_transfer_to_existing_private_recipient() { .is_some()); } -// Marvin-todo -/// Tests a previously-untried combination: `Burn`'s guest requires `owner` to be a *signer* -/// (`#[account(signer)]`) — every existing private-owner test so far -/// (`ata_create_from_private_owner`) only used owner as a passive `PrivateUnauthorized` recipient -/// in `Create`, which doesn't need signer authorization at all. Here, owner self-initializes *and* -/// signs in the same transaction via `PrivateAuthorizedInit` (proving control by supplying their -/// own nsk directly) — the ATA holding itself stays public, per the confirmed `PDA` finding above; -/// only the signing identity is private. +/// Private account owner can sign transactions. #[test] fn ata_burn_with_private_owner_signing() { let mut state = V03State::new(); @@ -1001,13 +968,8 @@ fn ata_burn_with_private_owner_signing() { .is_some()); } -// Marvin-todo -/// Composes the `GROUP` dimension with the signer-authorization finding just proven above: a -/// group-owned owner (GMS distributed through the real seal/unseal handshake, exactly as in -/// `token_group_owned_holding_shared_control`) signs an `ATA::Burn` via `PrivateAuthorizedInit`. -/// "Bob" — who only ever receives the sealed GMS, never Alice's `GroupKeyHolder` object — -/// independently re-derives the identical nsk/npk and successfully signs for the shared ATA -/// owner identity. +/// 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(); @@ -1130,3 +1092,297 @@ fn ata_group_owned_owner_signing() { .get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected)) .is_some()); } + +/// Private owner +#[test] +fn ata_transfer_with_private_owner_signing() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + state.force_insert_account(Ids::recipient_ata(), Accounts::recipient_ata_init()); + + let owner_nsk: NullifierSecretKey = [95u8; 32]; + let owner_npk = NullifierPublicKey::from(&owner_nsk); + let owner_vpk = ViewingPublicKey::from_seed(&[96u8; 32], &[97u8; 32]); + let owner_id = AccountId::for_regular_private_account(&owner_npk, 0); + + // 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 sender_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + let sender_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(sender_ata_id, sender_ata_account.clone()); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let sender_ata_pre = AccountWithMetadata::new(sender_ata_account, false, sender_ata_id); + let recipient_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::recipient_ata()), + false, + Ids::recipient_ata(), + ); + + let transfer_amount = 400_000_u128; + let instruction = ata_core::Instruction::Transfer { + token_program_id: Ids::token_program(), + amount: transfer_amount, + }; + + let shared_secret = SharedSecretKey::encapsulate_deterministic(&owner_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, sender_ata_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedInit { + epk: EphemeralPublicKey(Vec::new()), + view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &owner_vpk), + ssk: shared_secret, + nsk: owner_nsk, + identifier: 0, + }, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![sender_ata_id, Ids::recipient_ata()], 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(sender_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 - transfer_amount, + }), + nonce: Nonce(0), + } + ); + assert_eq!( + state.get_account_by_id(Ids::recipient_ata()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: transfer_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()); +} + +/// Group transfer is possible with group members added after the ATA is initialized. +#[test] +fn ata_transfer_with_group_owned_owner_signing() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + state.force_insert_account(Ids::recipient_ata(), Accounts::recipient_ata_init()); + + let alice = GroupOwner::new([19_u8; 32]); + let owner_id = alice.id; + + // 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 sender_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + let sender_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(sender_ata_id, sender_ata_account.clone()); + + let bob_nsk = alice.admit_member(); + + let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let sender_ata_pre = AccountWithMetadata::new(sender_ata_account, false, sender_ata_id); + let recipient_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::recipient_ata()), + false, + Ids::recipient_ata(), + ); + + let transfer_amount = 400_000_u128; + let instruction = ata_core::Instruction::Transfer { + token_program_id: Ids::token_program(), + amount: transfer_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, sender_ata_pre, recipient_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![sender_ata_id, Ids::recipient_ata()], 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(sender_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 - transfer_amount, + }), + nonce: Nonce(0), + } + ); + assert_eq!( + state.get_account_by_id(Ids::recipient_ata()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: transfer_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()); +} + +#[test] +fn ata_create_from_group_owned_owner() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); + + let alice = GroupOwner::new([23_u8; 32]); + let owner_id = alice.id; + + let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition()); + let owner_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed); + + let owner_pre = AccountWithMetadata::new(Account::default(), false, owner_id); + let def_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let ata_pre = AccountWithMetadata::new(Account::default(), false, owner_ata_id); + + let instruction = ata_core::Instruction::Create { + token_program_id: Ids::token_program(), + }; + + 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, def_pre, ata_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + private_unauthorized_identity(alice.npk, &alice.vpk, 0), + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![Ids::token_definition(), owner_ata_id], + 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(owner_ata_id), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 0_u128, + }), + nonce: Nonce(0), + } + ); +} diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index d72ad67..165214b 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -1,8 +1,24 @@ +use std::collections::HashMap; + +use key_protocol::key_management::{ + group_key_holder::{GroupKeyHolder, SealingPublicKey}, + secret_holders::SecretSpendingKey, +}; use nssa::{ + execute_and_prove, + privacy_preserving_transaction::{ + circuit::ProgramWithDependencies, 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::{EphemeralPublicKey, ViewingPublicKey}, + Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, + NullifierSecretKey, }; -use nssa_core::account::{Account, AccountId, Data, Nonce}; use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position}; use token_core::{TokenDefinition, TokenHolding}; @@ -10,6 +26,41 @@ struct Keys; struct Ids; struct Balances; struct Accounts; +struct PrivateKeys; + +impl PrivateKeys { + fn destination_nsk() -> NullifierSecretKey { + [111; 32] + } + + fn destination_npk() -> NullifierPublicKey { + NullifierPublicKey::from(&Self::destination_nsk()) + } + + fn destination_vpk() -> ViewingPublicKey { + ViewingPublicKey::from_seed(&[141; 32], &[142; 32]) + } + + fn destination_id() -> AccountId { + AccountId::for_regular_private_account(&Self::destination_npk(), 0) + } + + fn stablecoin_holding_nsk() -> NullifierSecretKey { + [121; 32] + } + + fn stablecoin_holding_npk() -> NullifierPublicKey { + NullifierPublicKey::from(&Self::stablecoin_holding_nsk()) + } + + fn stablecoin_holding_vpk() -> ViewingPublicKey { + ViewingPublicKey::from_seed(&[151; 32], &[152; 32]) + } + + fn stablecoin_holding_id() -> AccountId { + AccountId::for_regular_private_account(&Self::stablecoin_holding_npk(), 0) + } +} impl Keys { fn owner() -> PrivateKey { @@ -398,3 +449,912 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { } } } + +fn stablecoin_program() -> Program { + Program::new(stablecoin_methods::STABLECOIN_ELF.to_vec().into()).expect("valid stablecoin ELF") +} + +fn token_program_instance() -> Program { + Program::new(token_methods::TOKEN_ELF.to_vec().into()).expect("valid token ELF") +} + +fn stablecoin_with_token_deps() -> ProgramWithDependencies { + ProgramWithDependencies::new( + stablecoin_program(), + HashMap::from([(Ids::token_program(), token_program_instance())]), + ) +} + +// 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`). +#[test] +fn stablecoin_open_position_via_privacy_transaction_is_not_expressible() { + let mut state = V03State::new(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account(Ids::user_holding(), Accounts::user_holding_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 owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id); + let position_pre = AccountWithMetadata::new(Account::default(), false, position_id); + let vault_pre = AccountWithMetadata::new(Account::default(), false, vault_id); + let user_holding_pre = + AccountWithMetadata::new(Accounts::user_holding_init(), true, Ids::user_holding()); + let definition_pre = AccountWithMetadata::new( + Accounts::collateral_definition_init(), + false, + Ids::collateral_definition(), + ); + + let collateral_amount = Balances::collateral_deposit(); + let instruction = stablecoin_core::Instruction::OpenPosition { collateral_amount }; + + let result = execute_and_prove( + vec![ + owner_pre, + position_pre, + vault_pre, + user_holding_pre, + definition_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &stablecoin_with_token_deps(), + ); + + let err = result.expect_err( + "OpenPosition must be rejected by the privacy-preserving circuit: vault's second \ + chained-call occurrence declares is_authorized: false after already being marked \ + authorized by the first chained call's pda_seeds match", + ); + let message = format!("{err:?}"); + assert!( + message.contains("Inconsistent authorization for account"), + "expected the authorization-consistency rejection, got a different error: {message}" + ); +} + +// 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`. +#[test] +fn stablecoin_withdraw_collateral_private_destination() { + 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_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; + let destination_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: destination_initial_balance, + }), + nonce: Nonce::private_account_nonce_init(&destination_id), + }; + state = state.with_private_accounts([( + Commitment::new(&destination_id, &destination_account), + Nullifier::for_account_initialization(&destination_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&destination_id, &destination_account)) + .expect("destination's commitment must be in the set"); + + 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(destination_account.clone(), true, destination_id); + + let instruction = stablecoin_core::Instruction::WithdrawCollateral { + 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(), + vec![ + 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, + membership_proof, + identifier: 0, + }, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![owner_id, position_id, vault_id], + vec![Nonce(0)], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!( + position.collateral_amount, + position_collateral - withdraw_amount + ); + assert_eq!(position.debt_amount, 0); + + match TokenHolding::try_from(&state.get_account_by_id(vault_id).data).expect("valid holding") { + TokenHolding::Fungible { balance, .. } => { + assert_eq!(balance, position_collateral - withdraw_amount); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible vault holding") + } + } + + let destination_nonce_after = Nonce::private_account_nonce_init(&destination_id) + .private_account_nonce_increment(&destination_nsk); + let new_destination_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: destination_initial_balance + withdraw_amount, + }), + nonce: destination_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&destination_id, &new_destination_account)) + .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. +#[test] +fn stablecoin_withdraw_collateral_group_owned_destination() { + 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); + + // 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" + ); + + let destination_initial_balance = 100_000_u128; + let destination_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: destination_initial_balance, + }), + nonce: Nonce::private_account_nonce_init(&destination_id), + }; + state = state.with_private_accounts([( + Commitment::new(&destination_id, &destination_account), + Nullifier::for_account_initialization(&destination_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&destination_id, &destination_account)) + .expect("destination's commitment must be in the set"); + + 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(destination_account.clone(), true, destination_id); + + let instruction = stablecoin_core::Instruction::WithdrawCollateral { + 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(), + vec![ + 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, + }, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![owner_id, position_id, vault_id], + vec![Nonce(0)], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!( + position.collateral_amount, + position_collateral - withdraw_amount + ); + + let destination_nonce_after = Nonce::private_account_nonce_init(&destination_id) + .private_account_nonce_increment(&bob_nsk); + let new_destination_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::collateral_definition(), + balance: destination_initial_balance + withdraw_amount, + }), + nonce: destination_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&destination_id, &new_destination_account)) + .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(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account( + Ids::stablecoin_definition(), + Accounts::stablecoin_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 = Balances::collateral_deposit(); + let initial_debt = Balances::initial_debt(); + let repay_amount = Balances::debt_repay_amount(); + + 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: initial_debt, + }), + nonce: Nonce(0), + }; + 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(); + let stablecoin_holding_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: initial_stablecoin_balance, + }), + nonce: Nonce::private_account_nonce_init(&stablecoin_holding_id), + }; + state = state.with_private_accounts([( + Commitment::new(&stablecoin_holding_id, &stablecoin_holding_account), + Nullifier::for_account_initialization(&stablecoin_holding_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new( + &stablecoin_holding_id, + &stablecoin_holding_account, + )) + .expect("stablecoin holding's commitment must be in the set"); + + 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 definition_pre = AccountWithMetadata::new( + Accounts::stablecoin_definition_init(), + false, + Ids::stablecoin_definition(), + ); + let stablecoin_holding_pre = AccountWithMetadata::new( + stablecoin_holding_account.clone(), + true, + stablecoin_holding_id, + ); + + let instruction = stablecoin_core::Instruction::RepayDebt { + 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, + position_pre, + definition_pre, + stablecoin_holding_pre, + ], + Program::serialize_instruction(instruction).unwrap(), + vec![ + 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, + membership_proof, + identifier: 0, + }, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![owner_id, position_id, Ids::stablecoin_definition()], + vec![Nonce(0)], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!(position.debt_amount, initial_debt - repay_amount); + assert_eq!(position.collateral_amount, position_collateral); + + match TokenDefinition::try_from(&state.get_account_by_id(Ids::stablecoin_definition()).data) + .expect("valid TokenDefinition") + { + TokenDefinition::Fungible { total_supply, .. } => { + assert_eq!( + total_supply, + Balances::stablecoin_supply_init() - repay_amount + ); + } + _ => panic!("expected Fungible definition"), + } + + let stablecoin_holding_nonce_after = Nonce::private_account_nonce_init(&stablecoin_holding_id) + .private_account_nonce_increment(&stablecoin_holding_nsk); + let new_stablecoin_holding_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: initial_stablecoin_balance - repay_amount, + }), + nonce: stablecoin_holding_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new( + &stablecoin_holding_id, + &new_stablecoin_holding_account + )) + .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(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account( + Ids::stablecoin_definition(), + Accounts::stablecoin_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 = Balances::collateral_deposit(); + let initial_debt = Balances::initial_debt(); + let repay_amount = Balances::debt_repay_amount(); + + 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: initial_debt, + }), + nonce: Nonce(0), + }; + 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" + ); + + let initial_stablecoin_balance = Balances::user_stablecoin_holding_init(); + let holding_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: initial_stablecoin_balance, + }), + nonce: Nonce::private_account_nonce_init(&holding_id), + }; + state = state.with_private_accounts([( + Commitment::new(&holding_id, &holding_account), + Nullifier::for_account_initialization(&holding_id), + )]); + let membership_proof = state + .get_proof_for_commitment(&Commitment::new(&holding_id, &holding_account)) + .expect("stablecoin holding's commitment must be in the set"); + + 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 definition_pre = AccountWithMetadata::new( + Accounts::stablecoin_definition_init(), + false, + Ids::stablecoin_definition(), + ); + let holding_pre = AccountWithMetadata::new(holding_account.clone(), true, holding_id); + + let instruction = stablecoin_core::Instruction::RepayDebt { + 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(), + vec![ + 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, + }, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![owner_id, position_id, Ids::stablecoin_definition()], + vec![Nonce(0)], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 0, + 0, + ) + .unwrap(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!(position.debt_amount, initial_debt - repay_amount); + + let holding_nonce_after = + Nonce::private_account_nonce_init(&holding_id).private_account_nonce_increment(&bob_nsk); + let new_holding_account = Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: initial_stablecoin_balance - repay_amount, + }), + nonce: holding_nonce_after, + }; + assert!(state + .get_proof_for_commitment(&Commitment::new(&holding_id, &new_holding_account)) + .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(); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + 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" + ); + + // 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 + // directly since OpenPosition can't be routed through the privacy circuit at all. + 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); + + // Bob self-initializes and signs the owner identity in the same transaction, then + // withdraws collateral through it. Destination stays public to isolate what's under test: + // only the owner identity's privacy/sharing, nothing else. + 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(Accounts::user_holding_init(), false, Ids::user_holding()); + + let instruction = stablecoin_core::Instruction::WithdrawCollateral { + 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, + }, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], + &stablecoin_with_token_deps(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![position_id, vault_id, Ids::user_holding()], + 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(); + + let position = + Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position"); + assert_eq!( + position.collateral_amount, + position_collateral - withdraw_amount + ); + + match TokenHolding::try_from(&state.get_account_by_id(Ids::user_holding()).data) + .expect("valid holding") + { + TokenHolding::Fungible { balance, .. } => { + assert_eq!(balance, Balances::user_holding_init() + withdraw_amount); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible destination holding") + } + } + + 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()); +} diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index fe0dc5f..3a9231a 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -1,19 +1,18 @@ -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, 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}, - Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey, - NullifierSecretKey, + encryption::ViewingPublicKey, + Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, }; use token_core::{TokenDefinition, TokenHolding}; @@ -95,27 +94,11 @@ impl Accounts { } fn holder_init() -> 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), - } + Self::token_holding(1_000_000_u128, Nonce(0)) } fn recipient_init() -> Account { - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 0_u128, - }), - nonce: Nonce(0), - } + Self::token_holding(0_u128, Nonce(0)) } fn authority_init() -> Account { @@ -126,6 +109,21 @@ impl Accounts { nonce: Nonce(0), } } + + /// A token holding account for the canonical `Ids::token_definition()`, at the given + /// balance and nonce. Covers every private and public token-holding shape in this file — + /// the `program_owner`/`definition_id` are fixed for this test module. + fn token_holding(balance: u128, nonce: Nonce) -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance, + }), + nonce, + } + } } fn deploy_token(state: &mut V03State) { @@ -199,15 +197,7 @@ fn token_new_fungible_definition() { assert_eq!( state.get_account_by_id(Ids::holder()), - 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(1), - } + Accounts::token_holding(1_000_000_u128, Nonce(1)) ); } @@ -237,15 +227,7 @@ fn token_initialize_account_succeeds_for_canonical_definition() { ); assert_eq!( state.get_account_by_id(Ids::recipient()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 0_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(0_u128, Nonce(1)) ); } @@ -306,28 +288,12 @@ fn token_transfer() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(500_000_u128, Nonce(1)) ); assert_eq!( state.get_account_by_id(Ids::recipient()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(0), - } + Accounts::token_holding(500_000_u128, Nonce(0)) ); } @@ -388,28 +354,12 @@ fn token_transfer_fresh_authorized_public_recipient() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(500_000_u128, Nonce(1)) ); assert_eq!( state.get_account_by_id(Ids::recipient()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(500_000_u128, Nonce(1)) ); } @@ -451,15 +401,7 @@ fn token_burn() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 800_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(800_000_u128, Nonce(1)) ); } @@ -501,15 +443,7 @@ fn token_mint() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_500_000_u128, - }), - nonce: Nonce(0), - } + Accounts::token_holding(1_500_000_u128, Nonce(0)) ); } @@ -623,15 +557,7 @@ fn token_mint_fresh_authorized_public_recipient() { assert_eq!( state.get_account_by_id(Ids::recipient()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 500_000_u128, - }), - nonce: Nonce(1), - } + Accounts::token_holding(500_000_u128, Nonce(1)) ); } @@ -693,10 +619,6 @@ fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account { let sender = AccountWithMetadata::new(sender_account, true, sender_id); let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id); - // Sender encapsulates a shared secret against the recipient's viewing key. The - // circuit fills the real EPK, so we pass an empty placeholder in the identity. - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::Transfer { amount_to_transfer: amount, }; @@ -705,13 +627,7 @@ fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account { Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - npk: recipient_npk, - ssk: shared_secret, - identifier: 0, - }, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), ], &token_program().into(), ) @@ -726,15 +642,7 @@ fn shielded_token_transfer(amount: u128, state: &mut V03State) -> Account { .transition_from_privacy_preserving_transaction(&tx, 0, 0) .unwrap(); - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: amount, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - } + Accounts::token_holding(amount, Nonce::private_account_nonce_init(&recipient_id)) } #[test] @@ -746,15 +654,7 @@ fn token_shielded_transfer() { assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_000_000 - amount, - }), - nonce: Nonce(1), - } + Accounts::token_holding(1_000_000 - amount, Nonce(1)) ); let recipient_commitment = Commitment::new(&PrivateKeys::recipient_id(), &recipient_account); @@ -775,15 +675,12 @@ fn token_shielded_transfer_authorized_private_init() { let sender_nonce = sender_account.nonce; let recipient_nsk = PrivateKeys::recipient_nsk(); - let recipient_npk = PrivateKeys::recipient_npk(); let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::Transfer { amount_to_transfer: amount, }; @@ -792,13 +689,7 @@ fn token_shielded_transfer_authorized_private_init() { Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: shared_secret, - nsk: recipient_nsk, - identifier: 0, - }, + private_authorized_init_identity(recipient_nsk, &recipient_vpk, 0), ], &token_program().into(), ) @@ -815,26 +706,11 @@ fn token_shielded_transfer_authorized_private_init() { assert_eq!( state.get_account_by_id(sender_id), - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_000_000 - amount, - }), - nonce: Nonce(1), - } + Accounts::token_holding(1_000_000 - amount, Nonce(1)) ); - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: amount, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + 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()); @@ -848,7 +724,6 @@ fn token_private_transfer() { // Shield tokens into a private account (becomes the sender for the private transfer). let sender_account = shielded_token_transfer(shielded_amount, &mut state); - let sender_npk = PrivateKeys::recipient_npk(); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); let sender_id = PrivateKeys::recipient_id(); @@ -862,11 +737,6 @@ fn token_private_transfer() { .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in the set"); - // Distinct `output_index` per private output keeps the encapsulated secrets reproducible. - let shared_secret_1 = SharedSecretKey::encapsulate_deterministic(&sender_vpk, &[0u8; 32], 0).0; - let shared_secret_2 = - SharedSecretKey::encapsulate_deterministic(&new_recipient_vpk, &[0u8; 32], 1).0; - let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender_id); let new_recipient_pre = AccountWithMetadata::new(Account::default(), false, new_recipient_id); @@ -877,24 +747,10 @@ fn token_private_transfer() { vec![sender_pre, new_recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk), - ssk: shared_secret_1, - nsk: sender_nsk, - membership_proof, - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &new_recipient_npk, - &new_recipient_vpk, - ), - npk: new_recipient_npk, - ssk: shared_secret_2, - identifier: 0, - }, + // Distinct `output_index` per private output keeps the encapsulated secrets + // reproducible. + private_authorized_update_identity(sender_nsk, &sender_vpk, membership_proof, 0), + private_unauthorized_identity(new_recipient_npk, &new_recipient_vpk, 1), ], &token_program().into(), ) @@ -910,28 +766,16 @@ fn token_private_transfer() { let sender_nonce_after = Nonce::private_account_nonce_init(&sender_id).private_account_nonce_increment(&sender_nsk); - let new_sender_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: shielded_amount - transfer_amount, - }), - nonce: sender_nonce_after, - }; + let new_sender_account = + Accounts::token_holding(shielded_amount - transfer_amount, sender_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account)) .is_some()); - let new_recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: transfer_amount, - }), - nonce: Nonce::private_account_nonce_init(&new_recipient_id), - }; + let new_recipient_account = Accounts::token_holding( + transfer_amount, + Nonce::private_account_nonce_init(&new_recipient_id), + ); assert!(state .get_proof_for_commitment(&Commitment::new(&new_recipient_id, &new_recipient_account)) .is_some()); @@ -945,7 +789,6 @@ fn token_deshielded_transfer() { // Shield tokens into a private account, then deshield some back to a public account. let sender_account = shielded_token_transfer(shielded_amount, &mut state); - let sender_npk = PrivateKeys::recipient_npk(); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); let sender_id = PrivateKeys::recipient_id(); @@ -956,8 +799,6 @@ fn token_deshielded_transfer() { .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in the set"); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&sender_vpk, &[0u8; 32], 0).0; - let public_recipient_pre = AccountWithMetadata::new( state.get_account_by_id(public_recipient_id), false, @@ -972,14 +813,7 @@ fn token_deshielded_transfer() { vec![sender_pre, public_recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk), - ssk: shared_secret, - nsk: sender_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(sender_nsk, &sender_vpk, membership_proof, 0), InputAccountIdentity::Public, ], &token_program().into(), @@ -997,28 +831,13 @@ fn token_deshielded_transfer() { assert_eq!( state.get_account_by_id(public_recipient_id), - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: deshield_amount, - }), - nonce: Nonce(0), - } + Accounts::token_holding(deshield_amount, Nonce(0)) ); let sender_nonce_after = Nonce::private_account_nonce_init(&sender_id).private_account_nonce_increment(&sender_nsk); - let new_sender_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: shielded_amount - deshield_amount, - }), - nonce: sender_nonce_after, - }; + let new_sender_account = + Accounts::token_holding(shielded_amount - deshield_amount, sender_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account)) .is_some()); @@ -1041,21 +860,13 @@ fn token_mint_shielded_to_private_unauthorized() { AccountWithMetadata::new(definition_account, true, Ids::token_definition()); 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::Mint { amount_to_mint }; let (output, proof) = execute_and_prove( vec![definition_pre, recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - npk: recipient_npk, - ssk: shared_secret, - identifier: 0, - }, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), ], &token_program().into(), ) @@ -1089,15 +900,10 @@ fn token_mint_shielded_to_private_unauthorized() { } ); - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: amount_to_mint, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_account = Accounts::token_holding( + amount_to_mint, + Nonce::private_account_nonce_init(&recipient_id), + ); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) .is_some()); @@ -1111,7 +917,6 @@ fn token_mint_authorized_private_init() { let amount_to_mint = 500_000_u128; let recipient_nsk = PrivateKeys::recipient_nsk(); - let recipient_npk = PrivateKeys::recipient_npk(); let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); @@ -1121,21 +926,13 @@ fn token_mint_authorized_private_init() { AccountWithMetadata::new(definition_account, true, Ids::token_definition()); let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::Mint { amount_to_mint }; let (output, proof) = execute_and_prove( vec![definition_pre, recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: shared_secret, - nsk: recipient_nsk, - identifier: 0, - }, + private_authorized_init_identity(recipient_nsk, &recipient_vpk, 0), ], &token_program().into(), ) @@ -1169,15 +966,10 @@ fn token_mint_authorized_private_init() { } ); - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: amount_to_mint, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_account = Accounts::token_holding( + amount_to_mint, + Nonce::private_account_nonce_init(&recipient_id), + ); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) .is_some()); @@ -1192,33 +984,22 @@ fn token_mint_into_existing_private_holding() { let amount_to_mint = 250_000_u128; let recipient_nsk = PrivateKeys::recipient_nsk(); - let recipient_npk = PrivateKeys::recipient_npk(); let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); - let recipient_pre = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: pre_balance, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_pre = Accounts::token_holding( + pre_balance, + Nonce::private_account_nonce_init(&recipient_id), + ); + let recipient_commitment = Commitment::new(&recipient_id, &recipient_pre); state = state.with_private_accounts([( - Commitment::new(&recipient_id, &recipient_pre), + recipient_commitment.clone(), Nullifier::for_account_initialization(&recipient_id), )]); - assert!( - state - .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_pre)) - .is_some(), - "seeded balance must land before the existing-holding mint under test" - ); let membership_proof = state - .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_pre)) - .expect("recipient's commitment must be in the set"); + .get_proof_for_commitment(&recipient_commitment) + .expect("seeded recipient's commitment must be in the set"); let definition_account = state.get_account_by_id(Ids::token_definition()); let definition_nonce = definition_account.nonce; @@ -1227,25 +1008,12 @@ fn token_mint_into_existing_private_holding() { let existing_recipient_pre = AccountWithMetadata::new(recipient_pre.clone(), true, recipient_id); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let (output, second_proof) = execute_and_prove( vec![definition_pre, existing_recipient_pre], - Program::serialize_instruction(token_core::Instruction::Mint { - amount_to_mint, - }) - .unwrap(), + Program::serialize_instruction(token_core::Instruction::Mint { amount_to_mint }).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: shared_secret, - nsk: recipient_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(recipient_nsk, &recipient_vpk, membership_proof, 0), ], &token_program().into(), ) @@ -1257,8 +1025,7 @@ fn token_mint_into_existing_private_holding() { output, ) .unwrap(); - let witness = - WitnessSet::for_message(&message, second_proof, &[&Keys::def_key()]); + let witness = WitnessSet::for_message(&message, second_proof, &[&Keys::def_key()]); state .transition_from_privacy_preserving_transaction( &PrivacyPreservingTransaction::new(message, witness), @@ -1284,15 +1051,8 @@ fn token_mint_into_existing_private_holding() { let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) .private_account_nonce_increment(&recipient_nsk); - let recipient_after_second_mint = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: pre_balance + amount_to_mint, - }), - nonce: recipient_nonce_after, - }; + let recipient_after_second_mint = + Accounts::token_holding(pre_balance + amount_to_mint, recipient_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new( &recipient_id, @@ -1308,21 +1068,15 @@ fn token_private_burn() { let holding_balance = 500_000_u128; let burn_amount = 200_000_u128; - let holder_npk = PrivateKeys::recipient_npk(); let holder_nsk = PrivateKeys::recipient_nsk(); let holder_vpk = PrivateKeys::recipient_vpk(); let holder_id = PrivateKeys::recipient_id(); // Predefined holding account to burn from. - let holder_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: holding_balance, - }), - nonce: Nonce::private_account_nonce_init(&holder_id), - }; + 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(), @@ -1332,8 +1086,6 @@ fn token_private_burn() { .get_proof_for_commitment(&holder_commitment) .expect("holder's commitment must be in the set"); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&holder_vpk, &[0u8; 32], 0).0; - let definition_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::token_definition()), false, @@ -1349,14 +1101,7 @@ fn token_private_burn() { Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&holder_npk, &holder_vpk), - ssk: shared_secret, - nsk: holder_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(holder_nsk, &holder_vpk, membership_proof, 0), ], &token_program().into(), ) @@ -1386,16 +1131,10 @@ fn token_private_burn() { } ); - let new_holder_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: holding_balance - burn_amount, - }), - nonce: Nonce::private_account_nonce_init(&holder_id) - .private_account_nonce_increment(&holder_nsk), - }; + let new_holder_account = Accounts::token_holding( + holding_balance - burn_amount, + Nonce::private_account_nonce_init(&holder_id).private_account_nonce_increment(&holder_nsk), + ); assert!(state .get_proof_for_commitment(&Commitment::new(&holder_id, &new_holder_account)) .is_some()); @@ -1409,20 +1148,14 @@ fn token_transfer_into_existing_private_holding() { let init_balance = 500_000_u128; let second_amount = 100_000_u128; - let recipient_npk = PrivateKeys::recipient_npk(); let recipient_nsk = PrivateKeys::recipient_nsk(); let recipient_vpk = PrivateKeys::recipient_vpk(); let recipient_id = PrivateKeys::recipient_id(); - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: init_balance, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let recipient_account = Accounts::token_holding( + init_balance, + Nonce::private_account_nonce_init(&recipient_id), + ); let recipient_commitment = Commitment::new(&recipient_id, &recipient_account); state = state.with_private_accounts([( recipient_commitment.clone(), @@ -1436,8 +1169,6 @@ fn token_transfer_into_existing_private_holding() { let sender_account = state.get_account_by_id(sender_id); let sender_nonce = sender_account.nonce; - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); let recipient_pre = AccountWithMetadata::new(recipient_account.clone(), true, recipient_id); @@ -1449,14 +1180,7 @@ fn token_transfer_into_existing_private_holding() { Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: shared_secret, - nsk: recipient_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(recipient_nsk, &recipient_vpk, membership_proof, 0), ], &token_program().into(), ) @@ -1473,44 +1197,22 @@ fn token_transfer_into_existing_private_holding() { assert_eq!( state.get_account_by_id(sender_id), - Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - // `first_amount` was seeded directly into the recipient, never debited from - // the sender — only the real transfer (`second_amount`) actually happened. - balance: 1_000_000 - second_amount, - }), - nonce: Nonce(1), - } + // `first_amount` was seeded directly into the recipient, never debited from the + // sender — only the real transfer (`second_amount`) actually happened. + Accounts::token_holding(1_000_000 - second_amount, Nonce(1)) ); let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) .private_account_nonce_increment(&recipient_nsk); - let new_recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: init_balance + second_amount, - }), - nonce: recipient_nonce_after, - }; + let new_recipient_account = + Accounts::token_holding(init_balance + second_amount, recipient_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &new_recipient_account)) .is_some()); } -// Marvin-todo -/// Fully private counterpart to `token_transfer_into_existing_private_holding`: instead of a -/// *public* sender crediting an existing private recipient, both legs are private and the -/// recipient already exists (not fresh, unlike `token_private_transfer`'s new recipient). This -/// is a new combination — two distinct private accounts, both driven by -/// `PrivateAuthorizedUpdate` (spend + credit-existing) in the same transaction — that neither -/// existing test covers. `Token::Transfer` has no definition-account parameter at all, so with -/// both legs private there is no public account anywhere in this transaction: no signer, no -/// public message ids. +/// Private Token transfer into a pre-existing Token holding account. This requires +/// the account's `nsk`; `PrivateAuthorizedUpdate`. #[test] fn token_private_transfer_into_existing_private_holding() { let mut state = state_for_token_tests(); @@ -1518,58 +1220,43 @@ fn token_private_transfer_into_existing_private_holding() { let recipient_initial_balance = 300_000_u128; let transfer_amount = 200_000_u128; - let sender_npk = PrivateKeys::recipient_npk(); let sender_nsk = PrivateKeys::recipient_nsk(); let sender_vpk = PrivateKeys::recipient_vpk(); let sender_id = PrivateKeys::recipient_id(); - let recipient_npk = PrivateKeys::holder_npk(); let recipient_nsk = PrivateKeys::holder_nsk(); let recipient_vpk = PrivateKeys::holder_vpk(); let recipient_id = PrivateKeys::holder_id(); // Seed both sides directly — neither needs a real prior transaction to exist. - let sender_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: sender_initial_balance, - }), - nonce: Nonce::private_account_nonce_init(&sender_id), - }; - let recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: recipient_initial_balance, - }), - nonce: Nonce::private_account_nonce_init(&recipient_id), - }; + let sender_account = Accounts::token_holding( + sender_initial_balance, + Nonce::private_account_nonce_init(&sender_id), + ); + let recipient_account = Accounts::token_holding( + recipient_initial_balance, + Nonce::private_account_nonce_init(&recipient_id), + ); + let sender_commitment = Commitment::new(&sender_id, &sender_account); + let recipient_commitment = Commitment::new(&recipient_id, &recipient_account); state = state.with_private_accounts([ ( - Commitment::new(&sender_id, &sender_account), + sender_commitment.clone(), Nullifier::for_account_initialization(&sender_id), ), ( - Commitment::new(&recipient_id, &recipient_account), + recipient_commitment.clone(), Nullifier::for_account_initialization(&recipient_id), ), ]); let sender_membership_proof = state - .get_proof_for_commitment(&Commitment::new(&sender_id, &sender_account)) + .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in the set"); let recipient_membership_proof = state - .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .get_proof_for_commitment(&recipient_commitment) .expect("recipient's commitment must be in the set"); - let sender_shared_secret = - SharedSecretKey::encapsulate_deterministic(&sender_vpk, &[0u8; 32], 0).0; - let recipient_shared_secret = - SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 1).0; - let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender_id); let recipient_pre = AccountWithMetadata::new(recipient_account.clone(), true, recipient_id); @@ -1580,22 +1267,13 @@ fn token_private_transfer_into_existing_private_holding() { vec![sender_pre, recipient_pre], Program::serialize_instruction(instruction).unwrap(), vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk), - ssk: sender_shared_secret, - nsk: sender_nsk, - membership_proof: sender_membership_proof, - identifier: 0, - }, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - ssk: recipient_shared_secret, - nsk: recipient_nsk, - membership_proof: recipient_membership_proof, - identifier: 0, - }, + private_authorized_update_identity(sender_nsk, &sender_vpk, sender_membership_proof, 0), + private_authorized_update_identity( + recipient_nsk, + &recipient_vpk, + recipient_membership_proof, + 1, + ), ], &token_program().into(), ) @@ -1613,30 +1291,18 @@ fn token_private_transfer_into_existing_private_holding() { let sender_nonce_after = Nonce::private_account_nonce_init(&sender_id).private_account_nonce_increment(&sender_nsk); - let new_sender_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: sender_initial_balance - transfer_amount, - }), - nonce: sender_nonce_after, - }; + let new_sender_account = + Accounts::token_holding(sender_initial_balance - transfer_amount, sender_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account)) .is_some()); let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id) .private_account_nonce_increment(&recipient_nsk); - let new_recipient_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: recipient_initial_balance + transfer_amount, - }), - nonce: recipient_nonce_after, - }; + let new_recipient_account = Accounts::token_holding( + recipient_initial_balance + transfer_amount, + recipient_nonce_after, + ); assert!(state .get_proof_for_commitment(&Commitment::new(&recipient_id, &new_recipient_account)) .is_some()); @@ -1651,7 +1317,6 @@ fn token_initialize_private_account_succeeds_for_canonical_definition() { let mut state = state_for_token_tests_without_recipient(); let owner_nsk = PrivateKeys::recipient_nsk(); - let owner_npk = PrivateKeys::recipient_npk(); let owner_vpk = PrivateKeys::recipient_vpk(); let owner_id = PrivateKeys::recipient_id(); @@ -1662,21 +1327,13 @@ fn token_initialize_private_account_succeeds_for_canonical_definition() { ); let account_to_init_pre = AccountWithMetadata::new(Account::default(), true, owner_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&owner_vpk, &[0u8; 32], 0).0; - let instruction = token_core::Instruction::InitializeAccount; let (output, proof) = execute_and_prove( vec![definition_pre, account_to_init_pre], Program::serialize_instruction(instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &owner_vpk), - ssk: shared_secret, - nsk: owner_nsk, - identifier: 0, - }, + private_authorized_init_identity(owner_nsk, &owner_vpk, 0), ], &token_program().into(), ) @@ -1691,31 +1348,14 @@ fn token_initialize_private_account_succeeds_for_canonical_definition() { .transition_from_privacy_preserving_transaction(&tx, 0, 0) .unwrap(); - let expected_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 0, - }), - nonce: Nonce::private_account_nonce_init(&owner_id), - }; + let expected_account = Accounts::token_holding(0, Nonce::private_account_nonce_init(&owner_id)); assert!(state .get_proof_for_commitment(&Commitment::new(&owner_id, &expected_account)) .is_some()); } -// TODO: think this is unnecessary; double check. -/// Confirms `InitializeAccount` is self-service-only: unlike `Transfer`/`Mint`, whose recipient -/// host logic never asserts `is_authorized`, the guest's `#[account(init, signer)]` on -/// `account_to_initialize` requires `is_authorized == true` — enforced by the SPEL macro's own -/// account validation before `token_program::initialize::initialize_account`'s host logic -/// (which carries the same assert as defense in depth) ever runs. The only private identity -/// variant satisfying that for a fresh account is `PrivateAuthorizedInit`, which requires -/// supplying `nsk` directly — so a third party cannot initialize a private holding on behalf of -/// an `(npk, vpk, identifier)` whose `nsk` they don't possess. Attempting it via -/// `PrivateUnauthorized` (the variant that *would* allow third-party setup elsewhere) is -/// rejected at the framework's signer check, since that variant forces `is_authorized: false`. +/// Confirms that `InitializeAccount` cannot be performed without private account's `nsk`. +/// E.g., account must be `PrivateAuthorizedInit` and not `PrivateUnauthorized`. #[test] fn token_initialize_private_account_without_nsk_is_not_expressible() { let state = state_for_token_tests_without_recipient(); @@ -1731,20 +1371,12 @@ fn token_initialize_private_account_without_nsk_is_not_expressible() { ); let account_to_init_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); - let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0; - let result = execute_and_prove( vec![definition_pre, account_to_init_pre], Program::serialize_instruction(token_core::Instruction::InitializeAccount).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), - npk: recipient_npk, - ssk: shared_secret, - identifier: 0, - }, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), ], &token_program().into(), ); @@ -1760,47 +1392,21 @@ fn token_initialize_private_account_without_nsk_is_not_expressible() { ); } -/// Two independent parties share control of one private Token holding via a `GroupKeyHolder` -/// Group Master Secret (GMS), distributed through the real seal/unseal handshake — not by -/// reusing key material directly — so the test proves actual sharing, not code reuse. "Alice" -/// creates the group and shields tokens into the shared holding; "Bob" only ever receives the -/// *sealed* GMS, independently re-derives the identical nsk/npk from it, and successfully -/// burns from the same holding neither of them personally owns. Validates the `GROUP` Q2 -/// dimension: sharing a private account (group-owned) used as a program account. -/// TODO: add a function for spending +/// 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] fn token_group_owned_holding_shared_control_burn() { let mut state = state_for_token_tests(); let shield_amount = 500_000_u128; let burn_amount = 200_000_u128; - // Alice creates the group and derives the shared account'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 group_npk = alice_keys.generate_nullifier_public_key(); - let group_vpk = alice_keys.generate_viewing_public_key(); - let group_id = AccountId::for_regular_private_account(&group_npk, 0); - - // Alice distributes the GMS to Bob via the real seal/unseal handshake, not by handing - // over key material directly. - 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-account keys from the unsealed 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(), - group_npk, - "Bob must derive the identical npk as Alice from the shared GMS" - ); + // Alice creates the group and derives the shared account'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 group_npk = alice.npk; + let group_vpk = alice.vpk; + let group_id = alice.id; // Alice shields tokens into the group-owned holding (mirrors `shielded_token_transfer`, // parameterized by the group's npk/vpk instead of a personal one). @@ -1810,7 +1416,6 @@ fn token_group_owned_holding_shared_control_burn() { let sender_pre = AccountWithMetadata::new(sender_account, true, sender_id); let group_pre_shield = AccountWithMetadata::new(Account::default(), false, group_id); - let shield_secret = SharedSecretKey::encapsulate_deterministic(&group_vpk, &[0u8; 32], 0).0; let shield_instruction = token_core::Instruction::Transfer { amount_to_transfer: shield_amount, }; @@ -1819,13 +1424,7 @@ fn token_group_owned_holding_shared_control_burn() { Program::serialize_instruction(shield_instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&group_npk, &group_vpk), - npk: group_npk, - ssk: shield_secret, - identifier: 0, - }, + private_unauthorized_identity(group_npk, &group_vpk, 0), ], &token_program().into(), ) @@ -1840,15 +1439,8 @@ fn token_group_owned_holding_shared_control_burn() { .transition_from_privacy_preserving_transaction(&shield_tx, 0, 0) .unwrap(); - let group_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: shield_amount, - }), - nonce: Nonce::private_account_nonce_init(&group_id), - }; + let group_account = + Accounts::token_holding(shield_amount, Nonce::private_account_nonce_init(&group_id)); let group_commitment = Commitment::new(&group_id, &group_account); assert!(state.get_proof_for_commitment(&group_commitment).is_some()); @@ -1857,8 +1449,6 @@ fn token_group_owned_holding_shared_control_burn() { let membership_proof = state .get_proof_for_commitment(&group_commitment) .expect("group holding's commitment must be in the set"); - let burn_shared_secret = - SharedSecretKey::encapsulate_deterministic(&group_vpk, &[0u8; 32], 0).0; let definition_pre = AccountWithMetadata::new( state.get_account_by_id(Ids::token_definition()), @@ -1875,14 +1465,7 @@ fn token_group_owned_holding_shared_control_burn() { Program::serialize_instruction(burn_instruction).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&group_npk, &group_vpk), - ssk: burn_shared_secret, - nsk: bob_nsk, - membership_proof, - identifier: 0, - }, + private_authorized_update_identity(bob_nsk, &group_vpk, membership_proof, 0), ], &token_program().into(), ) @@ -1914,20 +1497,137 @@ fn token_group_owned_holding_shared_control_burn() { let group_nonce_after = Nonce::private_account_nonce_init(&group_id).private_account_nonce_increment(&bob_nsk); - let new_group_account = Account { - program_owner: Ids::token_program(), - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: shield_amount - burn_amount, - }), - nonce: group_nonce_after, - }; + let new_group_account = Accounts::token_holding(shield_amount - burn_amount, group_nonce_after); assert!(state .get_proof_for_commitment(&Commitment::new(&group_id, &new_group_account)) .is_some()); } +/// Two independent parties (Alice and Bob) control a private Token holding (via `GroupKeyHolder`). +/// Alice initializes the private Token account, and Bob transfers tokens from the shared account. +#[test] +fn token_group_owned_holding_shared_control_transfer() { + let mut state = state_for_token_tests(); + let shield_amount = 500_000_u128; + let transfer_amount = 200_000_u128; + + // Alice creates the group and derives the shared account'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 group_vpk = alice.vpk; + let group_id = alice.id; + + let group_account = + Accounts::token_holding(shield_amount, Nonce::private_account_nonce_init(&group_id)); + let group_commitment = Commitment::new(&group_id, &group_account); + state = state.with_private_accounts([( + group_commitment.clone(), + Nullifier::for_account_initialization(&group_id), + )]); + + // Bob spends via Transfer — not Burn — sending to a fresh private recipient. + let membership_proof = state + .get_proof_for_commitment(&group_commitment) + .expect("group holding's commitment must be in the set"); + + let recipient_npk = PrivateKeys::holder_npk(); + let recipient_vpk = PrivateKeys::holder_vpk(); + let recipient_id = PrivateKeys::holder_id(); + + let group_pre = AccountWithMetadata::new(group_account, true, group_id); + let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); + + let instruction = token_core::Instruction::Transfer { + amount_to_transfer: transfer_amount, + }; + let (output, proof) = execute_and_prove( + vec![group_pre, recipient_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + private_authorized_update_identity(bob_nsk, &group_vpk, membership_proof, 0), + private_unauthorized_identity(recipient_npk, &recipient_vpk, 1), + ], + &token_program().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], 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(); + + let group_nonce_after = + Nonce::private_account_nonce_init(&group_id).private_account_nonce_increment(&bob_nsk); + let new_group_account = + Accounts::token_holding(shield_amount - transfer_amount, group_nonce_after); + assert!(state + .get_proof_for_commitment(&Commitment::new(&group_id, &new_group_account)) + .is_some()); + + let new_recipient_account = Accounts::token_holding( + transfer_amount, + Nonce::private_account_nonce_init(&recipient_id), + ); + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &new_recipient_account)) + .is_some()); +} + +/// Two independent parties (Alice and Bob) control a private Token holding (via `GroupKeyHolder`). +/// Alice initializes the private Token account (`InitializeAccount` with `PrivateAuthorizedInit`)/ +#[test] +fn token_group_owned_holding_shared_control_initialize() { + let mut state = state_for_token_tests_without_recipient(); + + // Alice creates the group and derives the shared account'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 group_id = alice.id; + let bob_nsk = alice.admit_member(); + + // Bob — who never created the group — self-initializes the shared holding directly. + let definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let group_pre = AccountWithMetadata::new(Account::default(), true, group_id); + + let instruction = token_core::Instruction::InitializeAccount; + let (output, proof) = execute_and_prove( + vec![definition_pre, group_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + private_authorized_init_identity(bob_nsk, &alice.vpk, 0), + ], + &token_program().into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![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(); + + let expected_account = Accounts::token_holding(0, Nonce::private_account_nonce_init(&group_id)); + assert!(state + .get_proof_for_commitment(&Commitment::new(&group_id, &expected_account)) + .is_some()); +} + #[test] fn token_new_fungible_definition_with_authority() { let mut state = V03State::new(); @@ -2213,15 +1913,7 @@ fn token_rotate_authority_then_new_authority_can_mint() { ); assert_eq!( state.get_account_by_id(Ids::holder()), - Account { - program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenHolding::Fungible { - definition_id: Ids::token_definition(), - balance: 1_500_000_u128, - }), - nonce: Nonce(0), - } + Accounts::token_holding(1_500_000_u128, Nonce(0)) ); // Step 4: OLD authority (def_key self-authority path) must be rejected after rotation. @@ -2243,3 +1935,105 @@ fn token_rotate_authority_then_new_authority_can_mint() { "Old authority must be rejected after rotation" ); } + +#[test] +fn token_mint_with_authority_to_private_holding() { + let mut state = V03State::new(); + deploy_token(&mut state); + + let authority_key: [u8; 32] = Ids::authority() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + + // Create the definition with an external mint authority from the start — the rotation + // dance itself is already covered by `token_rotate_authority_then_new_authority_can_mint`. + let instruction = token_core::Instruction::NewFungibleDefinition { + name: String::from("Gold"), + total_supply: 1_000_000_u128, + mint_authority: Some(AccountId::new(authority_key)), + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::holder()], + vec![Nonce(0), Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::def_key(), &Keys::holder_key()], + ); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + state.force_insert_account(Ids::authority(), Accounts::authority_init()); + + let amount_to_mint = 500_000_u128; + let recipient_npk = PrivateKeys::recipient_npk(); + let recipient_vpk = PrivateKeys::recipient_vpk(); + let recipient_id = PrivateKeys::recipient_id(); + + // Definition is `#[account(mut)]` only under external authority — it does not itself + // authorize the mint, so it goes in as an ordinary (unauthorized) public account. + let definition_pre = AccountWithMetadata::new( + state.get_account_by_id(Ids::token_definition()), + false, + Ids::token_definition(), + ); + let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id); + let authority_account = state.get_account_by_id(Ids::authority()); + let authority_nonce = authority_account.nonce; + let authority_pre = AccountWithMetadata::new(authority_account, true, Ids::authority()); + + let instruction = token_core::Instruction::MintWithAuthority { amount_to_mint }; + let (output, proof) = execute_and_prove( + vec![definition_pre, recipient_pre, authority_pre], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + private_unauthorized_identity(recipient_npk, &recipient_vpk, 0), + InputAccountIdentity::Public, + ], + &token_program().into(), + ) + .unwrap(); + + // `public_account_ids` carries every public account for post-state zipping (definition, + // then authority — their `execute_and_prove` input order); `nonces` carries only the + // signer(s), positionally matched to the witness keys below (just `authority` here). + let message = Message::try_from_circuit_output( + vec![Ids::token_definition(), Ids::authority()], + vec![authority_nonce], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::authority_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 + amount_to_mint, + metadata_id: None, + authority: Some(AccountId::new(authority_key)), + }), + nonce: Nonce(1), + } + ); + + let recipient_account = Accounts::token_holding( + amount_to_mint, + Nonce::private_account_nonce_init(&recipient_id), + ); + assert!(state + .get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_account)) + .is_some()); +}