Skip to content

test(privacy): add privacy-preserving coverage, ported to lez v0.2.4 - #350

Open
0x-r4bbit wants to merge 1 commit into
mainfrom
rebase-215
Open

test(privacy): add privacy-preserving coverage, ported to lez v0.2.4#350
0x-r4bbit wants to merge 1 commit into
mainfrom
rebase-215

Conversation

@0x-r4bbit

Copy link
Copy Markdown
Collaborator

Rebase of #215 onto main. Adds privacy-preserving integration-test coverage (token, ata, stablecoin, amm) exercising LEE's private-account variants (REGULAR/EXIST/GROUP/PDA/CHAIN) plus docs/findings.md.

Ported from logos-execution-zone v0.2.0 to the v0.2.4 API that main pins:

  • nssa/nssa_core -> lee/lee_core; clock_core and key_protocol at v0.2.4.
  • PrivateUnauthorized -> PrivateForeignInit; drop epk/ssk/encapsulate_deterministic; identities now carry vpk/random_seed/commitment_root.
  • Fresh private-init pre-states are is_authorized = true (upstream #621).
  • Message::try_from_circuit_output(ids, nonces, out) -> from_circuit_output(nonces, out).
  • AccountId::for_regular_private_account gains the vpk argument.
  • Stablecoin Position debt model (normalized_debt_amount + position_nonce) and amm SwapExact* field sets updated to the current program APIs.

The negative tests the report anticipated #621/#625 would resolve are reconciled with v0.2.4: three are now passing positive tests
(token_initialize_private_account_via_foreign_init, token_new_fungible_definition_foreign_init_holder, amm_new_definition_foreign_init_lp_holder); the amm swap-to-fresh-destination case stays negative but now blocks on AMM's destination-must-exist precondition. docs/findings.md updated accordingly.

Rebase of #215 onto main. Adds privacy-preserving integration-test coverage
(token, ata, stablecoin, amm) exercising LEE's private-account variants
(REGULAR/EXIST/GROUP/PDA/CHAIN) plus docs/findings.md.

Ported from logos-execution-zone v0.2.0 to the v0.2.4 API that main pins:
- nssa/nssa_core -> lee/lee_core; clock_core and key_protocol at v0.2.4.
- PrivateUnauthorized -> PrivateForeignInit; drop epk/ssk/encapsulate_deterministic;
  identities now carry vpk/random_seed/commitment_root.
- Fresh private-init pre-states are is_authorized = true (upstream #621).
- Message::try_from_circuit_output(ids, nonces, out) -> from_circuit_output(nonces, out).
- AccountId::for_regular_private_account gains the vpk argument.
- Stablecoin Position debt model (normalized_debt_amount + position_nonce) and
  amm SwapExact* field sets updated to the current program APIs.

The negative tests the report anticipated #621/#625 would resolve are reconciled
with v0.2.4: three are now passing positive tests
(token_initialize_private_account_via_foreign_init,
token_new_fungible_definition_foreign_init_holder,
amm_new_definition_foreign_init_lp_holder); the amm swap-to-fresh-destination case
stays negative but now blocks on AMM's destination-must-exist precondition.
docs/findings.md updated accordingly.

All 112 integration tests pass (RISC0_DEV_MODE=1); make clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Documentation/comments still use pre-v0.2.4 PrivateUnauthorized terminology and contain a few correctness/clarity issues that should be aligned to PrivateForeignInit and cleaned up before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a substantial suite of privacy-preserving integration tests for LEZ programs (token/ata/stablecoin/amm) against the v0.2.4 lee/lee_core APIs, plus a findings write-up documenting which privacy patterns are expressible vs. blocked.

Changes:

  • Introduces shared helpers for building InputAccountIdentity variants and for group-owned (GMS-based) private-account test setup.
  • Expands integration tests across Token/ATA/Stablecoin/AMM to cover REGULAR/EXIST/GROUP/CHAIN privacy patterns (including several negative “not expressible” cases).
  • Adds docs/findings.md describing outcomes and linking them to upstream LEZ protocol constraints/changes (e.g., PR #621/#625).
File summaries
File Description
programs/integration_tests/tests/token.rs Refactors fixtures and adds many privacy-preserving Token tests (foreign-init, authorized init/update, group-owned flows).
programs/integration_tests/tests/stablecoin.rs Adds privacy-preserving Stablecoin tests (withdraw to private/group destinations, repay from private holdings, and a documented non-expressible case).
programs/integration_tests/tests/ata.rs Adds privacy tests demonstrating private owners/signers, transfers to existing private recipients, and a private-PDA ATA negative test.
programs/integration_tests/tests/amm.rs Adds privacy tests for swaps/liquidity with private holdings, and adjusts clock ownership to satisfy v0.2.4 circuit/output invariants.
programs/integration_tests/src/lib.rs New shared helpers for privacy identities plus group-owner/member key distribution via seal/unseal.
programs/integration_tests/Cargo.toml Adds key_protocol dependency (v0.2.4) to support group-key tests.
docs/findings.md New report summarizing privacy coverage results and open items.
Review details

Suppressed comments (7)

docs/findings.md:30

  • The summary table uses the old PrivateUnauthorized name; for v0.2.4 this should be PrivateForeignInit (formerly PrivateUnauthorized).
| `PrivateUnauthorized`| &#10060; | anyone |

docs/findings.md:71

  • The privacy-coverage matrix still refers to PrivateUnauthorized, but v0.2.4 uses PrivateForeignInit for npk-only initialization.
| EXIST   | private account initialized without `nsk`; `PrivateUnauthorized` | 

docs/findings.md:106

  • This table row still uses the pre-v0.2.4 PrivateUnauthorized name; replace with PrivateForeignInit to match the actual identity variant used by the tests.
| 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 (`PrivateUnauthorized` — `npk`/`vpk` only, no `nsk`) — `Create` never asserts `owner.is_authorized` | ✅ |

docs/findings.md:116

  • Terminology + spacing: PrivateUnauthorized was renamed to PrivateForeignInit in v0.2.4, and the missing space after the backtick makes the sentence harder to read.
- Transfer explicitly blocks `PrivateUnauthorized`and `PrivateAuthorizedInit`. 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.

docs/findings.md:131

  • This Stablecoin remark still refers to PrivateUnauthorized; on v0.2.4 the npk-only initialization variant is PrivateForeignInit.
- `WithdrawCollateral` does not support withdrawals to `PrivateUnauthorized` and `PrivateAuthorizedInit`; explicitly checks that the destination account is not default. Demonstrated with the test `stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible`. **[Open — Programs]**

docs/findings.md:139

  • These Token table rows still use the pre-v0.2.4 PrivateUnauthorized name; use PrivateForeignInit for the npk-only initialization variant.
| 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" | ✅ |

docs/findings.md:145

  • These rows also use PrivateUnauthorized terminology; in v0.2.4 the correct name is PrivateForeignInit.
| 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) | ✅ |
  • Files reviewed: 7/8 changed files
  • Comments generated: 10
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/findings.md

# 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 compatible LEZ programs are with selective privacy.
Comment thread docs/findings.md
Comment on lines +14 to +18
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 unable to spend the resulting private account. E.g., Alice cannot authorize the transaction.
Comment thread docs/findings.md
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 unable to 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).
Comment thread docs/findings.md

## 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`.
Comment thread docs/findings.md
> from the #621 signer check to AMM's own destination-must-already-exist precondition **[Open —
> Programs]**.

