The spel-framework dispatcher filter that drops the clock account is real, but ValidatedStateDiff::from_public_transaction never checks that a program's output accounts match the caller-declared message.account_ids, unlike the privacy circuit's own account_identities.len() == states_iter.len() check. That absence is why the drop went unnoticed by every pre-existing public AMM test. Update findings.md's conclusion and add a dated correction to privacy-test-matrix.md attributing this second, independent gap to logos-execution-zone.
63 KiB
Privacy test matrix (Q2 privacy validation)
Living tracker for the Q2 privacy-feature validation commitment: add privacy-preserving
variants of the existing token / ata / amm / stablecoin integration test flows, and
record which combinations work, fail, or cannot be expressed. Every row starting in
Not started should end the effort as Pass (test merged) or Fail /
Not-expressible (folded into docs/privacy-gap-report.md with root cause).
This is the tracking scaffold, not the final deliverable — docs/privacy-gap-report.md gets
written from the resolved state of this table.
Key findings so far (highest priority — read this before anything else)
OpenPositioncannot be called via aPrivacyPreservingTransactionat all, for any reason related to privacy — confirmed with an all-public control case (zero private accounts, still fails identically).open_position.rsissues two chained calls that both reusevault:Token::InitializeAccountauthorizes it viapda_seeds, thenToken::Transferre-declares itis_authorized: falseon its second occurrence (a legitimate choice on the public-transaction path, per that file's own comment). The privacy circuit'sauthorized_accountsbookkeeping is monotonic — once authorized, an account must stay declaredis_authorized: trueon every later occurrence — so this is rejected with"Inconsistent authorization for account {id}"(lee_core'sexecution_state.rs:301). Likely fixable by not re-declaringvaultunauthorized on its second occurrence. Seestablecoin_open_position_via_privacy_transaction_is_not_expressibleand the Stablecoin section below for the full writeup. Single most actionable item for the protocol team.- Private PDAs are structurally impossible under every program's current derivation — ATA,
AMM, and Stablecoin all derive PDAs via
for_public_pdaonly, which can never satisfyPrivatePdaInit/PrivatePdaUpdate's binding requirement (traced precisely inexecution_state.rs; see the ATA section). Fixable only by a source change tofor_private_pdain each*_corecrate. - 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.
- Group-owned (shared) accounts work identically to personal ones wherever tried —
Transfer, Burn, InitializeAccount, and as the signing
ownerbehind a PDA-locked resource (ATA, Stablecoin) — using the real seal/unseal GMS distribution, not just key reuse. - 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"insideexecute_and_proveitself. 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-mutatedpoolcopy into its chained TWAP call. Root-causing further requires the Docker-based guest rebuild pipeline (make build-programs), not plaincargo 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:
| Code | Meaning |
|---|---|
BASE |
Extends the already-proven single-private-account pattern (shield / private→private / deshield) to an instruction that has no private coverage yet. Not itself one of the four Q2 checkboxes. |
PDA |
Private PDAs used as program inputs |
GROUP |
Sharing a private account (group-owned) used as a program account |
EXIST |
Sending funds to an existing private account (not a fresh one) |
CHAIN |
Multiple private accounts in one transaction, and/or a private account carried through a ChainedCall |
Priority — P1 build first, P2 second wave, P3 stretch/optional.
Status — Not started / Pass / Fail / Not-expressible / Blocked.
Depends on — which other deployed program(s) or new crate dependencies the row needs.
Token (token.rs) — no program dependencies
Foundation layer. Has no PDAs of its own and issues no ChainedCalls, so PDA and CHAIN
don't apply here — it's the substrate the other three programs build on.
Cross-cutting checkbox audit (end of Token phase, 2026-07-07)
Checked against the 4 Q2 checkboxes explicitly, not assumed:
| Checkbox | Status | Basis |
|---|---|---|
| Private PDAs used as program inputs | N/A at this layer | token_core has no for_public_pda/for_private_pda calls anywhere — Token holdings are addressed by arbitrary AccountId, not program-derived. Only testable once wrapped by another program's PDA (ATA/AMM/Stablecoin) — correctly deferred, not a gap in Token coverage. |
| Sharing a private account (group-owned) | Covered | token_group_owned_holding_shared_control_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 ChainedCalls (only ATA/AMM/Stablecoin do); deferred. |
Net: of the 4 checkboxes, Token-phase work directly validated 2 (EXIST, GROUP), leaned on a
pre-existing test for half of a 3rd (CHAIN's multi-account half), and the remaining checkbox
(PDA) plus the other half of CHAIN are structurally out of reach until ATA/AMM/Stablecoin
phases — not oversights specific to this phase.
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 |
|---|---|---|---|
| Transfer | BASE (shield) | token_shielded_transfer |
Pass |
| Transfer | BASE (private→private) | token_private_transfer |
Pass |
| Transfer | BASE (deshield) | token_deshielded_transfer |
Pass |
| Transfer | BASE (authorized variant) | token_shielded_transfer_authorized_private_init — fresh recipient self-initializes via PrivateAuthorizedInit instead of being passively credited via PrivateUnauthorized |
Pass |
| Mint | BASE | token_mint_shielded — mint directly to a fresh private recipient (self-authority signer + PrivateUnauthorized recipient) |
Pass |
| Mint | BASE (authorized variant) | token_mint_authorized_init — mint to a fresh recipient that self-initializes via PrivateAuthorizedInit (own nsk supplied) instead of being passively credited |
Pass |
| Burn | BASE | token_private_burn — burn from an existing private holding via a single PrivateAuthorizedUpdate |
Pass |
| Transfer | EXIST |
token_transfer_into_existing_private_holding — second transfer into an already-shielded recipient |
Pass — with a finding, see below |
| Transfer | EXIST + CHAIN (fully private) |
token_private_transfer_into_existing_private_holding — both legs private, recipient already existing (not fresh); two distinct accounts both via PrivateAuthorizedUpdate in one tx |
Pass |
| InitializeAccount | BASE | token_initialize_private_account — self-init of a private holding via PrivateAuthorizedInit |
Pass |
| InitializeAccount | new: self-service-only boundary | token_initialize_private_account_without_nsk_is_not_expressible |
Not-expressible — confirmed by design, not a gap |
| 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)
and derives the shared account's npk/vpk via derive_keys_for_shared_account; she shields tokens
into it. The GMS is then distributed to "Bob" through the real seal_for/unseal ML-KEM-768
handshake — Bob never touches Alice's GroupKeyHolder object, only the sealed bytes. Bob
independently re-derives the identical nsk/npk from the unsealed GMS and successfully burns from
the shared holding using his own derivation. Required adding key_protocol as a new git dependency
(same repo/tag as nssa/nssa_core) to integration_tests/Cargo.toml — it wasn't previously a
dependency of lez-programs. Passed on the first attempt; no gap found for this dimension at the
Token layer.
Finding (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
(PrivateAuthorizedUpdate, PrivatePdaUpdate) requires that account's own nsk + a membership
proof. There is no "blind credit" variant analogous to how any public account can be unilaterally
credited — a sender cannot push funds into an existing private account without the recipient
actively co-signing (supplying their nsk) in that same transaction. This is a real protocol/UX
property, not a bug: worth flagging to the privacy work as the answer to "can you send to an
existing private account" being yes, but only cooperatively, which has real wallet-UX
implications (recipient must be online / pre-coordinate, unlike a public transfer or a fresh
shield).
Finding (token_private_transfer_into_existing_private_holding, confirmed 2026-07-07):
fills the last open combination for Transfer — every prior private test had at most one
existing-and-private leg (token_transfer_into_existing_private_holding's recipient) or a
fresh second leg (token_private_transfer's recipient), never both legs private and the
recipient already existing. Two distinct private accounts, each independently proven via its
own PrivateAuthorizedUpdate (one spending, one crediting an existing balance), compose in a
single transaction with no public account anywhere — no signer, no public message ids at all.
Passed on the first attempt; built entirely on direct seeding (with_private_accounts) for
both sides, no real setup transactions needed.
Finding (token_mint_into_existing_private_holding, confirmed 2026-07-07): the EXIST
cooperation requirement generalizes across instructions, not just Transfer. mint_inner
already supports crediting an existing holding on the public side (branches on
user_holding_account.account == Account::default()); the private side needs the same
PrivateAuthorizedUpdate cooperation as Transfer — no instruction-specific escape hatch.
Passed on the first attempt once modeled on token_transfer_into_existing_private_holding.
Planned
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 |
|---|---|---|---|---|---|
| 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
the wrong identity variant, not a real protocol limit. InitializeAccount's guest requires the
target to be a signer (#[account(init, signer)]) — i.e. self-initialization, the same shape as
PrivateAuthorizedInit (owner supplies their own nsk directly, is_authorized: true is
legitimate), not PrivateUnauthorized (third party credits an account they don't control, nsk
withheld, is_authorized must be false). Matching the identity variant to the instruction's
actual authorization shape resolved it cleanly — passed on the first attempt once corrected.
Finding (self-service-only boundary, confirmed 2026-07-07 — prompted by a direct question,
not originally in the matrix): can a third party initialize a private Token holding for an
(npk, vpk, identifier) whose nsk they don't possess? No — and this is a deliberate design
boundary, not a gap. Unlike Transfer/Mint, whose recipient-side host logic never asserts
is_authorized (which is exactly why third-party shielding into a fresh recipient works there
via PrivateUnauthorized), InitializeAccount's guest declares account_to_initialize as
#[account(init, signer)]. Attempting it via PrivateUnauthorized (is_authorized: false,
no nsk needed) is rejected — empirically confirmed — at the SPEL macro's own account
validation layer ("must be a signer"), before token_program::initialize::initialize_account's
own is_authorized assert is even reached. The only variant that can construct a fresh private
account here is PrivateAuthorizedInit, which requires supplying nsk directly. Net: this
instruction is self-service-only by construction — you can initialize your own private holding,
but not one on someone else's behalf without their key material. Worth carrying into the gap
report as a scoping note on EXIST/BASE, not a defect.
Finding (token_mint_authorized_init, confirmed 2026-07-07): the self-service-only
boundary above is specific to InitializeAccount, not a general rule about "authorized" private
identities. Mint's guest marks user_holding_account as #[account(mut)] only (no
signer), and mint_inner never asserts is_authorized on it — confirmed by reading
token/src/mint.rs before writing the test, then verified empirically. So minting to a
recipient that self-initializes via PrivateAuthorizedInit (their own nsk supplied) works
just as well as token_mint_shielded's passive PrivateUnauthorized recipient — passed on the
first attempt. Worth stating plainly in the gap report: whether a "self-authorized fresh
recipient" is accepted is instruction-specific (gated by that instruction's own signer
requirement), not a blanket protocol rule.
Finding (token_shielded_transfer_authorized_private_init, confirmed 2026-07-07): the same
PrivateAuthorizedInit-instead-of-PrivateUnauthorized variant generalizes to Transfer too,
closing the last instruction where every fresh-recipient test used only PrivateUnauthorized
(token_shielded_transfer, token_private_transfer's new recipient, the group test's shield
step). transfer.rs asserts is_authorized only on the sender, never the recipient — same
shape as Mint — so this was expected and passed on the first attempt. Between this and the
Mint/InitializeAccount results, the picture is now complete: whether a fresh recipient can
choose to self-initialize (PrivateAuthorizedInit) instead of being passively credited
(PrivateUnauthorized) depends entirely on whether that instruction's guest marks the target
as a signer — true for InitializeAccount only (where PrivateUnauthorized is actually
rejected), optional for Transfer/Mint (both variants accepted).
ATA (ata.rs) — depends on Token
Existing
| Instruction | Dimension | Test | Status |
|---|---|---|---|
| Create | BASE (private owner only; ATA account + definition public) | ata_create_from_private_owner |
Pass |
| Create | PDA |
ata_create_private_ata_holding_is_not_expressible |
Not-expressible — confirmed |
Verified in ata/src/create.rs: the owner account is not forwarded into the
ChainedCall to Token — only token_definition and the ATA holding are. So the existing
ata_create_from_private_owner test proves a private account can seed a PDA derivation and
appear as a top-level tx participant, but does not prove a private account traveling
through a chained call. That gap is still open despite appearances.
Finding (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
precisely: Create's ChainedCall.pda_seeds authorizes Token to mutate
for_public_pda(ata_program_id, seed) — a match under the public PDA formula. In
resolve_authorization_and_record_bindings (execution_state.rs), a caller-seed match only
gets recorded into private_pda_bound_positions when it matches under for_private_pda
(is_private_form == true) — a public-form match authorizes the account but never binds it
as a private PDA. Every PrivatePdaInit/PrivatePdaUpdate identity requires its position to
appear in that binding map (hard assert! at execution_state.rs:211), and ATA's own
verify_ata_and_get_seed independently requires the account id to equal
for_public_pda(ata_program_id, seed) — never for_private_pda's output, by construction of
two different hash domains. These two requirements are mutually exclusive for the same
account_id, full stop — confirmed empirically by attempting exactly this and getting the
precise, deterministic rejection (ata_create_private_ata_holding_is_not_expressible, which
asserts on the exact panic text).
This generalizes: AMM's vault/pool and Stablecoin's position/vault use the identical
for_public_pda-only derivation, so they will hit the same wall for the same reason — no
need to rediscover this per program, just confirm each one uses for_public_pda (already
verified for both in amm_core/stablecoin_core) and cite this finding. The only fix is a
source change to ata_core/amm_core/stablecoin_core to derive PDAs via for_private_pda
instead — out of scope for this test-writing task, but this is the single clearest, most
actionable item to feed back to the privacy/protocol work.
All originally-planned ATA rows are now resolved — see updated Existing table below. ATA phase
is complete.
| Instruction | Dimension | Test | Status |
|---|---|---|---|
| Transfer | CHAIN + EXIST (collapsed — see finding) |
ata_transfer_to_existing_private_recipient |
Pass |
| Burn | new: signer-authorization | ata_burn_with_private_owner_signing |
Pass |
| Burn | GROUP + signer-authorization |
ata_group_owned_owner_signing |
Pass |
| 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
initialized"). That means a fresh private recipient (shield-style, PrivateUnauthorized) can
never be created through ATA::Transfer — only an already-existing account can be credited.
This collapses what the matrix originally planned as two separate rows (BASE and EXIST)
into one: ata_transfer_to_existing_private_recipient funds a private holding via a direct
(non-ATA) Token::Transfer shield first, then sends more into it through ATA's chained call,
with the recipient cooperating via PrivateAuthorizedUpdate (consistent with the Token-phase
EXIST finding). This is also the first test in the whole exercise where a private account
identity travels through a nested ChainedCall rather than a top-level instruction call —
and it worked on the first attempt, with no special handling needed.
Finding (signer-authorization, confirmed 2026-07-07 — new angle, not in the original matrix):
Transfer/Burn require owner to be a signer (#[account(signer)]), unlike Create
(merely mut). Every existing private-owner test only used owner passively (Create, no
signer requirement). ata_burn_with_private_owner_signing tests whether a private account can
satisfy a signer requirement by self-initializing and signing in the same transaction via
PrivateAuthorizedInit — it does, cleanly, on the first attempt. ata_group_owned_owner_signing
composes this with GROUP: the GMS is distributed through the real seal/unseal handshake (as
in token_group_owned_holding_shared_control_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
33 public tests, 0 private. Confirmed in amm_core: all 5 PDAs (config, pool, vault×2,
liquidity-token, lp-lock) use for_public_pda exclusively.
Not every account is an equally meaningful privacy target: Pool/Config are the AMM's public price surface (reserves must be readable to quote a swap; TWAP needs a continuously observable tick) — privatizing them fights the AMM's purpose. Vault/LP-lock are the credible middle case. User-held token/LP balances are the highest-value target.
⚠ 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
WithdrawCollateralreuses 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 pastexecute_and_provecleanly — it fails later, attransition_from_privacy_preserving_transaction, withInvalidInput("Empty commitments and empty nullifiers found in message"). This looks like an unrelated, general protocol rule (aPrivacyPreservingTransactionneeds at least one actual private account, or usePublicTransactioninstead) — 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-Publicidentities. - Leading structural lead, superseded below: every AMM instruction that hits the length
mismatch passes a post-update copy of
pool(pool_price_source, holdingpool_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. This was the leading lead at the time, but is likely not the real cause — see the more precise finding below, which identifies the specific missing account directly. - Precisely identified the missing account (2026-07-13): instrumented
execution_state.rs's per-account loop invalidate_and_sync_stateswitheprintln!tracing (see below for how this was made to actually take effect) and confirmed via exact string-levelAccountIdmatching thatCLOCK_01_PROGRAM_ACCOUNT_IDis the account that vanishes — it's supplied as a top-level input and is clearly present in the AMM program's own returnedpost_states(confirmed directly insync.rs'ssync_reservesandswap.rs'sfinalize_swap, both of which explicitly includeAccountPostState::new(clock.account...)), yet it never appears in the circuit-level trace at any call depth, not even inside the TWAP chained call which also explicitly passesclock.clone(). Root cause of why it's dropped was not yet found at this point — since resolved, see "Root cause found" below: it's aspel-frameworkguest-wrapper filter, not the circuit's own processing. Instrumentation was fully reverted afterward (verified byte-identical to the original checkout and original artifact) rather than left in place. - Confirmed this also blocks real private-account attempts, not just the all-public control
case (2026-07-13): three tests —
amm_swap_a_to_b_private_user_holding_is_not_expressible(privateuser_holding_a, 8 vs 7 accounts),amm_add_liquidity_private_lp_holding_is_not_expressible(privateuser_holding_lp, 10 vs 9),amm_remove_liquidity_private_lp_holding_is_not_expressible(privateuser_holding_lp, 10 vs 9) — all fail with the identical"Invalid account_identities length"panic, always exactly one account short. This rules out "the bug only manifests because there are zero private accounts" as an explanation; it's a structural property of these instructions' account/chained-call shape, independent of privacy entirely.
How the instrumentation was made to actually take effect (2026-07-08 attempt failed, 2026-07-13
attempt succeeded): eprintln! tracing added directly to the pinned lee_core checkout's
execution_state.rs first appeared to have no effect — prints never surfaced, and the original
panic kept firing from the same file/line even after cargo clean -p lee -p lee_core and a fresh
compile. Root cause: real guest execution runs a separately cross-compiled RISC-V ELF
(risc0_build::embed_methods!), and the pinned PRIVACY_PRESERVING_CIRCUIT_ELF artifact is a
pre-built, checked-in binary (artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin
in the checkout) embedded via build_utils::include_artifacts — editing the .rs source alone
never touches that binary. Fix: rebuild the guest ELF directly with
cargo risczero build -p privacy_preserving_circuit_program --manifest-path <checkout>/Cargo.toml
(matching the checkout's own Justfile build-artifacts recipe) and copy the result over the
checked-in .bin — plus cargo clean -p lee -p lee_core again afterward, since
cargo:rerun-if-changed was scoped to the artifacts directory, and overwriting a file's
content in place doesn't change the directory's own mtime, so cargo's incremental build silently
kept using the old compiled rlib (with the old bytes baked in via include_bytes!) even after
the file swap. Once both steps were done, the eprintln! output finally appeared and led
directly to the CLOCK_01_PROGRAM_ACCOUNT_ID finding above. All instrumentation (source edits,
rebuilt artifact) was fully reverted afterward and verified byte-identical to the original.
Next step when this is picked back up: check whether output_pre_states.len()/
output_post_states.len() already differ from 8/8 (or 6/6, etc.) before the per-account
validation loop in validate_and_sync_states runs — that would localize the drop to either the
AMM guest/SPEL-macro layer or the circuit's own processing, and is the next concrete step now
that instrumentation is confirmed to work end-to-end.
✅ Root cause found (2026-07-14) — it's in spel-framework, not lee_core, and not AMM's own code
Investigated (via a Fable 5 subagent, source-reading only — no instrumentation needed this time) by comparing the two transaction validators side by side and checking the guest-wrapper code that sits between AMM's own functions and either validator. Fully verified by direct inspection afterward (both citations below reproduced and confirmed independently).
The account is deleted before it ever reaches either validator. The #[lez_program] macro's
generated main() — spel-framework-macros/src/lib.rs:303-329, in the pinned
spel-393b37c2cff64018 checkout at rev 91023c9115bf88173b0d25d2e905f2a55ef0313b — post-processes
every guest function's returned (pre_states, post_states) pairs before writing the
ProgramOutput:
// Filter out non-program-owned, non-default-state accounts from the output.
//
// LEZ validate_execution rule 7: if post.program_owner == DEFAULT_PROGRAM_ID
// and pre.account != Account::default(), validation fails. This would happen
// for signer accounts (e.g., proposer/executor) whose nonce has been incremented
// by a prior transaction — they are not owned by the program and must not be
// returned in the program's post-states.
.filter(|(pre, post)| {
let is_default_owner = pre.account.program_owner == DEFAULT_PROGRAM_ID;
let pre_is_default = pre.account == Account::default();
let has_claim = post.required_claim().is_some();
!is_default_owner || pre_is_default || has_claim
})
This was written to solve a real, narrow problem: drop signer accounts (proposer/executor)
whose nonce got bumped by a prior transaction, since they're not owned by the program and
validate_execution's rule 7 would otherwise reject the output. But the predicate is broader
than that one case, and clock happens to satisfy it too:
is_default_owner = true— the clock account is seeded viaforce_insert_accountwithAccount { data: <real clock bytes>, ..Account::default() }(advance_clockinamm.rs), so itsprogram_ownerstaysDEFAULT_PROGRAM_ID— it's never claimed by any program.pre_is_default = false— itsdatafield holds real, non-default clock bytes.has_claim = false— AMM never issues aClaimfor clock; it only reads it.
!true || false || false = false → the (pre, post) pair for clock is silently dropped from
ProgramOutput.pre_states/post_states, every single time, for every AMM instruction that
touches it — and for TWAP's UpdateCurrentTick too, since it's built with the exact same macro
at the exact same pin. This is exactly why the earlier eprintln! trace never saw clock at
any call depth, including inside the nested TWAP call: it was gone before the circuit ever got
the chance to see it, not dropped by the circuit itself.
Why the public-transaction path never noticed: ValidatedStateDiff::from_public_transaction
(lee/state_machine/src/validated_state_diff.rs) only ever iterates whatever the program's
output actually contains (program_output.pre_states) and zips it against
program_output.post_states to build the state diff. There is no check anywhere that the
output covers every account the caller originally supplied — a silently-dropped, unmodified
account just never appears in the diff, and nothing asserts it should have. validate_execution
(the rule 7 the filter comment refers to) only checks pre_states.len() == post_states.len()
within the already-filtered output (7 == 7 — passes trivially, since both sides of the pair
were dropped together).
Why the privacy-preserving path panics: the circuit builds its own account-tracking state as
the union of every ProgramOutput.pre_states it sees across the whole call tree — 7 accounts,
no clock. But the caller (the test, or in production a real wallet/client) must supply one
InputAccountIdentity per account it believes is involved — 8, including clock, since nothing
told the caller clock would be dropped. compute_circuit_output's
assert_eq!(account_identities.len(), states_iter.len()) (output.rs:27) then fails: 8 != 7.
The public path tolerates exactly this same silent drop; only the private path's stricter
1:1 correspondence check turns it into a hard failure.
This is a spel-framework bug, not a lez_core/circuit bug, and not an AMM program bug.
Neither this repo's own code nor the pinned LEZ dependency is at fault — the defect is in the
0x-r4bbit/spel proc-macro crate's generated wrapper, one layer removed from both. Fix options
belong upstream: scope the filter to only the specific signer-nonce-bump case it was written for
(e.g. keep any pair the handler's own logic explicitly returned, rather than blanket-filtering
by ownership), or have the circuit tolerate identities without a corresponding output pre-state.
The trigger condition is narrow but real: any account with program_owner == DEFAULT_PROGRAM_ID
that a program reads but never claims will hit this — not just clock, and not just AMM. It just
happens to be clock here because every pool-mutating AMM instruction reads it.
Soundness implication, not just a test-writing inconvenience: because clock never reaches
public_pre_states on the privacy-preserving path, the host validator
(check_privacy_preserving_circuit_proof_is_valid) never checks the clock data a proof was
generated against against real chain state. A malicious prover could in principle supply an
arbitrary timestamp as a private witness and no check anywhere would catch it. Worth escalating
to the LEZ/SPEL maintainers independent of whether/when the AMM test-writing blocker itself gets
prioritized.
Upstream provenance checked (2026-07-15): cloned both logos-co/spel (upstream) and
0x-r4bbit/spel (the pinned fork) to check whether the filter was fork-introduced. It isn't.
git log -S"is_default_owner" on upstream logos-co/spel finds it added in PR #126, squash-merged
to upstream main as commit 1f51875 ("SpelOutput::execute() with auto-claim support"), still
present at upstream's current HEAD (0cb7e09, v0.6.0). Walking the fork's history back to its
merge point from logos-co/release/v0.5.0 (commit 73fc462) shows the filter already present
there too, before any of the fork's own commits. This is an upstream logos-co/spel bug,
inherited unchanged by the pinned fork — report it against the former, not the latter.
Correction — there's a second, independent bug in logos-execution-zone itself (2026-07-15):
the "not a lez_core/circuit bug" claim above is too narrow. ValidatedStateDiff:: from_public_transaction (lee/state_machine/src/validated_state_diff.rs) never checks that the
accounts touched in a program's output match the caller-declared message.account_ids — no count
check, no membership check, nothing analogous to the privacy circuit's own account_identities. len() == states_iter.len() assertion in compute_circuit_output. The reconciliation loop just
does state_diff.insert(pre.account_id, post.account().clone()) for whatever pairs the program's
output happens to contain, however many that is, and returns Ok(...) regardless.
That absence is why the spel-framework drop went unnoticed by every one of this repo's ~34
pre-existing public AMM tests: the public path has no validation capable of catching a
silently-dropped account at all, so a bug with zero privacy dimension to it hid behind passing
public tests until the privacy circuit's stricter, positional bookkeeping happened to expose it.
Two independent upstream defects, not one: spel-framework's over-broad filter (which creates the
drop) and logos-execution-zone's missing account-accounting check on the public path (which lets
any such drop — from this or any future bug — go completely undetected). The latter is arguably
the more consequential of the two, since it's a general soundness gap independent of clock, AMM,
or privacy entirely. Both should be reported upstream; report the lee gap against
logos-co/logos-execution-zone, not logos-co/spel.
✅ Fixed for these tests (2026-07-15) — test-fixture clock ownership, not a circuit workaround
The immediate blocker for all five CHAIN-dimension AMM privacy tests below was that
advance_clock (programs/integration_tests/tests/amm.rs) constructed the clock account with
..Account::default(), leaving program_owner == DEFAULT_PROGRAM_ID and tripping the
spel-framework filter above on every AMM/TWAP call that echoes it back. The real production
clock account is owned by a dedicated clock program (lez/system_accounts/src/lib.rs's
clock_account(): program_owner: programs::clock().id()), not DEFAULT_PROGRAM_ID — but that
constructor lives behind the gated system-programs/artifacts feature this test crate can't
reach, so advance_clock fabricates the account directly and never set an owner.
Fix applied: advance_clock now sets program_owner to a placeholder non-default ProgramId
([42_u32; 8], a stand-in — not the real production clock program ID, which isn't reachable from
this crate). Nothing in AMM/TWAP-oracle logic checks the clock's program_owner value (only its
account_id against CLOCK_01_PROGRAM_ACCOUNT_ID), and validate_execution's other rules
(no-ownership-change, no-unauthorized-data/balance-change) are satisfied trivially since clock is
echoed unchanged — so this is a safe, minimal, test-only fix. All five previously-blocked tests
were rewritten from _is_not_expressible/expect_err assertions to real success assertions
(rebuilding the PrivacyPreservingTransaction, applying it via
transition_from_privacy_preserving_transaction, and checking resulting public state +
commitments for the private accounts) and now pass. Full amm.rs suite: 40/40 passing.
The spel-framework dispatcher filter itself is untouched and remains an open upstream bug
(confirmed to originate in logos-co/spel, not the pinned fork — see above) — it would still
silently drop any other DEFAULT_PROGRAM_ID-owned, non-default, unclaimed account threaded
through a chained call. Worth reporting upstream regardless of this fixture-level fix.
Existing
10 private tests out of 34 pre-existing public + 10 = 44. Six demonstrate an actually-working AMM
privacy path end-to-end (private account touched, transaction applied, resulting public state and
private commitments verified): the five chained-call tests unblocked by the clock test-fixture fix
above, plus amm_new_definition_private_initial_lp_holder (pool creation with a fresh
PrivateAuthorizedInit LP holder — new_definition.rs explicitly permits this, unlike
swap/remove). Four are confirmed not-expressible, splitting into two distinct root causes:
amm_remove_liquidity_private_new_user_holdings_is_not_expressible and
amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible both hit the same
"destination must already exist and be Token-Program-owned" precondition;
amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible and
amm_new_definition_private_unauthorized_lp_holder_is_not_expressible both hit an earlier, more
fundamental guest-ABI signer requirement that a PrivateUnauthorized identity can never satisfy —
any account declared a required signer in the guest ABI structurally excludes PrivateUnauthorized
recipients, regardless of what the program's own internal precondition would otherwise allow.
Second finding, unrelated to the circuit bug (2026-07-13): remove_liquidity requires
user_holding_a/user_holding_b to already exist and already be owned by the configured Token
Program (remove.rs's assert_eq!(user_holding_a.account.program_owner, token_program_id, ...))
— unlike token::transfer's recipient handling, which tolerates Account::default() and
self-initializes it. So RemoveLiquidity can never pay out to a brand-new private destination
(PrivateUnauthorized — only npk known, no nsk, the pattern
token_mint_shielded_to_private_unauthorized uses): the attempt
(amm_remove_liquidity_private_new_user_holdings_is_not_expressible) fails inside the AMM
program's own precondition check ("User Token A holding must be owned by the configured Token Program"), before any chained call or the privacy-preserving circuit is ever reached — and
would equally reject a brand-new public destination. Same shape of finding as Stablecoin's
stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible: a plain
program-level precondition that predates privacy entirely, not a circuit artifact.
Planned
| Instruction | Dimension | Test | Priority | Depends on | Status |
|---|---|---|---|---|---|
| SwapExactInput | CHAIN |
amm_swap_a_to_b_private_user_holding |
P1 | Token, TWAP oracle (public leg) | ✅ Expressible — private user_holding_a, unblocked by the clock test-fixture fix |
| SwapExactOutput | CHAIN |
amm_swap_exact_output_private_user_holding |
P1 | Token, TWAP oracle (public leg) | ✅ Expressible — same fix |
| AddLiquidity | CHAIN |
amm_add_liquidity_private_user_holdings — private deposit legs (user_holding_a/user_holding_b) |
P1 | Token, TWAP oracle (public leg) | ✅ Expressible — same fix |
| AddLiquidity | BASE | amm_add_liquidity_private_lp_holding — private LP output holding |
P1 | Token | ✅ Expressible — private user_holding_lp, same fix |
| RemoveLiquidity | CHAIN |
amm_remove_liquidity_private_lp_holding |
P1 | Token, TWAP oracle (public leg) | ✅ Expressible — private user_holding_lp, same fix |
| RemoveLiquidity | EXIST (negative) |
amm_remove_liquidity_private_new_user_holdings_is_not_expressible — brand-new PrivateUnauthorized token A/B destinations |
P1 | Token | Confirmed not-expressible for a different reason — AMM's own precondition requires the destination to already be owned by the Token Program; unrelated to the clock issue |
| Swap | EXIST (negative) |
amm_swap_a_to_b_private_unauthorized_destination_is_not_expressible — brand-new PrivateUnauthorized recipient (npk only) |
P1 | Token | Confirmed not-expressible — the guest ABI (#[account(mut, signer)] on both user_holding_a/user_holding_b in methods/guest/src/bin/amm.rs) requires every swap participant to be a signer; PrivateUnauthorized is authorized by nobody by construction, so it can never satisfy this, independent of the destination-must-exist issue below |
| Swap | EXIST (negative) |
amm_swap_a_to_b_private_authorized_init_destination_is_not_expressible — brand-new PrivateAuthorizedInit recipient (self-initializes with its own nsk, satisfies the signer requirement) |
P1 | Token | Confirmed not-expressible for a different reason — same "destination must already exist and be Token-Program-owned" precondition as RemoveLiquidity's finding above (swap.rs asserts user_holding_b.account.program_owner == token_program_id unconditionally) |
| Swap / AddLiquidity | EXIST |
amm_swap_into_existing_private_holding |
P2 | Token | Not started — swap into an already-existing private destination (PrivateAuthorizedUpdate); distinct from the two fresh-destination rows above, which are both confirmed not-expressible |
| NewDefinition | BASE | amm_new_definition_private_initial_lp_holder |
P2 | Token | ✅ Expressible — new_definition.rs's own precondition on user_holding_lp (`account != Account::default() |
| NewDefinition | EXIST (negative), BASE |
amm_new_definition_private_unauthorized_lp_holder_is_not_expressible — PrivateUnauthorized initial LP holder (npk only) |
P2 | Token | Confirmed not-expressible — same guest-ABI signer requirement as the Swap PrivateUnauthorized finding above: user_holding_lp is #[account(mut, signer)] unconditionally, so it's rejected before new_definition.rs's own "fresh LP holding requires user authorization" precondition is ever reached |
| 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 — blocked by the separate PDA-formula finding, not the clock issue |
| AddLiquidity / RemoveLiquidity | GROUP |
amm_group_owned_lp_holding |
P3 | Token, key_protocol |
Not started — no longer blocked by the clock issue (fixed 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. The clock-account blocker above is fixed (2026-07-15) — the
remaining unwritten rows are open to pick up, except the PDA rows, which are separately
blocked by the for_public_pda-only formula finding.
Stablecoin (stablecoin.rs) — depends on Token
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
| 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 |
| WithdrawCollateral | new: destination must pre-exist | stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible |
Not-expressible — confirmed |
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.
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.
Finding (stablecoin_withdraw_collateral_to_new_private_destination_is_not_expressible,
confirmed 2026-07-09 — second, unrelated not-expressible result for this program):
WithdrawCollateral cannot pay out to a brand-new private destination. withdraw_collateral.rs
hard-asserts destination.account != Account::default() before the chained Token::Transfer is
even constructed — a plain host-side program precondition, unrelated to the OpenPosition
authorization-bookkeeping bug above. It fires regardless of privacy: a brand-new public
destination would be rejected identically. Confirmed by attempting WithdrawCollateral with a
PrivateUnauthorized destination (fresh Account::default() pre-state, only npk known) and
observing the exact "Destination must be initialized" panic surface as the circuit-execution
error. Consequence: every WithdrawCollateral test in this phase necessarily uses
PrivateAuthorizedUpdate (nsk known) for the destination — a pre-existing private destination
is the only expressible shape, not a coverage choice.
ProtocolParameters remains out of scope — not yet consumed by any instruction (no
freeze/admin logic wired up), nothing to test.
Phase 0 prerequisites (blocking every remaining GROUP row)
Add— done (2026-07-07), added tokey_protocolas a git dependencyintegration_tests/Cargo.tomlpinned to the same repo/tag asnssa/nssa_core. Unblocks the remainingGROUProws 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— mostly done (2026-07-08):private_unauthorized_identity/private_authorized_init_identity/private_authorized_update_identity(build anInputAccountIdentityfrom just the key material) andGroupOwner(the Alice-creates/Bob-admitted GMS handshake, via::new(seed)+.admit_member()) now live there and are used throughouttoken.rs,stablecoin.rs(fully migrated), and the newerata.rsgroup tests. Only the originalata_group_owned_owner_signingstill has its own independent inline copy — not yet migrated. Low priority; revisit before/during AMM if it's still outstanding then.
Implementation technique worth carrying into AMM/Stablecoin (found 2026-07-07): private
account preconditions don't need a real proven transaction to set up. V03State::with_private_accounts(impl IntoIterator<Item = (Commitment, Nullifier)>)
is a genuine, non-test-gated builder method — pair Commitment::new(&id, &account) with
Nullifier::for_account_initialization(&id) (the same pairing a real PrivateUnauthorized/
PrivateAuthorizedInit would have produced) and the seeded state is indistinguishable from a
real one to any subsequent transaction. Confirmed against lee's own test suite pattern before
using it, then applied to refactor token_private_burn, token_transfer_into_existing_private_holding,
and token_mint_into_existing_private_holding's setup legs — all still pass. Caveat: seeding
skips whatever public-side effect the bypassed transaction would have had (sender debit for a
shield, supply increase for a mint) — assertions on public state must account for that, matching
how public fixtures (Accounts::holder_init()) already set balances without a real mint ever
having produced them. This will matter more for AMM/Stablecoin, where setup transactions are
heavier (chained calls, multiple accounts) than a single shield.
Row count summary
| Program | Existing private / confirmed | Planned rows | Out-of-scope instructions noted |
|---|---|---|---|
| Token | 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 | 6 (all confirmed not-expressible: Swap/SwapExactOutput/AddLiquidity (both LP and deposit legs)/RemoveLiquidity blocked by the same circuit bug, plus RemoveLiquidity's separate new-destination precondition; 2 further rows predicted not-expressible pending confirmation via the PDA finding) |
5 | 5 |
| Stablecoin | 7 (5 pass + 2 confirmed not-expressible — phase complete) | 0 | 1 |