From 8853a790e62238c0f784617fb17c9f63872d7c89 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Wed, 27 May 2026 02:53:52 -0300 Subject: [PATCH] update specs --- docs/specs.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/specs.md b/docs/specs.md index 1c95b487..f057437e 100644 --- a/docs/specs.md +++ b/docs/specs.md @@ -619,8 +619,8 @@ After execution, the runtime processes each post-state's optional `claim`: - `Claim::Authorized` — the standard claim path: sets `program_owner = executing_program_id` (when currently default) for all account kinds except self-owned PDAs. Whether `is_authorized` is required depends on the account kind: - **Public accounts:** `is_authorized` must be `true`, set when the transaction signer included the account in the authorized set. - - **Public and private PDAs:** `is_authorized` must be `true`, set when the caller included the matching seed in `ChainedCall.pda_seeds`. - - **Regular private accounts**: there's no extra enforcement for claiming. The circuit already imposes that `is_authorized` must be true for the `PrivateAuthorizedInit` and `PrivateAuthorizedUpdate` variants, and false for `PrivateUnauthorized`. + - **Public PDAs:** `is_authorized` must be `true`, set when the caller included the matching seed in `ChainedCall.pda_seeds`. + - **Private accounts (regular and PDA)**: there's no enforcement. For regular accounts the circuit ensures the right authorization posture via the `PrivateAuthorizedInit`/`PrivateAuthorizedUpdate`/`PrivateUnauthorized` variant selection. For private PDAs, derivation binding is established via `Claim::Pda(seed)`, `pda_seeds`, or the `seed` field in the identity — none of these require `is_authorized` to be `true`. - `Claim::Pda(seed)` — sets `program_owner = executing_program_id` (when currently default) by proving the account's ID is structurally derived from the executing program's own ID and the given seed, with no user authorization required. Unlike `Claim::Authorized`, the claim is not backed by a signature or nullifier key proof; instead, the program demonstrates ownership by construction: if the address was computed from `(self_program_id, seed)`, then no other program could have produced that same address. The derivation formula depends on the account kind: for public accounts it is `AccountId::for_public_pda(executing_program_id, seed)`; for private PDAs it is `AccountId::for_private_pda(executing_program_id, seed, npk, identifier)` using the npk supplied for that pre-state, making the claim user-specific. `Claim::Pda` is not applicable to standalone private accounts. ### Program-derived account IDs (PDAs) @@ -629,7 +629,7 @@ After execution, the runtime processes each post-state's optional `claim`: **Private PDA:** derived from `(program_id, seed, npk, identifier)`. Unlike public PDAs, private PDAs are per-user: two users at the same `(program_id, seed)` get different addresses. Within a single user's namespace the `identifier` diversifies further. -Authorization for private PDAs in chained calls is established by the caller including the PDA seed in `pda_seeds`. In privacy-preserving transactions, the ZK proof additionally verifies that the address matches `AccountId::for_private_pda` using the NPK supplied for that pre-state. +Authorization for private PDAs in chained calls is established by the caller including the PDA seed in `pda_seeds`, or via `Claim::Pda(seed)` for the owning program itself. A third path exists for top-level or cross-program access where neither mechanism is available: the wallet supplies `seed: Some((seed, authority_program_id))` in the `PrivatePdaInit` or `PrivatePdaUpdate` identity. When present, the circuit verifies `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == pre_state.account_id` directly. This is a pure derivation check — the pre-state must have `is_authorized == false` and no authorization is implied. ### Validity windows @@ -896,6 +896,13 @@ pub enum InputAccountIdentity { npk: NullifierPublicKey, ssk: SharedSecretKey, identifier: Identifier, + /// When `Some((seed, authority_program_id))`, the circuit verifies + /// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == + /// pre_state.account_id` directly, binding the position without requiring a + /// `Claim::Pda` or caller `pda_seeds`. The `pre_state` must have + /// `is_authorized == false`; this field provides derivation binding only, not + /// authorization. + seed: Option<(PdaSeed, ProgramId)>, }, /// Update of an existing private PDA. npk is derived from nsk. @@ -905,6 +912,12 @@ pub enum InputAccountIdentity { nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, + /// When `Some((seed, authority_program_id))`, the circuit verifies + /// `AccountId::for_private_pda(authority_program_id, seed, npk(nsk), identifier) == + /// pre_state.account_id` directly, binding the position without requiring a + /// caller `pda_seeds`. The `pre_state` must have `is_authorized == false`; + /// this field provides derivation binding only, not authorization. + seed: Option<(PdaSeed, ProgramId)>, }, } ``` @@ -940,12 +953,12 @@ For each `InputAccountIdentity` the circuit performs the following: | `PrivateAuthorizedInit` | `from_private(npk(nsk), ident)` | `nonce_init(account_id)` | init nullifier | nsk | `is_authorized` must be `true` | | `PrivateAuthorizedUpdate` | `from_private(npk(nsk), ident)` | `nonce_increment(nsk)` | update nullifier | nsk + membership proof | **not enforced** (skipped) | | `PrivateUnauthorized` | `from_private(npk, ident)` | `nonce_init(account_id)` | init nullifier | none | **not enforced** (skipped) | -| `PrivatePdaInit` | `for_private_pda(prog, seed, npk, ident)` | `nonce_init(account_id)` | init nullifier | pda_seeds/Claim::Pda, or CallerData propagation | `is_authorized` must be `true` | -| `PrivatePdaUpdate` | `for_private_pda(prog, seed, npk(nsk), ident)` | `nonce_increment(nsk)` | update nullifier | (pda_seeds or CallerData) + nsk + membership proof | `is_authorized` must be `true` | +| `PrivatePdaInit` | `for_private_pda(prog, seed, npk, ident)` | `nonce_init(account_id)` | init nullifier | pda_seeds/Claim::Pda, CallerData, or external `seed` (binding only, `is_authorized == false`) | **not enforced** (skipped) | +| `PrivatePdaUpdate` | `for_private_pda(prog, seed, npk(nsk), ident)` | `nonce_increment(nsk)` | update nullifier | (pda_seeds or CallerData or external `seed`) + nsk + membership proof; `is_authorized == false` for external seed path | **not enforced** (skipped) | For each private account the post-state commitment is `Commitment::new(account_id, post_account)` (with the new nonce applied). The ciphertext is `EncryptionScheme::encrypt(post_account, kind, ssk, commitment, output_index)`. -The chain-of-calls logic and `validate_execution` rules are identical to the public execution path. The claiming rules diverge for **standalone private accounts only**: `Claim::Authorized` on a `Regular` private kind is allowed unconditionally (no `is_authorized` check). For public accounts and private PDAs, `Claim::Authorized` enforcement matches the public path. +The chain-of-calls logic and `validate_execution` rules are identical to the public execution path. The claiming rules diverge for **all private accounts**: `Claim::Authorized` on any private kind (regular or PDA) is allowed unconditionally — no `is_authorized` check is performed. For public accounts, `Claim::Authorized` still requires `is_authorized == true`. ## Encrypted private account discovery and tagging