From dbb9d4acc16082cd850b1988cd9d393438b94d94 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Tue, 12 May 2026 22:43:42 -0300 Subject: [PATCH] update --- docs/specs.md | 301 +++++++++++++++++++++++--------------------------- 1 file changed, 139 insertions(+), 162 deletions(-) diff --git a/docs/specs.md b/docs/specs.md index d29f9818..4f1e3a1d 100644 --- a/docs/specs.md +++ b/docs/specs.md @@ -6,6 +6,7 @@ type AccountId = [u8; 32]; type NativeToken = u128; type ProgramId = [u32; 8]; +struct PdaSeed([u8; 32]); type Data = List; type Nonce = u128; type ByteString = List; @@ -87,139 +88,7 @@ The number of native tokens held by the account. It's represented as a 128-bit n An arbitrary byte string field to be managed by the `program_owner` program. The content and interpretation of this field are defined by the program logic. The maximum size of it is defined by the constant `DATA_MAX_LENGTH_IN_BYTES`. -### Nonce field - -The nonce is a 128-bit integer value. It has different uses depending on the visibility of the account: - -- **Public accounts:** The nonce counts the number of transactions in which the associated public key of the account appears as a signer. This serves as a sequence number to prevent replay of transactions involving this account. -- **Private accounts:** A pseudorandom value used to provide entropy for the account's commitment, making it unconditionally hiding. The initial nonce is derived from the account ID, and subsequent nonces are derived from the nullifier secret key and the previous nonce. - -**Private account nonce initialization:** - -$$\mathsf{nonce}_0 = \mathsf{SHA256}(\mathsf{account\_id} \;||\; [0_{u8}; 32])_{[0..16]}$$ - -where the result is the first 16 bytes of the hash, interpreted as a `u128` little-endian integer. The full preimage is 64 bytes: the 32-byte account ID followed by 32 zero bytes. - -**Private account nonce update:** - -$$\mathsf{nonce}_{i+1} = \mathsf{SHA256}(\mathsf{nsk} \;||\; \mathsf{nonce}_i \;||\; [0_{u8}; 16])_{[0..16]}$$ - -where `nonce_i` is the 16-byte little-endian encoding of the current nonce, and the result is the first 16 bytes of the hash interpreted as a `u128` little-endian integer. The full preimage is 64 bytes: 32-byte `nsk` + 16-byte nonce + 16 zero bytes. - -```rust -impl Nonce { - fn private_account_nonce_init(account_id: &AccountId) -> Self { - let mut bytes = [0_u8; 64]; - bytes[..32].copy_from_slice(account_id.value()); - // bytes[32..64] are zero - let hash: [u8; 32] = sha256(bytes); - Self(u128::from_le_bytes(*hash.first_chunk::<16>().unwrap())) - } - - fn private_account_nonce_increment(self, nsk: &NullifierSecretKey) -> Self { - let mut bytes = [0_u8; 64]; - bytes[..32].copy_from_slice(nsk); - bytes[32..48].copy_from_slice(&self.0.to_le_bytes()); - // bytes[48..64] are zero - let hash: [u8; 32] = sha256(bytes); - Self(u128::from_le_bytes(*hash.first_chunk::<16>().unwrap())) - } -} -``` - -### Account default value - -```rust -Account { - balance: 0, - program_owner: [0; 8], - data: Data::default(), - nonce: 0, -} -``` - -where `Data::default()` is the unique zero-length byte array. - -### Commitment - -The commitment of an account is computed as: - -$$\mathsf{Commitment} = \mathsf{SHA256}(\mathsf{COMMITMENT\_PREFIX} \;||\; \mathsf{account\_id} \;||\; \mathsf{ProgramOwner} \;||\; \mathsf{BalanceBytes} \;||\; \mathsf{NonceBytes} \;||\; \mathsf{DataDigest})$$ - -where - -- `COMMITMENT_PREFIX` is the domain separator: - ```rust - /// ASCII "/LEE/v0.3/Commitment/" zero-padded to 32 bytes - COMMITMENT_PREFIX: [u8; 32] = b"/LEE/v0.3/Commitment/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - ``` -- `account_id` is the 32-byte account ID of the private account (which encodes both the owner's `Npk` and the `Identifier`; see Account ID section). -- `ProgramOwner` is the 32 bytes of the program owner field encoded as 8 little-endian `u32` words. -- `BalanceBytes` are the 16 bytes of the little-endian representation of the balance. -- `NonceBytes` are the 16 bytes of the little-endian representation of the nonce. -- `DataDigest` are the 32 bytes of the SHA256 digest of the data field. - -The total preimage is 160 bytes. - -```rust -impl Commitment { - fn new(account_id: &AccountId, account: &Account) -> Self { - const COMMITMENT_PREFIX: &[u8; 32] = - b"/LEE/v0.3/Commitment/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; - - let mut bytes = Vec::new(); - bytes.extend_from_slice(COMMITMENT_PREFIX); - bytes.extend_from_slice(account_id.value()); - for word in &account.program_owner { - bytes.extend_from_slice(&word.to_le_bytes()); - } - bytes.extend_from_slice(&account.balance.to_le_bytes()); - bytes.extend_from_slice(&account.nonce.to_le_bytes()); - let data_digest: [u8; 32] = sha256(&account.data); - bytes.extend_from_slice(&data_digest); - sha256(bytes) - } -} -``` - -### Nullifier - -A private account's commitment is nullified each time the account's state is updated. There are two methods for computing a nullifier: - -- **Initialization nullifier** (used when the private account is created for the first time): - - $$\mathsf{Nullifier} = \mathsf{SHA256}(\mathsf{INIT\_PREFIX} \;||\; \mathsf{account\_id})$$ - - ```rust - /// ASCII "/LEE/v0.3/Nullifier/Initialize/" zero-padded to 32 bytes - INIT_PREFIX: [u8; 32] = b"/LEE/v0.3/Nullifier/Initialize/\x00" - ``` - -- **Update nullifier** (used when an existing private account's state is updated): - - $$\mathsf{Nullifier} = \mathsf{SHA256}(\mathsf{UPDATE\_PREFIX} \;||\; \mathsf{commitment} \;||\; \mathsf{nsk})$$ - - ```rust - /// ASCII "/LEE/v0.3/Nullifier/Update/" zero-padded to 32 bytes - UPDATE_PREFIX: [u8; 32] = b"/LEE/v0.3/Nullifier/Update/\x00\x00\x00\x00\x00" - ``` - -```rust -impl Nullifier { - fn for_account_initialization(account_id: &AccountId) -> Self { - let mut bytes = INIT_PREFIX.to_vec(); - bytes.extend_from_slice(account_id.value()); - sha256(bytes) - } - - fn for_account_update(commitment: &Commitment, nsk: &NullifierSecretKey) -> Self { - let mut bytes = UPDATE_PREFIX.to_vec(); - bytes.extend_from_slice(&commitment.to_byte_array()); - bytes.extend_from_slice(nsk); - sha256(bytes) - } -} -``` +Private accounts use a *nullifier public key* (`Npk`) as a core identifier. The next two subsections derive `Npk` and define the account ID formats — both are prerequisites for the nonce, commitment, and nullifier fields that follow. ### Nullifier public key derivation @@ -321,6 +190,140 @@ impl AccountId { } ``` +### Nonce field + +The nonce is a 128-bit integer value. It has different uses depending on the visibility of the account: + +- **Public accounts:** The nonce counts the number of transactions in which the associated public key of the account appears as a signer. This serves as a sequence number to prevent replay of transactions involving this account. +- **Private accounts:** A pseudorandom value used to provide entropy for the account's commitment, making it unconditionally hiding. The initial nonce is derived from the account ID, and subsequent nonces are derived from the nullifier secret key and the previous nonce. + +**Private account nonce initialization:** + +$$\mathsf{nonce}_0 = \mathsf{SHA256}(\mathsf{account\_id} \;||\; [0_{u8}; 32])_{[0..16]}$$ + +where the result is the first 16 bytes of the hash, interpreted as a `u128` little-endian integer. The full preimage is 64 bytes: the 32-byte account ID followed by 32 zero bytes. + +**Private account nonce update:** + +$$\mathsf{nonce}_{i+1} = \mathsf{SHA256}(\mathsf{nsk} \;||\; \mathsf{nonce}_i \;||\; [0_{u8}; 16])_{[0..16]}$$ + +where `nonce_i` is the 16-byte little-endian encoding of the current nonce, and the result is the first 16 bytes of the hash interpreted as a `u128` little-endian integer. The full preimage is 64 bytes: 32-byte `nsk` + 16-byte nonce + 16 zero bytes. + +```rust +impl Nonce { + fn private_account_nonce_init(account_id: &AccountId) -> Self { + let mut bytes = [0_u8; 64]; + bytes[..32].copy_from_slice(account_id.value()); + // bytes[32..64] are zero + let hash: [u8; 32] = sha256(bytes); + Self(u128::from_le_bytes(*hash.first_chunk::<16>().unwrap())) + } + + fn private_account_nonce_increment(self, nsk: &NullifierSecretKey) -> Self { + let mut bytes = [0_u8; 64]; + bytes[..32].copy_from_slice(nsk); + bytes[32..48].copy_from_slice(&self.0.to_le_bytes()); + // bytes[48..64] are zero + let hash: [u8; 32] = sha256(bytes); + Self(u128::from_le_bytes(*hash.first_chunk::<16>().unwrap())) + } +} +``` + +### Account default value + +```rust +Account { + balance: 0, + program_owner: [0; 8], + data: Data::default(), + nonce: 0, +} +``` + +where `Data::default()` is the unique zero-length byte array. + +### Commitment + +The commitment of an account is computed as: + +$$\mathsf{Commitment} = \mathsf{SHA256}(\mathsf{COMMITMENT\_PREFIX} \;||\; \mathsf{account\_id} \;||\; \mathsf{ProgramOwner} \;||\; \mathsf{BalanceBytes} \;||\; \mathsf{NonceBytes} \;||\; \mathsf{DataDigest})$$ + +where + +- `COMMITMENT_PREFIX` is the domain separator: + ```rust + /// ASCII "/LEE/v0.3/Commitment/" zero-padded to 32 bytes + COMMITMENT_PREFIX: [u8; 32] = b"/LEE/v0.3/Commitment/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ``` +- `account_id` is the 32-byte account ID of the private account (which encodes both the owner's `Npk` and the `Identifier`; see the Account ID section above). +- `ProgramOwner` is the 32 bytes of the program owner field encoded as 8 little-endian `u32` words. +- `BalanceBytes` are the 16 bytes of the little-endian representation of the balance. +- `NonceBytes` are the 16 bytes of the little-endian representation of the nonce. +- `DataDigest` are the 32 bytes of the SHA256 digest of the data field. + +The total preimage is 160 bytes. + +```rust +impl Commitment { + fn new(account_id: &AccountId, account: &Account) -> Self { + const COMMITMENT_PREFIX: &[u8; 32] = + b"/LEE/v0.3/Commitment/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + + let mut bytes = Vec::new(); + bytes.extend_from_slice(COMMITMENT_PREFIX); + bytes.extend_from_slice(account_id.value()); + for word in &account.program_owner { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&account.balance.to_le_bytes()); + bytes.extend_from_slice(&account.nonce.to_le_bytes()); + let data_digest: [u8; 32] = sha256(&account.data); + bytes.extend_from_slice(&data_digest); + sha256(bytes) + } +} +``` + +### Nullifier + +A private account's commitment is nullified each time the account's state is updated. There are two methods for computing a nullifier: + +- **Initialization nullifier** (used when the private account is created for the first time): + + $$\mathsf{Nullifier} = \mathsf{SHA256}(\mathsf{INIT\_PREFIX} \;||\; \mathsf{account\_id})$$ + + ```rust + /// ASCII "/LEE/v0.3/Nullifier/Initialize/" zero-padded to 32 bytes + INIT_PREFIX: [u8; 32] = b"/LEE/v0.3/Nullifier/Initialize/\x00" + ``` + +- **Update nullifier** (used when an existing private account's state is updated): + + $$\mathsf{Nullifier} = \mathsf{SHA256}(\mathsf{UPDATE\_PREFIX} \;||\; \mathsf{commitment} \;||\; \mathsf{nsk})$$ + + ```rust + /// ASCII "/LEE/v0.3/Nullifier/Update/" zero-padded to 32 bytes + UPDATE_PREFIX: [u8; 32] = b"/LEE/v0.3/Nullifier/Update/\x00\x00\x00\x00\x00" + ``` + +```rust +impl Nullifier { + fn for_account_initialization(account_id: &AccountId) -> Self { + let mut bytes = INIT_PREFIX.to_vec(); + bytes.extend_from_slice(account_id.value()); + sha256(bytes) + } + + fn for_account_update(commitment: &Commitment, nsk: &NullifierSecretKey) -> Self { + let mut bytes = UPDATE_PREFIX.to_vec(); + bytes.extend_from_slice(&commitment.to_byte_array()); + bytes.extend_from_slice(nsk); + sha256(bytes) + } +} +``` + ### Private account kind and encryption scheme #### PrivateAccountKind @@ -436,7 +439,7 @@ All programs share the same function signature. They take as input: 3. A list of accounts, each annotated with metadata. 4. An instruction-specific data word list. -Programs are treated as blackboxes: given some inputs, they produce a `ProgramOutput` that represents their claimed state transition. All validation is performed on the output, never on the raw inputs. In privacy-preserving transactions the sequencer sees only a ZK proof of the circuit; the circuit cryptographically verifies each program output without having access to the program's inputs. In public transactions the program is executed directly, but the same output-based validation applies — this uniformity is intentional, so that the constraint rules do not differ between the two execution paths. +Programs are treated as blackboxes: given some inputs, they produce a `ProgramOutput` that represents their claimed state transition. All validation is performed on the output, never on the raw inputs. In privacy-preserving transactions the program runs inside an off-chain ZK circuit; the sequencer receives only a proof that the circuit was executed correctly, without seeing the program's inputs. In public transactions the program is executed directly, but the same output-based validation applies — this uniformity is intentional, so that the constraint rules do not differ between the two execution paths. `ProgramOutput` is the program's complete claimed state transition and contains: @@ -611,42 +614,16 @@ 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: - **Plain 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`. - - **Standalone private accounts** (`PrivateAuthorizedInit`, `PrivateAuthorizedUpdate`, `PrivateUnauthorized`): `is_authorized` is not enforced. For the authorized variants, possession of the `nsk` is already implicit proof of ownership; for `PrivateUnauthorized` the pre-state must be `Account::default()` (a fresh account), so there is no prior owner to protect. The claim is therefore allowed unconditionally for all Regular kinds. + - **Standalone private accounts** (`Regular` kind): `is_authorized` is not enforced. For accounts the caller owns, possession of the `nsk` is already implicit proof of ownership. - `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) -```rust -pub struct PdaSeed([u8; 32]); -``` - **Public PDA:** derived from `(program_id, seed)` — see the Account ID section above. **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`. The privacy circuit additionally verifies the address against `AccountId::for_private_pda` using the NPK supplied for that pre-state. - -### ProgramOutput - -Programs do not return a raw tuple; they commit a `ProgramOutput` to the zkVM journal: - -```rust -pub struct ProgramOutput { - pub self_program_id: ProgramId, - pub caller_program_id: Option, - pub instruction_data: InstructionData, - /// The account pre-states the program received as input. - pub pre_states: Vec, - /// The account post-states produced by execution. - pub post_states: Vec, - /// Chained calls to other programs. - pub chained_calls: Vec, - /// Block range within which this output is valid. - pub block_validity_window: BlockValidityWindow, - /// Timestamp range within which this output is valid. - pub timestamp_validity_window: TimestampValidityWindow, -} -``` +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. ### Validity windows