Privacy coverage for LEZ program tests is greatly improved from the added tests. Though, there are a few noticable gaps:
Comment thread docs/findings.md
- Pre-#621, `PrivateUnauthorized` accounts were blocked by programs with a check requiring `is_authorized = true`, since a fresh `PrivateUnauthorized` account was always initialized with `is_authorized = false`. This was a `logos-execution-zone` protocol-level issue, not something programs could work around. It has been resolved by [PR 621](https://github.com/logos-blockchain/logos-execution-zone/pull/621) (in v0.2.4): the variant is now `PrivateForeignInit` and its pre-state is authorized; see the Action items section.
- Privacy transactions have issues with chain calls in which multiple calls affect the same private account. The privacy preserving circuit's `authorized_accounts` bookkeeping is monotonic (once an account is authorized, every later occurrence within the same transaction must also declare it authorized), which rejects some call patterns that are valid on the public-transaction path (E.g. `Stablecoin::OpenPosition`). This is a `logos-execution-zone` protocol level issue. A proposed revision to account updates would mitigate this issue: accounts updated iteratively based on their state diff rather than "full replacement".

Additionally, testing undercovered a "bug" in LEZ:
Comment on lines +773 to +776
/// `WithdrawCollateral` blocks withdraws to private accounts (via private donations);
/// `PrivateUnauthorized` account initialization (e.g., `nsk` is not known) is not permitted
/// due to the assertion in `withdraw_collateral.rs` asserts `destination.account !=
/// Account::default()`
Comment on lines +674 to +675
/// Shielded transaction to a private account using the account's `nsk`.
/// `token_shielded_transfer` only uses the account's `npk`; thus, `PrivateUnauthorized` private.
Comment on lines +854 to +855
/// Mints directly to a new recipient private holding (`PrivateUnauthorized`).
/// The recipient's cooperation is unnecessary; only known of the recipient's `npk`, `vpk`.
Comment on lines +1305 to +1308
/// Initializes a private holding account directly (private account secret keys are known).
/// `InitializeAccount` requires `account_to_initialize` to be authorized. E.g., for private
/// accounts must be `PrivateAuthorizedInit` and not `PrivateUnauthorized`; the account owner
/// must supply their own `nsk`.

@3esmit 3esmit left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two negative fixtures use invalid v0.2.4 PrivateForeignInit pre-states, so current program errors mask a second circuit rejection. Both positive private-input AMM swap tests also carry an unnecessary output-recipient signature.

let position_pre =
AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id);
let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id);
let destination_pre = AccountWithMetadata::new(Account::default(), false, destination_id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep this foreign-init pre-state authorized

private_foreign_init_identity maps to v0.2.4 PrivateForeignInit, whose circuit branch requires pre_state.is_authorized == true. This test currently stops earlier at Destination must be initialized, so false is masked. Once that program guard is fixed, the test will still fail in the privacy circuit instead of exposing the newly supported path. Set this flag to true; the intended Stablecoin error still fires with that valid pre-state.

false,
Ids::token_lp_definition(),
);
let user_a_pre = AccountWithMetadata::new(Account::default(), false, user_a_id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep both foreign-init pre-states authorized
Both slots are paired with private_foreign_init_identity, but v0.2.4 rejects a PrivateForeignInit whose pre-state is unauthorized. The current Token A ownership panic fires first and hides that second failure. If the documented AMM destination guard is removed, this test therefore remains negative for the wrong reason. Set both flags to true; the focused test still reaches the intended AMM ownership error.

Ids::vault_b(),
);
let user_a_pre = AccountWithMetadata::new(user_a_account, true, user_a_id);
let user_b_pre =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exercise the swap without an output-recipient signature
swap_exact_input declares only user_input_holding as signer, but this test authorizes user_b, supplies its nonce/key, and expects a signer nonce bump. That extra witness lets the privacy test pass even if output-recipient cooperation becomes required. Mark user_b unauthorized and use empty public nonces/witness keys; the test passes with that minimal signer set. The same redundant signature appears in amm_swap_exact_output_private_user_holding.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants