mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 14:11:09 +00:00
test(privacy): add privacy-preserving test coverage for token and ata programs
Validates the Q2 privacy features (shield/deshield, private-to-private transfers, existing-account crediting, group-owned accounts, and private PDAs) against the token and ata program flows, which previously ran almost entirely in public context. Adds the key_protocol dependency for GMS-based group-account tests and introduces a docs/privacy-test-matrix.md tracker mapping each program/instruction/ privacy-dimension combination to pass, fail, or not-expressible, with root-cause findings for each gap (notably that private PDAs are structurally unsupported by any program currently deriving addresses via for_public_pda). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5b82a52c6b
commit
bbc8079391
Generated
+3613
-27
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
# Privacy test matrix (Q2 privacy validation)
|
||||
|
||||
Living tracker for the Q2 privacy-feature validation commitment: add privacy-preserving
|
||||
variants of the existing `token` / `ata` / `amm` / `stablecoin` integration test flows, and
|
||||
record which combinations work, fail, or cannot be expressed. Every row starting in
|
||||
**Not started** should end the effort as **Pass** (test merged) or **Fail** /
|
||||
**Not-expressible** (folded into `docs/privacy-gap-report.md` with root cause).
|
||||
|
||||
This is the tracking scaffold, not the final deliverable — `docs/privacy-gap-report.md` gets
|
||||
written from the resolved state of this table.
|
||||
|
||||
## Legend
|
||||
|
||||
**Dimension** — which cross-cutting Q2 feature (or baseline coverage gap) a row exercises:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `BASE` | Extends the already-proven single-private-account pattern (shield / private→private / deshield) to an instruction that has no private coverage yet. Not itself one of the four Q2 checkboxes. |
|
||||
| `PDA` | Private PDAs used as program inputs |
|
||||
| `GROUP` | Sharing a private account (group-owned) used as a program account |
|
||||
| `EXIST` | Sending funds to an existing private account (not a fresh one) |
|
||||
| `CHAIN` | Multiple private accounts in one transaction, and/or a private account carried through a `ChainedCall` |
|
||||
|
||||
**Priority** — `P1` build first, `P2` second wave, `P3` stretch/optional.
|
||||
|
||||
**Status** — `Not started` / `Pass` / `Fail` / `Not-expressible` / `Blocked`.
|
||||
|
||||
**Depends on** — which other deployed program(s) or new crate dependencies the row needs.
|
||||
|
||||
---
|
||||
|
||||
## Token (`token.rs`) — no program dependencies
|
||||
|
||||
Foundation layer. Has no PDAs of its own and issues no `ChainedCall`s, so `PDA` and `CHAIN`
|
||||
don't apply here — it's the substrate the other three programs build on.
|
||||
|
||||
### Cross-cutting checkbox audit (end of Token phase, 2026-07-07)
|
||||
|
||||
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. |
|
||||
| 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. |
|
||||
|
||||
Net: of the 4 checkboxes, Token-phase work directly validated 2 (`EXIST`, `GROUP`), leaned on a
|
||||
pre-existing test for half of a 3rd (`CHAIN`'s multi-account half), and the remaining checkbox
|
||||
(`PDA`) plus the other half of `CHAIN` are structurally out of reach until ATA/AMM/Stablecoin
|
||||
phases — not oversights specific to this phase.
|
||||
|
||||
### Existing
|
||||
|
||||
| Instruction | Dimension | Test | Status |
|
||||
|---|---|---|---|
|
||||
| Transfer | BASE (shield) | `token_shielded_transfer` | Pass |
|
||||
| Transfer | BASE (private→private) | `token_private_transfer` | Pass |
|
||||
| Transfer | BASE (deshield) | `token_deshielded_transfer` | Pass |
|
||||
| Transfer | BASE (authorized variant) | `token_shielded_transfer_authorized_private_init` — fresh recipient self-initializes via `PrivateAuthorizedInit` instead of being passively credited via `PrivateUnauthorized` | Pass |
|
||||
| Mint | BASE | `token_mint_shielded` — mint directly to a fresh private recipient (self-authority signer + `PrivateUnauthorized` recipient) | Pass |
|
||||
| Mint | BASE (authorized variant) | `token_mint_authorized_init` — mint to a fresh recipient that self-initializes via `PrivateAuthorizedInit` (own `nsk` supplied) instead of being passively credited | Pass |
|
||||
| Burn | BASE | `token_private_burn` — burn from an existing private holding via a single `PrivateAuthorizedUpdate` | Pass |
|
||||
| Transfer | `EXIST` | `token_transfer_into_existing_private_holding` — second transfer into an already-shielded recipient | Pass — **with a finding**, see below |
|
||||
| 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 |
|
||||
| Mint | `EXIST` | `token_mint_into_existing_private_holding` — mint once to establish the holding, mint again into it via `PrivateAuthorizedUpdate` | 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)
|
||||
and derives the shared account's npk/vpk via `derive_keys_for_shared_account`; she shields tokens
|
||||
into it. The GMS is then distributed to "Bob" through the real `seal_for`/`unseal` ML-KEM-768
|
||||
handshake — Bob never touches Alice's `GroupKeyHolder` object, only the sealed bytes. Bob
|
||||
independently re-derives the identical nsk/npk from the unsealed GMS and successfully burns from
|
||||
the shared holding using his own derivation. Required adding `key_protocol` as a new git dependency
|
||||
(same repo/tag as `nssa`/`nssa_core`) to `integration_tests/Cargo.toml` — it wasn't previously a
|
||||
dependency of `lez-programs`. Passed on the first attempt; no gap found for this dimension at the
|
||||
Token layer.
|
||||
|
||||
**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
|
||||
(`PrivateAuthorizedUpdate`, `PrivatePdaUpdate`) requires that account's own `nsk` + a membership
|
||||
proof. There is no "blind credit" variant analogous to how any public account can be unilaterally
|
||||
credited — a sender cannot push funds into an existing private account without the recipient
|
||||
actively co-signing (supplying their nsk) in that same transaction. This is a real protocol/UX
|
||||
property, not a bug: worth flagging to the privacy work as the answer to "can you send to an
|
||||
existing private account" being **yes, but only cooperatively**, which has real wallet-UX
|
||||
implications (recipient must be online / pre-coordinate, unlike a public transfer or a fresh
|
||||
shield).
|
||||
|
||||
**Finding (`token_private_transfer_into_existing_private_holding`, confirmed 2026-07-07):**
|
||||
fills the last open combination for Transfer — every prior private test had at most one
|
||||
existing-and-private leg (`token_transfer_into_existing_private_holding`'s recipient) or a
|
||||
fresh second leg (`token_private_transfer`'s recipient), never both legs private *and* the
|
||||
recipient already existing. Two distinct private accounts, each independently proven via its
|
||||
own `PrivateAuthorizedUpdate` (one spending, one crediting an existing balance), compose in a
|
||||
single transaction with no public account anywhere — no signer, no public message ids at all.
|
||||
Passed on the first attempt; built entirely on direct seeding (`with_private_accounts`) for
|
||||
both sides, no real setup transactions needed.
|
||||
|
||||
**Finding (`token_mint_into_existing_private_holding`, confirmed 2026-07-07):** the `EXIST`
|
||||
cooperation requirement generalizes across instructions, not just Transfer. `mint_inner`
|
||||
already supports crediting an existing holding on the public side (branches on
|
||||
`user_holding_account.account == Account::default()`); the private side needs the same
|
||||
`PrivateAuthorizedUpdate` cooperation as Transfer — no instruction-specific escape hatch.
|
||||
Passed on the first attempt once modeled on `token_transfer_into_existing_private_holding`.
|
||||
|
||||
### Planned
|
||||
|
||||
| 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 |
|
||||
|
||||
**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
|
||||
the wrong identity variant, not a real protocol limit. `InitializeAccount`'s guest requires the
|
||||
target to be a *signer* (`#[account(init, signer)]`) — i.e. self-initialization, the same shape as
|
||||
`PrivateAuthorizedInit` (owner supplies their own `nsk` directly, `is_authorized: true` is
|
||||
legitimate), not `PrivateUnauthorized` (third party credits an account they don't control, `nsk`
|
||||
withheld, `is_authorized` must be `false`). Matching the identity variant to the instruction's
|
||||
actual authorization shape resolved it cleanly — passed on the first attempt once corrected.
|
||||
|
||||
**Finding (self-service-only boundary, confirmed 2026-07-07 — prompted by a direct question,
|
||||
not originally in the matrix):** can a third party initialize a private Token holding for an
|
||||
`(npk, vpk, identifier)` whose `nsk` they don't possess? No — and this is a deliberate design
|
||||
boundary, not a gap. Unlike `Transfer`/`Mint`, whose recipient-side host logic never asserts
|
||||
`is_authorized` (which is exactly why third-party shielding into a fresh recipient works there
|
||||
via `PrivateUnauthorized`), `InitializeAccount`'s guest declares `account_to_initialize` as
|
||||
`#[account(init, signer)]`. Attempting it via `PrivateUnauthorized` (`is_authorized: false`,
|
||||
no `nsk` needed) is rejected — empirically confirmed — at the SPEL macro's own account
|
||||
validation layer ("`must be a signer`"), before `token_program::initialize::initialize_account`'s
|
||||
own `is_authorized` assert is even reached. The only variant that can construct a fresh private
|
||||
account here is `PrivateAuthorizedInit`, which requires supplying `nsk` directly. Net: this
|
||||
instruction is self-service-only by construction — you can initialize your own private holding,
|
||||
but not one on someone else's behalf without their key material. Worth carrying into the gap
|
||||
report as a scoping note on `EXIST`/`BASE`, not a defect.
|
||||
|
||||
**Finding (`token_mint_authorized_init`, confirmed 2026-07-07):** the self-service-only
|
||||
boundary above is specific to `InitializeAccount`, not a general rule about "authorized" private
|
||||
identities. `Mint`'s guest marks `user_holding_account` as `#[account(mut)]` only (no
|
||||
`signer`), and `mint_inner` never asserts `is_authorized` on it — confirmed by reading
|
||||
`token/src/mint.rs` before writing the test, then verified empirically. So minting to a
|
||||
recipient that self-initializes via `PrivateAuthorizedInit` (their own `nsk` supplied) works
|
||||
just as well as `token_mint_shielded`'s passive `PrivateUnauthorized` recipient — passed on the
|
||||
first attempt. Worth stating plainly in the gap report: whether a "self-authorized fresh
|
||||
recipient" is accepted is instruction-specific (gated by that instruction's own signer
|
||||
requirement), not a blanket protocol rule.
|
||||
|
||||
**Finding (`token_shielded_transfer_authorized_private_init`, confirmed 2026-07-07):** the same
|
||||
`PrivateAuthorizedInit`-instead-of-`PrivateUnauthorized` variant generalizes to `Transfer` too,
|
||||
closing the last instruction where every fresh-recipient test used only `PrivateUnauthorized`
|
||||
(`token_shielded_transfer`, `token_private_transfer`'s new recipient, the group test's shield
|
||||
step). `transfer.rs` asserts `is_authorized` only on the sender, never the recipient — same
|
||||
shape as `Mint` — so this was expected and passed on the first attempt. Between this and the
|
||||
`Mint`/`InitializeAccount` results, the picture is now complete: whether a fresh recipient can
|
||||
choose to self-initialize (`PrivateAuthorizedInit`) instead of being passively credited
|
||||
(`PrivateUnauthorized`) depends entirely on whether that instruction's guest marks the target
|
||||
as a signer — true for `InitializeAccount` only (where `PrivateUnauthorized` is actually
|
||||
rejected), optional for `Transfer`/`Mint` (both variants accepted).
|
||||
|
||||
---
|
||||
|
||||
## ATA (`ata.rs`) — depends on Token
|
||||
|
||||
### Existing
|
||||
|
||||
| Instruction | Dimension | Test | Status |
|
||||
|---|---|---|---|
|
||||
| Create | BASE (private owner only; ATA account + definition public) | `ata_create_from_private_owner` | Pass |
|
||||
| Create | `PDA` | `ata_create_private_ata_holding_is_not_expressible` | **Not-expressible — confirmed** |
|
||||
|
||||
Verified in `ata/src/create.rs`: the owner account is **not** forwarded into the
|
||||
`ChainedCall` to Token — only `token_definition` and the ATA holding are. So the existing
|
||||
`ata_create_from_private_owner` test proves a private account can seed a PDA derivation and
|
||||
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 (`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
|
||||
precisely: `Create`'s `ChainedCall.pda_seeds` authorizes Token to mutate
|
||||
`for_public_pda(ata_program_id, seed)` — a match under the *public* PDA formula. In
|
||||
`resolve_authorization_and_record_bindings` (`execution_state.rs`), a caller-seed match only
|
||||
gets recorded into `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. Every `PrivatePdaInit`/`PrivatePdaUpdate` identity requires its position to
|
||||
appear in that binding map (hard `assert!` at `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 of
|
||||
two different hash domains. These two requirements are mutually exclusive for the same
|
||||
account_id, full stop — confirmed empirically by attempting exactly this and getting the
|
||||
precise, deterministic rejection (`ata_create_private_ata_holding_is_not_expressible`, which
|
||||
asserts on the exact panic text).
|
||||
**This generalizes**: AMM's vault/pool and Stablecoin's position/vault use the identical
|
||||
`for_public_pda`-only derivation, so they will hit the *same* wall for the *same* reason — no
|
||||
need to rediscover this per program, just confirm each one uses `for_public_pda` (already
|
||||
verified for both in `amm_core`/`stablecoin_core`) and cite this finding. **The only fix** is a
|
||||
source change to `ata_core`/`amm_core`/`stablecoin_core` to derive PDAs via `for_private_pda`
|
||||
instead — out of scope for this test-writing task, but this is the single clearest, most
|
||||
actionable item to feed back to the privacy/protocol work.
|
||||
|
||||
All originally-planned ATA rows are now resolved — see updated `Existing` table below. ATA phase
|
||||
is complete.
|
||||
|
||||
| Instruction | Dimension | Test | Status |
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
|
||||
**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
|
||||
initialized"). That means a *fresh* private recipient (shield-style, `PrivateUnauthorized`) can
|
||||
never be created through `ATA::Transfer` — only an already-existing account can be credited.
|
||||
This collapses what the matrix originally planned as two separate rows (`BASE` and `EXIST`)
|
||||
into one: `ata_transfer_to_existing_private_recipient` funds a private holding via a direct
|
||||
(non-ATA) `Token::Transfer` shield first, then sends more into it through ATA's chained call,
|
||||
with the recipient cooperating via `PrivateAuthorizedUpdate` (consistent with the Token-phase
|
||||
`EXIST` finding). This is also the first test in the whole exercise where a private account
|
||||
identity travels through a *nested* `ChainedCall` rather than a top-level instruction call —
|
||||
and it worked on the first attempt, with no special handling needed.
|
||||
|
||||
**Finding (signer-authorization, confirmed 2026-07-07 — new angle, not in the original matrix):**
|
||||
`Transfer`/`Burn` require `owner` to be a *signer* (`#[account(signer)]`), unlike `Create`
|
||||
(merely `mut`). Every existing private-owner test only used owner passively (`Create`, no
|
||||
signer requirement). `ata_burn_with_private_owner_signing` tests whether a private account can
|
||||
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
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## AMM (`amm.rs`) — depends on Token, TWAP oracle
|
||||
|
||||
33 public tests, 0 private. Confirmed in `amm_core`: all 5 PDAs (config, pool, vault×2,
|
||||
liquidity-token, lp-lock) use `for_public_pda` exclusively.
|
||||
|
||||
Not every account is an equally meaningful privacy target: Pool/Config are the AMM's public
|
||||
price surface (reserves must be readable to quote a swap; TWAP needs a continuously
|
||||
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.
|
||||
|
||||
### Existing
|
||||
|
||||
0 private tests out of 33 public.
|
||||
|
||||
### 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 |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### Existing
|
||||
|
||||
0 private tests out of 2 public.
|
||||
|
||||
### Planned
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 prerequisites (blocking every remaining `GROUP` row)
|
||||
|
||||
- ~~Add `key_protocol` as a git dependency~~ — **done** (2026-07-07), added to
|
||||
`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.
|
||||
|
||||
**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<Item = (Commitment, Nullifier)>)`
|
||||
is a genuine, non-test-gated builder method — pair `Commitment::new(&id, &account)` with
|
||||
`Nullifier::for_account_initialization(&id)` (the same pairing a real `PrivateUnauthorized`/
|
||||
`PrivateAuthorizedInit` would have produced) and the seeded state is indistinguishable from a
|
||||
real one to any subsequent transaction. Confirmed against `lee`'s own test suite pattern before
|
||||
using it, then applied to refactor `token_private_burn`, `token_transfer_into_existing_private_holding`,
|
||||
and `token_mint_into_existing_private_holding`'s setup legs — all still pass. Caveat: seeding
|
||||
skips whatever *public*-side effect the bypassed transaction would have had (sender debit for a
|
||||
shield, supply increase for a mint) — assertions on public state must account for that, matching
|
||||
how public fixtures (`Accounts::holder_init()`) already set balances without a real mint ever
|
||||
having produced them. This will matter more for AMM/Stablecoin, where setup transactions are
|
||||
heavier (chained calls, multiple accounts) than a single shield.
|
||||
|
||||
## Row count summary
|
||||
|
||||
| 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 |
|
||||
| AMM | 0 (2 rows now predicted not-expressible pending confirmation) | 10 | 5 |
|
||||
| Stablecoin | 0 | 6 | 1 |
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
nssa = { workspace = true }
|
||||
nssa_core = { workspace = true, features = ["host", "test_utils"] }
|
||||
clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" }
|
||||
key_protocol = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" }
|
||||
amm_core = { workspace = true }
|
||||
token_core = { workspace = true }
|
||||
ata_core = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ata_core::{compute_ata_seed, get_associated_token_account_id};
|
||||
use key_protocol::key_management::{
|
||||
group_key_holder::{GroupKeyHolder, SealingPublicKey},
|
||||
secret_holders::SecretSpendingKey,
|
||||
};
|
||||
use nssa::{
|
||||
execute_and_prove,
|
||||
privacy_preserving_transaction::{
|
||||
@@ -13,7 +17,7 @@ use nssa::{
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
|
||||
encryption::{EphemeralPublicKey, ViewingPublicKey},
|
||||
EncryptedAccountData, InputAccountIdentity, NullifierPublicKey, NullifierSecretKey,
|
||||
Commitment, EncryptedAccountData, InputAccountIdentity, NullifierPublicKey, NullifierSecretKey,
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
@@ -592,3 +596,537 @@ 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.
|
||||
#[test]
|
||||
fn ata_create_private_ata_holding_is_not_expressible() {
|
||||
let mut state = V03State::new();
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
|
||||
let owner_id = Ids::owner();
|
||||
let owner_account = state.get_account_by_id(owner_id);
|
||||
|
||||
// Fresh personal npk/vpk for the ATA holding's privacy identity — distinct from the
|
||||
// owner's plain public keypair, which only supplies the seed input.
|
||||
let ata_nsk: NullifierSecretKey = [21u8; 32];
|
||||
let ata_npk = NullifierPublicKey::from(&ata_nsk);
|
||||
let ata_vpk = ViewingPublicKey::from_seed(&[41u8; 32], &[42u8; 32]);
|
||||
|
||||
// Address stays the *standard* public-PDA formula ATA always uses — only its state
|
||||
// privacy is under test, not its address derivation.
|
||||
let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition());
|
||||
let ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed);
|
||||
|
||||
let owner_pre = AccountWithMetadata::new(owner_account, true, owner_id);
|
||||
let def_pre = AccountWithMetadata::new(
|
||||
Accounts::token_definition_init(),
|
||||
false,
|
||||
Ids::token_definition(),
|
||||
);
|
||||
let ata_pre = AccountWithMetadata::new(Account::default(), false, ata_id);
|
||||
|
||||
let instruction = ata_core::Instruction::Create {
|
||||
token_program_id: Ids::token_program(),
|
||||
};
|
||||
let instruction_data = Program::serialize_instruction(instruction).unwrap();
|
||||
|
||||
let shared_secret = SharedSecretKey::encapsulate_deterministic(&ata_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 result = execute_and_prove(
|
||||
vec![owner_pre, def_pre, ata_pre],
|
||||
instruction_data,
|
||||
vec![
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::PrivatePdaInit {
|
||||
epk: EphemeralPublicKey(Vec::new()),
|
||||
view_tag: EncryptedAccountData::compute_view_tag(&ata_npk, &ata_vpk),
|
||||
npk: ata_npk,
|
||||
ssk: shared_secret,
|
||||
identifier: 0,
|
||||
seed: None,
|
||||
},
|
||||
],
|
||||
&program_with_deps,
|
||||
);
|
||||
|
||||
let err = result.expect_err(
|
||||
"a private-PDA ATA holding must be rejected: its account id can never satisfy both \
|
||||
ATA's own for_public_pda address check and PrivatePdaInit's for_private_pda binding \
|
||||
requirement simultaneously",
|
||||
);
|
||||
let message = format!("{err:?}");
|
||||
assert!(
|
||||
message.contains("has no proven (seed, npk) binding via Claim::Pda or caller pda_seeds"),
|
||||
"expected the private-PDA binding rejection, got a different error: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
#[test]
|
||||
fn ata_transfer_to_existing_private_recipient() {
|
||||
let mut state = state_for_ata_tests();
|
||||
|
||||
// A throwaway public holder to shield from directly via Token — bypassing ATA entirely,
|
||||
// since ATA::Transfer cannot originate a fresh private recipient (see doc comment above).
|
||||
let shield_source_key = PrivateKey::try_new([77u8; 32]).expect("valid private key");
|
||||
let shield_source_id = AccountId::from(&PublicKey::new_from_private_key(&shield_source_key));
|
||||
state.force_insert_account(
|
||||
shield_source_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,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
);
|
||||
|
||||
let recipient_nsk: NullifierSecretKey = [51u8; 32];
|
||||
let recipient_npk = NullifierPublicKey::from(&recipient_nsk);
|
||||
let recipient_vpk = ViewingPublicKey::from_seed(&[61u8; 32], &[62u8; 32]);
|
||||
let recipient_id = AccountId::for_regular_private_account(&recipient_npk, 0);
|
||||
|
||||
let shield_amount = 500_000_u128;
|
||||
let source_pre = AccountWithMetadata::new(
|
||||
state.get_account_by_id(shield_source_id),
|
||||
true,
|
||||
shield_source_id,
|
||||
);
|
||||
let fresh_recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_id);
|
||||
let shield_secret = SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0;
|
||||
|
||||
let token_program_for_shield = Program::new(token_methods::TOKEN_ELF.to_vec().into())
|
||||
.expect("valid token ELF")
|
||||
.into();
|
||||
let (shield_output, shield_proof) = execute_and_prove(
|
||||
vec![source_pre, fresh_recipient_pre],
|
||||
Program::serialize_instruction(token_core::Instruction::Transfer {
|
||||
amount_to_transfer: shield_amount,
|
||||
})
|
||||
.unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::PrivateUnauthorized {
|
||||
epk: EphemeralPublicKey(Vec::new()),
|
||||
view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk),
|
||||
npk: recipient_npk,
|
||||
ssk: shield_secret,
|
||||
identifier: 0,
|
||||
},
|
||||
],
|
||||
&token_program_for_shield,
|
||||
)
|
||||
.unwrap();
|
||||
let shield_message =
|
||||
Message::try_from_circuit_output(vec![shield_source_id], vec![Nonce(0)], shield_output)
|
||||
.unwrap();
|
||||
let shield_witness =
|
||||
WitnessSet::for_message(&shield_message, shield_proof, &[&shield_source_key]);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(
|
||||
&PrivacyPreservingTransaction::new(shield_message, shield_witness),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let recipient_after_shield = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: shield_amount,
|
||||
}),
|
||||
nonce: Nonce::private_account_nonce_init(&recipient_id),
|
||||
};
|
||||
assert!(
|
||||
state
|
||||
.get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_after_shield))
|
||||
.is_some(),
|
||||
"shield setup must land before the ATA transfer under test"
|
||||
);
|
||||
|
||||
// Now the actual test: owner's ATA sends more into that now-existing private recipient.
|
||||
let owner_id = Ids::owner();
|
||||
let owner_account = state.get_account_by_id(owner_id);
|
||||
let sender_ata_id = Ids::owner_ata();
|
||||
let sender_ata_account = state.get_account_by_id(sender_ata_id);
|
||||
|
||||
let membership_proof = state
|
||||
.get_proof_for_commitment(&Commitment::new(&recipient_id, &recipient_after_shield))
|
||||
.expect("recipient's commitment must be in the set");
|
||||
let transfer_secret =
|
||||
SharedSecretKey::encapsulate_deterministic(&recipient_vpk, &[0u8; 32], 0).0;
|
||||
|
||||
let owner_pre = AccountWithMetadata::new(owner_account, true, owner_id);
|
||||
let sender_ata_pre = AccountWithMetadata::new(sender_ata_account, false, sender_ata_id);
|
||||
let recipient_pre =
|
||||
AccountWithMetadata::new(recipient_after_shield.clone(), true, recipient_id);
|
||||
|
||||
let ata_transfer_amount = 200_000_u128;
|
||||
let instruction = ata_core::Instruction::Transfer {
|
||||
token_program_id: Ids::token_program(),
|
||||
amount: ata_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![
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::PrivateAuthorizedUpdate {
|
||||
epk: EphemeralPublicKey(Vec::new()),
|
||||
view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk),
|
||||
ssk: transfer_secret,
|
||||
nsk: recipient_nsk,
|
||||
membership_proof,
|
||||
identifier: 0,
|
||||
},
|
||||
],
|
||||
&program_with_deps,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message =
|
||||
Message::try_from_circuit_output(vec![owner_id, sender_ata_id], vec![Nonce(0)], output)
|
||||
.unwrap();
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner_key()]);
|
||||
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 - ata_transfer_amount,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
|
||||
let recipient_nonce_after = Nonce::private_account_nonce_init(&recipient_id)
|
||||
.private_account_nonce_increment(&recipient_nsk);
|
||||
let recipient_after_ata_transfer = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: shield_amount + ata_transfer_amount,
|
||||
}),
|
||||
nonce: recipient_nonce_after,
|
||||
};
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(
|
||||
&recipient_id,
|
||||
&recipient_after_ata_transfer
|
||||
))
|
||||
.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.
|
||||
#[test]
|
||||
fn ata_burn_with_private_owner_signing() {
|
||||
let mut state = V03State::new();
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
|
||||
let owner_nsk: NullifierSecretKey = [91u8; 32];
|
||||
let owner_npk = NullifierPublicKey::from(&owner_nsk);
|
||||
let owner_vpk = ViewingPublicKey::from_seed(&[93u8; 32], &[94u8; 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 ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed);
|
||||
let ata_account = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
};
|
||||
state.force_insert_account(ata_id, ata_account.clone());
|
||||
|
||||
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
|
||||
let ata_pre = AccountWithMetadata::new(ata_account, false, ata_id);
|
||||
let def_pre = AccountWithMetadata::new(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
false,
|
||||
Ids::token_definition(),
|
||||
);
|
||||
|
||||
let burn_amount = 300_000_u128;
|
||||
let instruction = ata_core::Instruction::Burn {
|
||||
token_program_id: Ids::token_program(),
|
||||
amount: burn_amount,
|
||||
};
|
||||
|
||||
let shared_secret = SharedSecretKey::encapsulate_deterministic(&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, ata_pre, def_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![ata_id, Ids::token_definition()], vec![], output)
|
||||
.unwrap();
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[]);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(
|
||||
&PrivacyPreservingTransaction::new(message, witness_set),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(ata_id),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128 - burn_amount,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: String::from("Gold"),
|
||||
total_supply: 1_000_000_u128 - burn_amount,
|
||||
metadata_id: None,
|
||||
authority: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
|
||||
let owner_expected = Account {
|
||||
nonce: Nonce::private_account_nonce_init(&owner_id),
|
||||
..Account::default()
|
||||
};
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
// 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.
|
||||
#[test]
|
||||
fn ata_group_owned_owner_signing() {
|
||||
let mut state = V03State::new();
|
||||
deploy_programs(&mut state);
|
||||
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
||||
|
||||
// Alice creates the group and derives the shared owner identity's keys.
|
||||
let alice_holder = GroupKeyHolder::new();
|
||||
let derivation_seed = [13_u8; 32];
|
||||
let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed);
|
||||
let owner_npk = alice_keys.generate_nullifier_public_key();
|
||||
let owner_id = AccountId::for_regular_private_account(&owner_npk, 0);
|
||||
|
||||
// Alice distributes the GMS to Bob via the real seal/unseal handshake.
|
||||
let bob_sealing_keys = SecretSpendingKey([17_u8; 32]).produce_private_key_holder(None);
|
||||
let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key();
|
||||
let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key;
|
||||
let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes(
|
||||
bob_sealing_vpk.to_bytes().to_vec(),
|
||||
));
|
||||
let bob_holder =
|
||||
GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS");
|
||||
|
||||
// Bob independently re-derives the same shared owner keys and is the one who signs below.
|
||||
let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed);
|
||||
let bob_nsk = bob_keys.nullifier_secret_key;
|
||||
let bob_vpk = bob_keys.generate_viewing_public_key();
|
||||
assert_eq!(
|
||||
bob_keys.generate_nullifier_public_key(),
|
||||
owner_npk,
|
||||
"Bob must derive the identical npk as Alice from the shared GMS"
|
||||
);
|
||||
|
||||
// The ATA holding must stay public (per the confirmed PDA finding), so it's seeded
|
||||
// directly rather than via a real `Create` transaction.
|
||||
let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition());
|
||||
let ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed);
|
||||
let ata_account = Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
};
|
||||
state.force_insert_account(ata_id, ata_account.clone());
|
||||
|
||||
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
|
||||
let ata_pre = AccountWithMetadata::new(ata_account, false, ata_id);
|
||||
let def_pre = AccountWithMetadata::new(
|
||||
state.get_account_by_id(Ids::token_definition()),
|
||||
false,
|
||||
Ids::token_definition(),
|
||||
);
|
||||
|
||||
let burn_amount = 300_000_u128;
|
||||
let instruction = ata_core::Instruction::Burn {
|
||||
token_program_id: Ids::token_program(),
|
||||
amount: burn_amount,
|
||||
};
|
||||
|
||||
let shared_secret = SharedSecretKey::encapsulate_deterministic(&bob_vpk, &[0u8; 32], 0).0;
|
||||
|
||||
let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap();
|
||||
let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap();
|
||||
let program_with_deps = ProgramWithDependencies::new(
|
||||
ata_program,
|
||||
HashMap::from([(Ids::token_program(), token_program)]),
|
||||
);
|
||||
|
||||
let (output, proof) = execute_and_prove(
|
||||
vec![owner_pre, ata_pre, def_pre],
|
||||
Program::serialize_instruction(instruction).unwrap(),
|
||||
vec![
|
||||
InputAccountIdentity::PrivateAuthorizedInit {
|
||||
epk: EphemeralPublicKey(Vec::new()),
|
||||
view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &bob_vpk),
|
||||
ssk: shared_secret,
|
||||
nsk: bob_nsk,
|
||||
identifier: 0,
|
||||
},
|
||||
InputAccountIdentity::Public,
|
||||
InputAccountIdentity::Public,
|
||||
],
|
||||
&program_with_deps,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let message =
|
||||
Message::try_from_circuit_output(vec![ata_id, Ids::token_definition()], vec![], output)
|
||||
.unwrap();
|
||||
let witness_set = WitnessSet::for_message(&message, proof, &[]);
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(
|
||||
&PrivacyPreservingTransaction::new(message, witness_set),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.get_account_by_id(ata_id),
|
||||
Account {
|
||||
program_owner: Ids::token_program(),
|
||||
balance: 0_u128,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: Ids::token_definition(),
|
||||
balance: 1_000_000_u128 - burn_amount,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
);
|
||||
|
||||
let owner_expected = Account {
|
||||
nonce: Nonce::private_account_nonce_init(&owner_id),
|
||||
..Account::default()
|
||||
};
|
||||
assert!(state
|
||||
.get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user