mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
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.
This commit is contained in:
+275
-38
@@ -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<Item = (Commitment, Nullifier)>)`
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user