Files
lez-programs/docs/findings.md
T
Marvin Jones 0117e5b754 test(privacy): add findings doc, stablecoin negative-destination test, refine ATA/token coverage
Adds ata_group_owned_owner_signing (Burn's missing GROUP variant) and two NewFungibleDefinition
private-initial-holder tests to Token. Removes two experimental PDA-external-seed Token tests
that had no real coverage purpose. Documents the root cause of the AMM privacy-test blocker
(a spel-framework guest-wrapper filter silently drops the clock account before either
transaction validator sees it) with full findings/matrix writeups, and reconciles table gaps
found while cross-checking each program's privacy tests against docs/findings.md.
2026-07-14 17:26:56 -04:00

19 KiB

Privacy coverage in LEZ programs

LEZ programs, ideally, are privacy agnostic. E.g., a program should work the same for public and private accounts. Currently, LEZ program integration tests only cover public accounts. This task, we expand the tests for LEZ programs to determine how adaptable (TODO-probably wrong word) LEZ programs are to selective privacy.

Private account variants in LEE

LEE's private state supports (regular) accounts, PDAs and group owned accounts.

Overview of (regular) private accounts

Private account initialization

Regular private accounts can be initialized with or without knowledge of the account's nullifier secret key nsk. This results in two initialization "types": PrivateUnauthorized and PrivateAuthorizedInit.

  • PrivateUnauthorized

    A special case for private accounts initialization that uses only public keys npk and vpk. Example: Alice can use Bob's keys (npk, vpk) and an identifier to send Bob a private transaction. Since Alice does not know the corresponding nsk, she is spend the resulting private account. E.g., Alice cannot authorize the transaction.

  • PrivateAuthorizedInit Private account initialized using the account's nsk (and some identifier). This operation cannot be done by the a third-party (an entity that does not possess spending authority of the account).

Private account update (PrivateAuthorizedUpdate)

Private account updates require knowledge of the account's nsk. E.g., Alice cannot update the private account that she initialized for Bob.

Summary

type authorized who can use
PrivateUnauthorized anyone
PrivateAuthorizedInit owner
PrivateAuthorizedUpdate owner

Only the account owner can (1) update their initialized account, and (2) use functions that require authorization with their account.

Remark

  • PrivateUnauthorized initialization is used for account initialization. is_authorized = false is a protection that does not seem crucial. Artifically, blocks some functions.

Private PDA

Private PDAs spending is restrict by a specific program. E.g., an AMM pool has PDAs for liquidity definition and vaults (for Token A and Token B). A program sets is_authorized = true for an account (purported PDA) by checking the correctness of its AccountId.

  • AccountId formulas:
    • Public: hash(prefix || program_id || seed)
    • Private: hash(prefix || program_id || seed || npk || identifier)

The difference in these PDA AccountId formulas prevents programs from being privacy agnostic for PDAs.

Group-shared (multi-party) private accounts

A single private account can be jointly controlled by two or more parties without either one handing over their actual secret key. The mechanism is a Group Master Secret (GMS), distributed via a real seal/unseal handshake (ML-KEM-768), not key reuse:

  1. Alice creates a GroupKeyHolder and derives the shared account's keys (nsk, vsk) from it.
  2. Alice seals the GMS against Bob's sealing public key and hands over only the sealed bytes.
  3. Bob unseals it with his own sealing secret key, then independently re-derives the account's keys from the same seed.

This ensures that any member of the group can execute programs on shared accounts using either PrivateAuthorizedInit or PrivateAuthorizedUpdate. From a program's perspective, shared accounts should behave the same as regular public accounts.

Privacy coverage for LEZ programs objectives

In this task, we extend testing for LEZ programs to cover privacy features:

description
PDA test checks for private PDA functionality.
REGULAR private accounts usage using nsk
EXIST private account initialized without nsk; PrivateUnauthorized
GROUP Shared group account
CHAIN private account used in a chain call

LEZ programs

AMM program (unusual issues)

Function tested Test name Category Description of objective Result
SwapExactInput amm_swap_a_to_b_private_user_holding_is_not_expressible REGULAR, CHAIN Private user_holding_a deposit leg — confirms the circuit-level account-count bug also fires with a real private account (8 vs 7 accounts), not just the all-public control case (confirmed not-expressible — circuit bug)
SwapExactOutput amm_swap_exact_output_private_user_holding_is_not_expressible REGULAR, CHAIN Same confirmation for SwapExactOutput — identical account/chained-call shape to SwapExactInput (8 vs 7 accounts) (confirmed not-expressible — circuit bug)
AddLiquidity amm_add_liquidity_private_lp_holding_is_not_expressible REGULAR, CHAIN Private LP-output holding (user_holding_lp) — same circuit bug (10 vs 9 accounts) (confirmed not-expressible — circuit bug)
AddLiquidity amm_add_liquidity_private_user_holdings_is_not_expressible REGULAR, CHAIN Private deposit legs (user_holding_a + user_holding_b) — same circuit bug (10 vs 9 accounts) (confirmed not-expressible — circuit bug)
RemoveLiquidity amm_remove_liquidity_private_lp_holding_is_not_expressible REGULAR, CHAIN Private LP holding (the account that signs/burns to remove liquidity) — same circuit bug (10 vs 9 accounts) (confirmed not-expressible — circuit bug)
RemoveLiquidity amm_remove_liquidity_private_new_user_holdings_is_not_expressible EXIST, CHAIN Brand-new PrivateUnauthorized token A/B destinations — rejected by a separate, unrelated program-level precondition (destination must already exist) before the circuit bug is even reached (confirmed not-expressible — different reason)

Remarks

  • RemoveLiquidity and Swaps may have issues with PrivateUnauthorized and PrivateAuthorizedInit that match issues detected in Stablecoin; e.g., explicitly requires is_authorized = true and non default accounts.
  • clock account issue: clock is silent dropped during privacy executions.

ATA program

ATA program offers limited usage with private accounts. Private accounts can be used as the owner (or as a recipient to transactions). But, ATA program can only generate public PDAs. The owner account can be public/private/shared and have any program_owner.

Function tested Test name Category Description of objective Result
Create ata_create_from_private_owner REGULAR, EXIST Any third party can bootstrap another owner's ATA using only that owner's public key material (PrivateUnauthorizednpk/vpk only, no nsk) — Create never asserts owner.is_authorized
Create ata_create_private_ata_holding_is_not_expressible PDA Attempts to make the ATA holding itself a private account via PrivatePdaInit/PrivatePdaUpdate — confirms the public-form PDA match ATA authorizes with and the private-form binding those variants require are mutually exclusive for the same account id (confirmed not-expressible)
Create ata_create_from_group_owned_owner GROUP Group-derived owner identity used to create an ATA — weaker than the other GROUP rows: Create never requires owner to prove control. (defensive/symmetry coverage only)
Transfer ata_transfer_to_existing_private_recipient REGULAR Sends more into an already-shielded private recipient through ATA's nested chained call into Token — the first test in the whole exercise proving a private identity survives a chained call at all
Transfer ata_transfer_with_private_owner_signing REGULAR Key discovery: unlike Create (merely mut), Transfer requires owner to be a signer (#[account(signer)]) — a private owner self-initializes and signs in the same transaction via PrivateAuthorizedInit
Transfer ata_transfer_with_group_owned_owner_signing GROUP Group-owned owner (real GMS seal/unseal handshake) signs ATA::Transfer as the required authorizing party
Burn ata_burn_with_private_owner_signing REGULAR Same signer-authorization discovery as ata_transfer_with_private_owner_signing, for Burn
Burn ata_group_owned_owner_signing GROUP Group-owned owner signs ATA::Burn as the required authorizing party

Remarks

  • Transfer explicitly blocks PrivateAuthorized. ATA's transfer checks that the recipient's account is non-default. E.g., ATA can not transfer funds to a third-party's private account.
  • ATA does not permit the creation of private token accounts. E.g., ATA only emits public PDA accounts. This is based on the PDA AccountId formulas used.

Stablecoin program

Function tested Test name Category Description of objective Result
WithdrawCollateral stablecoin_withdraw_collateral_private_destination REGULAR Withdraws collateral through the single Token::Transfer chained call into an already-existing private destination holding
WithdrawCollateral stablecoin_withdraw_collateral_group_owned_destination EXIST, GROUP Same, but the destination holding is group-owned (real GMS seal/unseal handshake)
WithdrawCollateral stablecoin_group_owned_position_owner GROUP The position's owner identity itself (not the destination) is group-derived — proves shared authority over a CDP by withdrawing collateral through it
RepayDebt stablecoin_repay_debt_private_stablecoin_holding REGULAR Burns from a private stablecoin holding through the single Token::Burn chained call
RepayDebt stablecoin_repay_debt_group_owned_stablecoin_holding GROUP Same, group-owned holding

Remarks

  • OpenPosition is blocked for use in privacy transactions due to the chained calls usage. OpenPosition calls Token::InitializeAccount and Token::Transfer for the same vault account which is disallowed behavior in privacy preserving circuit. Demonstrated with test stablecoin_open_position_via_privacy_transaction_is_not_expressible.
  • WithdrawCollateral does not support withdrawals to PrivateAuthorized and PrivateAuthorizedInit; explicitly checks that the destination account is not default. Demonstrated with teh test stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible.
  • Vault is explicitly public PDA by formula requirement.

Token program

Function tested Test name Category Description of objective Result
Transfer token_shielded_transfer EXIST A public sender shields tokens into a fresh private recipient (PrivateUnauthorized — only npk/vpk known, no nsk)
Transfer token_private_transfer REGULAR -> EXIST Two private accounts (sender via PrivateAuthorizedUpdate + fresh recipient via PrivateUnauthorized) compose in a single transaction with no public account at all — fulfills the "multiple private accounts in one tx"
Transfer token_deshielded_transfer REGULAR A private sender (PrivateAuthorizedUpdate) transfers out to a public recipient
Transfer token_shielded_transfer_authorized_private_init REGULAR Fresh recipient self-initializes via PrivateAuthorizedInit (own nsk supplied) instead of being passively credited via PrivateUnauthorized
Transfer token_transfer_into_existing_private_holding REGULAR Similar to token_shielded_transfer_authorized_private_init, but this shielded transaction does not initialize the private account. Second transfer into an already-shielded recipient — confirms crediting an existing private account requires the recipient's own cooperation (nsk), not just their public key
Transfer token_private_transfer_into_existing_private_holding REGULAR -> REGULAR Both legs private (sender + recipient) in one transaction, and the recipient is already existing rather than fresh
Transfer token_group_owned_holding_shared_control_transfer GROUP -> EXIST Group-owned sender (real GMS seal/unseal handshake) spends outward via Transfer to a fresh private recipient (PrivateUnauthorized)
Mint token_mint_private_unauthorized EXIST Mint directly to a fresh private recipient (self-authority signer + PrivateUnauthorized recipient)
Mint token_mint_authorized_private_init REGULAR (authorized variant) Mint to a fresh recipient that self-initializes via PrivateAuthorizedInit (own nsk supplied) instead of being passively credited
Mint token_mint_into_existing_private_holding REGULAR Mint once to establish a private holding, mint again into it via PrivateAuthorizedUpdate — crediting an existing private account
Burn token_private_burn REGULAR Burn from an existing private holding via a single PrivateAuthorizedUpdate
Burn token_group_owned_holding_shared_control_burn GROUP Shield tokens into a GMS-derived shared holding, then burn from it using an independently re-derived key
InitializeAccount token_initialize_private_account_succeeds_for_canonical_definition REGULAR Self-init of a private holding via PrivateAuthorizedInit
InitializeAccount token_initialize_private_account_without_nsk_is_not_expressible EXIST InitializeAccount's target is #[account(init, signer)] — a third party cannot initialize a private holding via PrivateUnauthorized (no nsk); rejected by the SPEL macro ("must be a signer") before the program's own logic runs (confirmed not-expressible by design)
InitializeAccount token_group_owned_holding_shared_control_initialize GROUP A group member — not the party who created the group — self-initializes the shared holding directly via PrivateAuthorizedInit
MintWithAuthority token_mint_with_authority_to_private_holding EXIST External-authority mint (distinct signer from the definition) directly to a fresh private recipient
NewFungibleDefinition token_new_fungible_definition_private_initial_holder REGULAR Public token definition, private initial holder that self-initializes via PrivateAuthorizedInit (own nsk supplied) — same self-service shape as InitializeAccount's target
NewFungibleDefinition token_new_fungible_definition_private_holder_without_nsk_is_not_expressible EXIST The initial holder cannot be created via PrivateUnauthorized — rejected by the SPEL macro before the program's own logic runs (confirmed not-expressible by design)

Remarks

  • Initialization is not possible for PrivateUnauthorized accounts due to is_authorized = false.
  • New token definition is not permitted for PrivateAuthorized as Token holding due to is_authorized = false.E.g., both Token Definition and Token Holding for a new Token must be from an authorized account.

Conclusions

Privacy coverage for LEZ program tests is greatly improved from the added tests. Though, there are a few noticable gaps:

  • PrivateUnauthorized accounts can be blocked by programs with a check is_authorized = true. However, th

Observations

  • Programs can be made privacy agnostic for PDAs by adjusting private PDA AccountId formula to match the public variant. Unclear how to precisely handle this to ensure AMM program generates unique pools for token pairs (in public PDA case).

TODO

  • Private PDAs used as program inputs across the above flows.

    **Not achieved — structurally blocked, not a test gap.** Every program with PDAs (ATA,
    AMM, Stablecoin) derives them via `for_public_pda(program_id, seed)` only. The private
    formula, `for_private_pda(program_id, seed, npk, identifier)`, additionally requires an
    `npk` — but none of `ata_core`/`amm_core`/`stablecoin_core`'s seed-computation functions
    accept an `npk` today, so it's never reachable through these programs as coded. Confirmed
    empirically not-expressible for ATA (`ata_create_private_ata_holding_is_not_expressible`);
    the same root cause applies to AMM and Stablecoin (identical `for_public_pda`-only
    pattern, verified directly in their `*_core` crates). Token has no PDAs at all — N/A at
    that layer, not a gap.
    *Re: "could we compose a test program that uses private PDAs with these pre-existing?"* —
    no. None of the four existing programs can be made to produce a `for_private_pda` address
    through a test alone, since the formula choice is hardcoded in their source. Demonstrating
    the mechanism at all would require either changing one of the `*_core` crates to derive via
    `for_private_pda`, or standing up a small purpose-built program whose only job is to
    exercise it — both are source changes, not test-writing. **This is the single most
    actionable item to feed back to the protocol team.**
    
  • Group owned shared private account as input to programs.

  • Sending funds to an existing private account. Achieved, with one real condition: cooperation is required. Confirmed across Token (Transfer, Mint), ATA (Transfer, including through a nested chained call into Token), and Stablecoin (WithdrawCollateral). Every path that touches an existing private account (PrivateAuthorizedUpdate) requires that account's own nsk plus a membership proof, supplied in the same transaction — there is no blind-credit analog to PrivateUnauthorized for existing accounts (only fresh accounts can be credited by a stranger). This isn't partial — it's a clean, fully-confirmed yes with one unavoidable, real-world condition: the recipient must be reachable to supply their nsk (online or pre-coordinated). That's a protocol/wallet-UX property to design around, not a bug or an untested edge.

  • [~] Multiple private accounts in one transaction, and private accounts carried through chained calls. This is two separate sub-objectives with different status — worth splitting: - Multiple private accounts in one tx — Achieved. token_private_transfer (sender + recipient, both private, zero public accounts anywhere) and token_private_transfer_into_existing_private_holding (same, recipient already existing). - Carried through a chained call — Achieved, but only single-hop so far. ata_transfer_to_existing_private_recipient proves a private identity survives one chained call (ATA → Token) — the first test in the whole exercise to prove this works at all. Every private Stablecoin WithdrawCollateral/RepayDebt test also carries a private account through exactly one chained call (Stablecoin → Token). Not yet tested: deeper, multi-hop chaining — an instruction issuing more than one chained call with a private account threaded through it (e.g. AMM's SwapExactInput chains into both Token and the TWAP oracle in one instruction). That case is currently unreachable: AMM is blocked entirely by a separate, privacy-unrelated circuit bug (see the AMM section) before any chaining depth can even be exercised. So: not unclear — genuinely proven for the single-hop case, with the deeper case blocked pending AMM.