72 KiB
NSSA v0.3 specifications
NSSA v0.3 basic types and constants
type AccountId = [u8; 32];
type NativeToken = u128;
type ProgramId = [u32; 8];
type Data = List<u8>;
type Nonce = u128;
type ByteString = List<u8>;
type InstructionData = List<u32>;
/// Sequencer-supplied block height and timestamp.
type BlockId = u64;
type Timestamp = u64;
/// Diversifies private accounts for a given NPK.
/// A single (nsk, vpk) keypair controls up to 2^128 distinct private accounts,
/// one per identifier value.
type Identifier = u128;
type Commitment = [u8; 32];
struct CommitmentSet {
merkle_tree: MerkleTree,
commitments: HashMap<Commitment, usize>,
root_history: HashSet<CommitmentSetDigest>,
}
type CommitmentSetDigest = [u8; 32];
type MembershipProof = (usize, List<[u8; 32]>);
type Nullifier = [u8; 32];
type NullifierSecretKey = [u8; 32];
type NullifierPublicKey = [u8; 32];
type NullifierSet = BTreeSet<Nullifier>;
/// A secp256k1 scalar (32 bytes).
type EphemeralSecretKey = [u8; 32];
/// A SEC1-compressed secp256k1 point (33 bytes).
type EphemeralPublicKey = [u8; 33];
/// A SEC1-compressed secp256k1 point (33 bytes).
type ViewingPublicKey = [u8; 33];
/// The x-coordinate of the ECDH shared point (32 bytes).
type SharedSecretKey = [u8; 32];
struct EncryptedAccountData {
ciphertext: Ciphertext,
epk: EphemeralPublicKey,
view_tag: u8, // 1-byte view tag
}
/// BIP-340 Schnorr on secp256k1 (x-only pubkeys)
type Signature = [u8; 64];
type PublicKey = [u8; 32]; // 32-byte x-only secp256k1 public key
/// The borsh serialization of a `risc0_zkvm::InnerReceipt`.
type Proof = ByteString;
const DATA_MAX_LENGTH_IN_BYTES = 100 * 1024;
const MAX_NUMBER_CHAINED_CALLS: usize = 10;
const DEFAULT_PROGRAM_ID: ProgramId = [0; 8];
Accounts
All accounts (public and private) share a common schema with standard fields:
struct Account {
program_owner: ProgramId,
balance: NativeToken,
data: Data,
nonce: Nonce,
}
Program owner field
The identification number of the program that can operate on this account's data. It's represented as a [u32; 8] identifier (ProgramId). This field ties the account to a specific program (often called the account's owner program program_owner) which defines the rules for how the account's data can be manipulated.
Balance field
The number of native tokens held by the account. It's represented as a 128-bit number. That means, the total supply of the system should never exceed 2^{128} - 1. As long as that is guaranteed, transfer operations on the balance will not overflow. This is because under normal transfer operations, no account can end up with a balance greater than the total supply of the system. NSSA prevents overflow exploits on the balance field by upcasting each term to u256 when computing the total balances before and after an execution.
Data field
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.
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
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_PREFIXis the domain separator:/// 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_idis the 32-byte account ID of the private account (which encodes both the owner'sNpkand theIdentifier; see Account ID section).ProgramOwneris the 32 bytes of the program owner field encoded as 8 little-endianu32words.BalanceBytesare the 16 bytes of the little-endian representation of the balance.NonceBytesare the 16 bytes of the little-endian representation of the nonce.DataDigestare the 32 bytes of the SHA256 digest of the data field.
The total preimage is 160 bytes.
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})/// 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})/// 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"
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)
}
}
Nullifier public key derivation
The nullifier public key is derived from the nullifier secret key via a pure hash function:
\mathsf{Npk} = \mathsf{SHA256}(\text{"LEE/keys"} \;||\; \mathsf{nsk} \;||\; [7] \;||\; [0; 23])
The total input is 64 bytes: 8 bytes prefix + 32 bytes nsk + 1 byte [7] + 23 bytes zero padding.
impl NullifierPublicKey {
fn from(nsk: &NullifierSecretKey) -> Self {
const PREFIX: &[u8; 8] = b"LEE/keys";
const SUFFIX_1: &[u8; 1] = &[7];
const SUFFIX_2: &[u8; 23] = &[0; 23];
let mut bytes = Vec::new();
bytes.extend_from_slice(PREFIX);
bytes.extend_from_slice(nsk);
bytes.extend_from_slice(SUFFIX_1);
bytes.extend_from_slice(SUFFIX_2);
Self(sha256(bytes))
}
}
Account ID
Public account ID:
\mathsf{AccountId} = \mathsf{SHA256}(\mathsf{PUBLIC\_ACCOUNT\_ID\_PREFIX} \;||\; \mathsf{public\_key})
/// ASCII "/LEE/v0.3/AccountId/Public/" zero-padded to 32 bytes
PUBLIC_ACCOUNT_ID_PREFIX: [u8; 32] = b"/LEE/v0.3/AccountId/Public/\x00\x00\x00\x00\x00"
Private account ID:
\mathsf{AccountId} = \mathsf{SHA256}(\mathsf{PRIVATE\_ACCOUNT\_ID\_PREFIX} \;||\; \mathsf{Npk} \;||\; \mathsf{identifier\_le})
The hash input is 80 bytes: 32-byte prefix + 32-byte Npk + 16-byte little-endian identifier. Each (Npk, identifier) pair yields a distinct account ID, so a single keypair controls up to 2^{128} private accounts.
/// ASCII "/LEE/v0.3/AccountId/Private/" zero-padded to 32 bytes
PRIVATE_ACCOUNT_ID_PREFIX: [u8; 32] = b"/LEE/v0.3/AccountId/Private/\x00\x00\x00\x00"
impl AccountId {
fn from_private(npk: &NullifierPublicKey, identifier: Identifier) -> Self {
let mut bytes = [0_u8; 80];
bytes[0..32].copy_from_slice(PRIVATE_ACCOUNT_ID_PREFIX);
bytes[32..64].copy_from_slice(&npk.0);
bytes[64..80].copy_from_slice(&identifier.to_le_bytes());
sha256(bytes)
}
}
Public program-derived account ID (public PDA):
\mathsf{AccountId} = \mathsf{SHA256}(\mathsf{PUBLIC\_PDA\_PREFIX} \;||\; \mathsf{program\_id} \;||\; \mathsf{seed})
The hash input is 96 bytes: 32-byte prefix + 32-byte program_id (as 8 LE u32 words) + 32-byte seed.
/// ASCII "/NSSA/v0.2/AccountId/PDA/" zero-padded to 32 bytes
PUBLIC_PDA_PREFIX: [u8; 32] = b"/NSSA/v0.2/AccountId/PDA/\x00\x00\x00\x00\x00\x00\x00"
Private program-derived account ID (private PDA):
\mathsf{AccountId} = \mathsf{SHA256}(\mathsf{PRIVATE\_PDA\_PREFIX} \;||\; \mathsf{program\_id} \;||\; \mathsf{seed} \;||\; \mathsf{Npk} \;||\; \mathsf{identifier\_le})
The hash input is 144 bytes: 32 + 32 + 32 + 32 + 16. Unlike public PDAs, the private PDA derivation includes Npk and identifier. This ensures two different users at the same (program_id, seed) get different addresses, and a single user at (program_id, seed, Npk) controls a family of 2^{128} private PDA addresses (one per identifier value).
/// ASCII "/LEE/v0.3/AccountId/PrivatePDA/" zero-padded to 32 bytes
PRIVATE_PDA_PREFIX: [u8; 32] = b"/LEE/v0.3/AccountId/PrivatePDA/\x00"
impl AccountId {
fn for_private_pda(
program_id: &ProgramId,
seed: &PdaSeed,
npk: &NullifierPublicKey,
identifier: Identifier,
) -> Self {
let mut bytes = [0_u8; 144];
bytes[0..32].copy_from_slice(PRIVATE_PDA_PREFIX);
let program_id_bytes: &[u8] = bytemuck::cast_slice(program_id);
bytes[32..64].copy_from_slice(program_id_bytes);
bytes[64..96].copy_from_slice(&seed.0);
bytes[96..128].copy_from_slice(&npk.to_byte_array());
bytes[128..144].copy_from_slice(&identifier.to_le_bytes());
sha256(bytes)
}
}
Private account kind and encryption scheme
PrivateAccountKind
Every private account output is tagged with a PrivateAccountKind that allows the receiver to reconstruct the account ID after decryption, without storing the ID on chain:
pub enum PrivateAccountKind {
Regular(Identifier),
Pda {
program_id: ProgramId,
seed: PdaSeed,
identifier: Identifier,
},
}
The kind is serialized as a fixed 81-byte header prepended to the encrypted account data:
Regular(ident): 0x00 || ident (16 bytes LE) || [0u8; 64]
Pda { program_id, seed, ident }: 0x01 || program_id (8 × u32 LE) || seed (32 bytes) || ident (16 bytes LE)
Both variants produce 81 header bytes, so ciphertext lengths are uniform across account types.
After decryption the receiver reconstructs the account ID from the kind:
Regular(ident)→AccountId::from_private(npk, ident)Pda { program_id, seed, ident }→AccountId::for_private_pda(program_id, seed, npk, ident)
Key agreement and shared secret
When creating a private account output, the sender generates an ephemeral secret key esk and the corresponding ephemeral public key Epk = esk * G. The shared secret is the x-coordinate of the ECDH result (32 bytes, not a SEC1-compressed point):
- Sender:
\mathsf{ss} = x\text{-coordinate of } (\mathsf{esk} \cdot \mathsf{vpk\_recipient}) - Receiver:
\mathsf{ss} = x\text{-coordinate of } (\mathsf{isk} \cdot \mathsf{Epk\_sender})
where vpk is the receiver's ViewingPublicKey (a 33-byte SEC1-compressed secp256k1 point) and isk is the corresponding viewing secret key (a secp256k1 scalar).
KDF
fn kdf(
shared_secret: &SharedSecretKey, // 32-byte x-coordinate
commitment: &Commitment, // 32-byte output commitment
output_index: u32, // index of this output within the tx (LE)
) -> [u8; 32] {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"NSSA/v0.2/KDF-SHA256/");
bytes.extend_from_slice(&shared_secret.0);
bytes.extend_from_slice(&commitment.to_byte_array());
bytes.extend_from_slice(&output_index.to_le_bytes());
sha256(bytes)
}
Encryption
fn encrypt(
account: &Account,
kind: &PrivateAccountKind,
shared_secret: &SharedSecretKey,
commitment: &Commitment,
output_index: u32,
) -> Ciphertext {
// Plaintext: 81-byte kind header || account serialization
let mut buffer = kind.to_header_bytes().to_vec();
buffer.extend_from_slice(&account.to_bytes());
// Apply ChaCha20 keystream with a [0; 12] nonce
let key = kdf(shared_secret, commitment, output_index);
chacha20_xor(&key, &[0u8; 12], &mut buffer);
Ciphertext(buffer)
}
account.to_bytes() serializes the account as:
program_owner (8 × u32 LE) || balance (16 bytes LE) || nonce (16 bytes LE) || data_len (u32 LE) || data
Decryption
fn decrypt(
ciphertext: &Ciphertext,
shared_secret: &SharedSecretKey,
commitment: &Commitment,
output_index: u32,
) -> Option<(PrivateAccountKind, Account)> {
let mut buffer = ciphertext.0.clone();
let key = kdf(shared_secret, commitment, output_index);
chacha20_xor(&key, &[0u8; 12], &mut buffer);
if buffer.len() < PrivateAccountKind::HEADER_LEN {
return None;
}
let header: &[u8; 81] = buffer[..81].try_into().unwrap();
let kind = PrivateAccountKind::from_header_bytes(header)?;
let account = Account::from_bytes(&buffer[81..]).ok()?;
Some((kind, account))
}
Programs
Programs define the logic for operating on accounts. They are stateless and can only execute instructions and modify the state of accounts passed to them. All changes to public or private accounts must be performed through program execution. There's no way to alter account state directly without invoking a program.
A program can only directly modify the state of accounts that are owned by the program. A program can queue other program executions to modify accounts owned by those programs. These queued executions are called chained calls.
All programs share the same function signature. They take as input:
- The executing program's own ID.
- The caller program's ID, or
Noneif this is a top-level call. - A list of accounts, each annotated with metadata.
- 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.
ProgramOutput is the program's complete claimed state transition and contains:
pre_states— the accounts the program claims to have operated on, including their pre-execution state.post_states— the resulting account states after execution.chained_calls— queued executions of other programs.self_program_idandcaller_program_id— used by the verifier to check that the output was produced by the expected program and invoked through the correct call chain.instruction_data— used to verify that each chained call was executed with the instruction the calling program requested.block_validity_windowandtimestamp_validity_window— range constraints on which blocks/timestamps this output is valid for.
Formally:
struct AccountWithMetadata {
account: Account,
is_authorized: bool,
account_id: AccountId,
}
pub struct AccountPostState {
account: Account,
claim: Option<Claim>,
}
pub struct ProgramOutput {
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
instruction_data: InstructionData,
pre_states: Vec<AccountWithMetadata>,
post_states: Vec<AccountPostState>,
chained_calls: Vec<ChainedCall>,
block_validity_window: BlockValidityWindow,
timestamp_validity_window: TimestampValidityWindow,
}
/// A claim request indicating the executing program intends to take ownership of an account.
pub enum Claim {
/// Standard claim path, used for all account kinds that are not self-owned PDAs. Succeeds
/// when the account's `is_authorized` flag is true (public accounts, public PDAs, private
/// PDAs), or unconditionally for standalone private accounts.
Authorized,
/// Ownership via a PDA seed. Only valid for PDAs owned by the executing program itself:
/// the AccountId must match the derivation from (self_program_id, seed) for public PDAs,
/// or from (self_program_id, seed, npk, identifier) for private PDAs.
Pda(PdaSeed),
}
pub struct ChainedCall {
pub program_id: ProgramId,
pub pre_states: Vec<AccountWithMetadata>,
pub instruction_data: InstructionData,
/// PDA seeds authorized for the callee. For each seed, the callee is authorized to
/// mutate the AccountId derived from (caller_program_id, seed) — whether public or private.
pub pda_seeds: Vec<PdaSeed>,
}
type Program = fn(
ProgramId, Option<ProgramId>, List<AccountWithMetadata>, InstructionData
) -> ProgramOutput;
The verifier validates that a ProgramOutput satisfies the following constraints:
- The output's
pre_statescontain unique account IDs. EachAccountIdin the list is unique. - The output's
pre_statesandpost_stateshave the same lengthN. - Program cannot update an account's nonce. For all
i in 0..N,pre_states[i].account.nonce == post_states[i].account.nonce. - Program cannot change the program owner of an account. For all
i in 0..N,pre_states[i].account.program_owner == post_states[i].account.program_owner. - Program can only decrease the native token balance for accounts that the program owns. For all
i in 0..N, ifpost_states[i].account.balance < pre_states[i].account.balance, thenpre_states[i].account.program_owner == executing_program_id. - Program can only change an account's data for accounts that the program owns (or if the account is default). For all
i in 0..N, ifpre_states[i].account.data != post_states[i].account.datathen eitherpre_states[i].account == Account::default()orpre_states[i].account.program_owner == executing_program_id. - Any account that has default program owner after execution must have been a default account before execution. For all
i in 0..N, ifpost_states[i].account.program_owner == DEFAULT_PROGRAM_IDthenpre_states[i].account == Account::default(). - The sum of balances across all
pre_statesequals the sum across allpost_states.
In pseudocode:
pub fn validate_execution(
pre_states: &[AccountWithMetadata],
post_states: &[AccountPostState],
executing_program_id: ProgramId,
) -> Result<(), ExecutionValidationError> {
// 1. Check account ids are all different
if !validate_uniqueness_of_account_ids(pre_states) {
return Err(ExecutionValidationError::PreStateAccountIdsNotUnique);
}
// 2. Lengths must match
if pre_states.len() != post_states.len() {
return Err(ExecutionValidationError::MismatchedPreStatePostStateLength { .. });
}
for (pre, post) in pre_states.iter().zip(post_states) {
// 3. Nonce must remain unchanged
if pre.account.nonce != post.account.nonce {
return Err(ExecutionValidationError::ModifiedNonce { .. });
}
// 4. Program ownership changes are not allowed
if pre.account.program_owner != post.account.program_owner {
return Err(ExecutionValidationError::ModifiedProgramOwner { .. });
}
let account_program_owner = pre.account.program_owner;
// 5. Decreasing balance only allowed if owned by executing program
if post.account.balance < pre.account.balance
&& account_program_owner != executing_program_id
{
return Err(ExecutionValidationError::UnauthorizedBalanceDecrease { .. });
}
// 6. Data changes only allowed if owned by executing program or if account is default
if pre.account.data != post.account.data
&& pre.account != Account::default()
&& account_program_owner != executing_program_id
{
return Err(ExecutionValidationError::UnauthorizedDataModification { .. });
}
// 7. If post state has default program owner, pre state must have been default
if post.account.program_owner == DEFAULT_PROGRAM_ID && pre.account != Account::default() {
return Err(ExecutionValidationError::NonDefaultAccountWithDefaultOwner { .. });
}
}
// 8. Total balance is preserved
let total_pre = WrappedBalanceSum::from_balances(pre_states.iter().map(|p| p.account.balance));
let total_post = WrappedBalanceSum::from_balances(post_states.iter().map(|p| p.account.balance));
if total_pre != total_post {
return Err(ExecutionValidationError::MismatchedTotalBalance { .. });
}
Ok(())
}
The is_authorized flag in AccountWithMetadata indicates whether the account owner has provided authorization. It is the program's responsibility to check it where needed. Authorization is granted via different mechanisms depending on the account type:
- For public accounts: through digital signatures.
- For private accounts: through knowledge proofs of the corresponding nullifier secret key.
Account authority vs program ownership
is_authorized and program_owner serve distinct purposes:
is_authorizedindicates whether the account owner has authorized the account to be used in this transaction. For public accounts this comes from a signature; for private accounts from a valid nullifier secret key proof. For PDAs it is established through thepda_seedsmechanism.program_ownerindicates which program can mutate the account's state. It is set once via the claiming mechanism and cannot be changed by programs directly.
Account claiming mechanism
Programs output a list of AccountPostState, each optionally carrying a Claim:
impl AccountPostState {
/// No claim — executing program does not request ownership.
pub fn new(account: Account) -> Self { Self { account, claim: None } }
/// Always claims ownership with the given claim type.
pub fn new_claimed(account: Account, claim: Claim) -> Self {
Self { account, claim: Some(claim) }
}
/// Claims ownership only if account.program_owner == DEFAULT_PROGRAM_ID.
pub fn new_claimed_if_default(account: Account, claim: Claim) -> Self {
let is_default = account.program_owner == DEFAULT_PROGRAM_ID;
Self { account, claim: is_default.then_some(claim) }
}
}
After execution, the runtime processes each post-state's optional claim:
Claim::Authorized— the standard claim path: setsprogram_owner = executing_program_id(when currently default) for all account kinds except self-owned PDAs. Whetheris_authorizedis required depends on the account kind:- Plain public accounts:
is_authorizedmust betrue, set when the transaction signer included the account in the authorized set. - Public and private PDAs:
is_authorizedmust betrue, set when the caller included the matching seed inChainedCall.pda_seeds. - Standalone private accounts (
PrivateAuthorizedInit,PrivateAuthorizedUpdate,PrivateUnauthorized):is_authorizedis not enforced. For the authorized variants, possession of thenskis already implicit proof of ownership; forPrivateUnauthorizedthe pre-state must beAccount::default()(a fresh account), so there is no prior owner to protect. The claim is therefore allowed unconditionally for all Regular kinds.
- Plain public accounts:
Claim::Pda(seed)— setsprogram_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. UnlikeClaim::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 isAccountId::for_public_pda(executing_program_id, seed); for private PDAs it isAccountId::for_private_pda(executing_program_id, seed, npk, identifier)using the npk supplied for that pre-state, making the claim user-specific.Claim::Pdais not applicable to standalone private accounts.
Program-derived account IDs (PDAs)
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:
pub struct ProgramOutput {
pub self_program_id: ProgramId,
pub caller_program_id: Option<ProgramId>,
pub instruction_data: InstructionData,
/// The account pre-states the program received as input.
pub pre_states: Vec<AccountWithMetadata>,
/// The account post-states produced by execution.
pub post_states: Vec<AccountPostState>,
/// Chained calls to other programs.
pub chained_calls: Vec<ChainedCall>,
/// 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,
}
Validity windows
Programs can constrain when their outputs are accepted by the sequencer:
/// A half-open interval [from, to) with optional bounds.
/// None means unbounded on that side.
pub struct ValidityWindow<T> {
from: Option<T>,
to: Option<T>,
}
pub type BlockValidityWindow = ValidityWindow<BlockId>;
pub type TimestampValidityWindow = ValidityWindow<Timestamp>;
An output outside its window is rejected at the sequencer level before any state transition is applied.
NSSA v0.3 state
struct NssaState {
public_state: Map<AccountId, Account>,
private_state: (CommitmentSet, NullifierSet),
programs: Map<ProgramId, Program>,
}
public_state: A map from each account ID to its correspondingAccountfor all public accounts. The entireAccountIdspace is conceptually populated; account IDs not explicitly stored are treated as having default values (sparse representation).private_state: A combination of two structures tracking private account state:CommitmentSet: An authenticated Merkle tree of all existing private account commitments.NullifierSet: ABTreeSetof all revealed nullifiers.
programs: A map fromProgramIdto program bytecode for each deployed program.
The CommitmentSet exposes:
insert(element)— add a commitment.get_authentication_path_for(element)— Merkle inclusion path.compute_digest_for_path(element, proof)— verify inclusion against a root.is_current_or_previous_digest(digest)— check against the root history.
The NullifierSet is a plain ordered set.
Dummy commitment and dummy commitment hash
Two special constants are derived from the default account and the null account ID [0; 32]:
/// DUMMY_COMMITMENT = Commitment::new(&AccountId([0; 32]), &Account::default())
/// Concretely: SHA256(COMMITMENT_PREFIX || [0]*32 || [0]*32 || [0]*16 || [0]*16 || SHA256([]))
pub const DUMMY_COMMITMENT: Commitment = Commitment([
55, 228, 215, 207, 112, 221, 239, 49, 238, 79, 71, 135, 155, 15, 184, 45, 104, 74, 51, 211,
238, 42, 160, 243, 15, 124, 253, 62, 3, 229, 90, 27,
]);
pub const DUMMY_COMMITMENT_HASH: [u8; 32] = [
250, 237, 192, 113, 155, 101, 119, 30, 235, 183, 20, 84, 26, 32, 196, 229, 154, 74, 254, 249,
129, 241, 118, 39, 41, 253, 141, 171, 184, 71, 8, 41,
];
DUMMY_COMMITMENT is the commitment of the default account (Account::default()) under the null account ID ([0; 32]). It is not a real user account: no keys exist that could spend it, so it can never be nullified.
At genesis, the CommitmentSet is initialized by inserting DUMMY_COMMITMENT as its first entry. This bootstraps the Merkle tree before any real private accounts exist and gives the set a well-defined root from the very first state. Because the Merkle tree hashes each leaf as SHA256(value), the root of a tree containing only DUMMY_COMMITMENT is SHA256(DUMMY_COMMITMENT) — which is exactly DUMMY_COMMITMENT_HASH. As a result, DUMMY_COMMITMENT_HASH is permanently present in the CommitmentSet's root_history from genesis onward.
Role in initialization nullifiers. Every nullifier submitted in a PrivacyPreservingTransaction must be paired with a CommitmentSetDigest. For update nullifiers this is the Merkle root that was current when the sender computed their membership proof. For init nullifiers — emitted when a private account is created for the first time (PrivateAuthorizedInit, PrivateUnauthorized, PrivatePdaInit) — no prior commitment exists to be spent, so there is no natural Merkle root to cite. Rather than special-casing this in the sequencer's acceptance logic, init nullifiers uniformly use DUMMY_COMMITMENT_HASH as their digest. Since DUMMY_COMMITMENT_HASH is always in root_history, the standard root_history.contains(digest) check accepts them without any extra branching — the same validation path covers both init and update nullifiers.
Built-in programs
NSSA v0.3 supports the following built-in programs, loaded at genesis. They are immutable and identified by unique ProgramId values.
Authorized transfer program
Moves native tokens from a source account to a destination account, requiring the source account to be authorized. Has two modes selected by the instruction (u128):
balance_to_move == 0with a single pre-state: claims the pre-state account on behalf of the caller (a self-init).balance_to_move != 0with two pre-states[sender, recipient]: standard transfer. The recipient is claimed only if itsprogram_owneris currently the default — pre-existing accounts owned by other programs keep their owner.
/// Instruction data: u128 amount to transfer (0 means initialize-account).
fn transfer_authorized(
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
pre_states: Vec<AccountWithMetadata>,
balance_to_move: u128,
) -> (Vec<AccountWithMetadata>, Vec<AccountPostState>, Vec<ChainedCall>) {
let post_states = match (pre_states.as_slice(), balance_to_move) {
([account_to_claim], 0) => {
assert_eq!(account_to_claim.account, Account::default());
assert!(account_to_claim.is_authorized);
vec![AccountPostState::new_claimed(
account_to_claim.account.clone(),
Claim::Authorized,
)]
}
([sender, recipient], balance_to_move) => {
assert!(sender.is_authorized, "Sender must be authorized");
let mut sender_post = sender.account.clone();
sender_post.balance = sender_post.balance.checked_sub(balance_to_move).unwrap();
let mut recipient_post = recipient.account.clone();
recipient_post.balance = recipient_post.balance.checked_add(balance_to_move).unwrap();
vec![
AccountPostState::new(sender_post),
AccountPostState::new_claimed_if_default(recipient_post, Claim::Authorized),
]
}
_ => panic!("invalid params"),
};
(pre_states, post_states, vec![])
}
Piñata program
Distributes a fixed prize of native tokens to the account that provides the correct solution to a proof-of-work–style challenge stored in the pinata account's data. Used for native token distribution during testing.
The pinata account's data is a 33-byte buffer: [difficulty (1 byte), seed (32 bytes)]. A solution s: u128 is valid iff the leftmost difficulty bytes of SHA256(seed || s_le_bytes) are all zero. After a winning solution the seed is rotated to SHA256(seed).
const PRIZE: u128 = 150;
/// Instruction data: u128 solution.
fn pinata(
self_program_id: ProgramId,
caller_program_id: Option<ProgramId>,
pre_states: Vec<AccountWithMetadata>,
solution: u128,
) -> (Vec<AccountWithMetadata>, Vec<AccountPostState>, Vec<ChainedCall>) {
let [pinata, winner] = <[_; 2]>::try_from(pre_states.clone()).unwrap();
let challenge = Challenge::parse(&pinata.account.data);
if !challenge.validate_solution(solution) {
// No valid solution: emit unchanged states and return.
let post_states = vec![
AccountPostState::new(pinata.account.clone()),
AccountPostState::new(winner.account.clone()),
];
return (pre_states, post_states, vec![]);
}
let mut pinata_post = pinata.account.clone();
let mut winner_post = winner.account.clone();
pinata_post.balance = pinata_post.balance.checked_sub(PRIZE).unwrap();
pinata_post.data = challenge.next_data().to_vec().try_into().unwrap();
winner_post.balance = winner_post.balance.checked_add(PRIZE).unwrap();
let post_states = vec![
// Pinata is claimed if its program_owner is currently default
// (so the very first invocation locks it under the pinata program).
AccountPostState::new_claimed_if_default(pinata_post, Claim::Authorized),
AccountPostState::new(winner_post),
];
(pre_states, post_states, vec![])
}
Token program
Manages user-defined fungible and non-fungible tokens. Each token consists of a definition account (immutable rules) and one or more holding accounts (per-owner balances). Optional metadata is stored in a separate metadata account.
pub enum TokenDefinition {
Fungible {
name: String,
total_supply: u128,
metadata_id: Option<AccountId>,
},
NonFungible {
name: String,
printable_supply: u128,
metadata_id: AccountId,
},
}
pub enum TokenHolding {
/// Balance of a fungible token.
Fungible { definition_id: AccountId, balance: u128 },
/// Master holding of an NFT collection. `print_balance` is the number of printable copies left.
NftMaster { definition_id: AccountId, print_balance: u128 },
/// A printed instance of an NFT.
NftPrintedCopy { definition_id: AccountId, owned: bool },
}
pub struct TokenMetadata {
pub definition_id: AccountId,
pub standard: MetadataStandard,
pub uri: String,
pub creators: Vec<...>,
pub primary_sale_date: u64,
}
Instructions:
Transfer { amount_to_transfer: u128 }— accounts:[sender_holding, recipient_holding]. Sender must be authorized. Recipient is auto-initialized whenAccount::default(). NFT master copies and printed copies are also transferred via this instruction (withamount_to_transfer == print_balancefor masters and1for printed copies). The recipient holding is claimed only if itsprogram_owneris currently default (new_claimed_if_default).NewFungibleDefinition { name: String, total_supply: u128 }— accounts:[definition_target, holding_target]. Both must beAccount::default(). The definition holds the supply; the holding is initialized tototal_supply. Both are claimed (new_claimed).NewDefinitionWithMetadata { new_definition, metadata }— accounts:[definition_target, holding_target, metadata_target]. Same as above plus a metadata account; supports both fungible and non-fungible variants. All three are claimed.InitializeAccount— accounts:[definition, account_to_initialize]. Creates a zero-balance holding bound todefinition.account_id. Only the holding is claimed.Burn { amount_to_burn: u128 }— accounts:[definition, user_holding]. Holding must be authorized. Decreases bothholding.balanceanddefinition.total_supply. Neither account is claimed.Mint { amount_to_mint: u128 }— accounts:[definition, holding_target]. Definition must be authorized. Increases bothholding.balanceanddefinition.total_supply. Holding is claimed if currently default.PrintNft— accounts:[nft_master_holding, nft_printed_copy_target]. Decrementsprint_balanceon the master and produces a new printed copy holding.
All Token Program operations check that the definition_id referenced by each holding matches the supplied definition account.
AMM program
Manages liquidity pools for token pairs; supports pool initialization, add/remove liquidity, and swaps.
Pool Definition Account:
struct PoolDefinition {
definition_token_a_id: AccountId,
definition_token_b_id: AccountId,
vault_a_id: AccountId,
vault_b_id: AccountId,
liquidity_pool_id: AccountId,
liquidity_pool_supply: u128,
reserve_a: u128,
reserve_b: u128,
fees: u128,
active: bool,
}
Initialize pool:
Creates the pool definition, two vaults (one per token), and an LP token definition. Deposits the initial liquidity through chained Token Program calls.
Add liquidity:
Deposits tokens into the vaults in proportion to the current reserves. Mints LP tokens to the depositor through chained Token Program calls. The LP amount is calculated as:
let delta_lp = min(
pool_supply * actual_amount_a / reserve_a,
pool_supply * actual_amount_b / reserve_b,
);
Remove liquidity:
Burns LP tokens and withdraws proportional amounts of each token from the vaults:
let withdraw_a = (reserve_a * amount_lp) / pool_supply;
let withdraw_b = (reserve_b * amount_lp) / pool_supply;
Swap:
Executes a constant-product AMM swap. For a deposit of amount_in of one token:
let amount_out = (reserve_out * amount_in) / (reserve_in + amount_in);
All AMM operations use chained calls to the Token Program for the actual token movements between user holding accounts and vault accounts. PDAs are used to authorize vault transfers.
Associated Token Account (ATA) program
Provides a deterministic, per-(owner, token_definition) holding-account address derived as a public PDA of the ATA program. Removes the need for users to manage explicit token-holding account IDs; given an owner account and a token definition, anyone can compute the corresponding ATA address.
pub fn compute_ata_seed(owner_id: AccountId, definition_id: AccountId) -> PdaSeed {
PdaSeed::new(SHA256(owner_id || definition_id))
}
pub fn get_associated_token_account_id(ata_program_id: &ProgramId, seed: &PdaSeed) -> AccountId {
AccountId::for_public_pda(ata_program_id, seed)
}
Instructions:
Create { ata_program_id }— accounts:[owner, token_definition, ata_account]. Creates the ATA for(owner, definition)if it does not already exist (idempotent). Chains to the Token Program'sInitializeAccountto populate the holding data.Transfer { ata_program_id, amount }— accounts:[owner, sender_ata, recipient_holding]. Owner must be authorized. The ATA program verifiessender_ata.account_id == for_public_pda(ata_program_id, compute_ata_seed(owner.id, definition_id)), then chains to the Token Program'sTransfer, passing the ATA seed inpda_seedsto authorize the ATA inside the Token Program.Burn { ata_program_id, amount }— accounts:[owner, holder_ata, token_definition]. Same PDA-seed authorization mechanism asTransfer; chains to the Token Program'sBurn.
The ata_program_id is passed as part of the instruction (rather than read from self_program_id) so it can be precomputed by callers without invoking the program.
Clock program
Records the current block ID and timestamp into three dedicated clock accounts, updated at different cadences (every 1, 10, and 50 blocks). Programs that need recent timestamps can read whichever granularity matches their needs.
pub const CLOCK_01_PROGRAM_ACCOUNT_ID: AccountId = AccountId::new(*b"/LEZ/ClockProgramAccount/0000001");
pub const CLOCK_10_PROGRAM_ACCOUNT_ID: AccountId = AccountId::new(*b"/LEZ/ClockProgramAccount/0000010");
pub const CLOCK_50_PROGRAM_ACCOUNT_ID: AccountId = AccountId::new(*b"/LEZ/ClockProgramAccount/0000050");
pub struct ClockAccountData {
pub block_id: u64,
pub timestamp: Timestamp,
}
The clock accounts are created at genesis and assigned program_owner = clock_program_id, so no claiming is required at runtime. The Clock Program is invoked exclusively by the sequencer as the last transaction in every block: users cannot invoke it directly. Its single instruction is the new block's Timestamp.
On execution, the program reads block_id from the 01 account, increments it, and updates each of the three clock accounts only when new_block_id is a multiple of the corresponding cadence.
Built-in program registry
The genesis state ships with the following built-in programs registered:
- Authorized transfer (
AUTHENTICATED_TRANSFER_ID) - Token (
TOKEN_ID) - AMM (
AMM_ID) - Associated Token Account (
ASSOCIATED_TOKEN_ACCOUNT_ID) - Clock (
CLOCK_ID) - Piñata (
PINATA_ID) — testnet only - Piñata Token (
PINATA_TOKEN_ID) — testnet only
All built-in programs are part of the trusted computing base, registered at genesis, and immutable. Additional user-defined programs may also be deployed via ProgramDeploymentTransaction (see "State transition from program deployment transactions").
Structure of a public transaction
struct Message {
program_id: ProgramId,
account_ids: List<AccountId>,
nonces: List<Nonce>,
instruction_data: InstructionData,
}
type WitnessSet = List<(Signature, PublicKey)>;
struct PublicTransaction {
message: Message,
witness_set: WitnessSet,
}
The message hash prefix is:
/// ASCII "/LEE/v0.3/Message/Public/" zero-padded to 32 bytes
PREFIX: [u8; 32] = b"/LEE/v0.3/Message/Public/\x00\x00\x00\x00\x00\x00\x00"
The message hash is SHA256(PREFIX || borsh_serialize(message)).
Message
program_id: TheProgramIdof the program to invoke.account_ids: The list of relevant account IDs that the program will operate on.nonces: One nonce per signer (public account). Each must match the current nonce of the corresponding account in state.instruction_data: Parameters encoded as a list ofu32words.
Witness set
A list of (signature, public_key) pairs. Each pair must produce a valid BIP-340 Schnorr signature over the message hash. The accounts derived from each public_key (via AccountId::from(&public_key)) form the authorized signer set. The program receives is_authorized = true for any pre-state account in this set (programs use the flag to decide whether to permit user-driven actions like transfers). Programs may also gain authorization for additional accounts via the PDA mechanism in chained calls. Upon acceptance, each signing account's nonce is incremented by 1.
Structure of a privacy-preserving transaction
struct Message {
public_account_ids: List<AccountId>,
nonces: List<Nonce>,
public_post_states: List<Account>,
encrypted_private_post_states: List<EncryptedAccountData>,
new_commitments: List<Commitment>,
new_nullifiers: List<(Nullifier, CommitmentSetDigest)>,
block_validity_window: BlockValidityWindow,
timestamp_validity_window: TimestampValidityWindow,
}
struct WitnessSet {
signatures_and_public_keys: List<(Signature, PublicKey)>,
proof: Proof,
}
struct PrivacyPreservingTransaction {
message: Message,
witness_set: WitnessSet,
}
The message hash prefix is:
/// ASCII "/LEE/v0.3/Message/Privacy/" zero-padded to 32 bytes
PREFIX: [u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00"
The hash is SHA256(PREFIX || borsh_serialize(message)).
Message
public_account_ids: Account IDs of all public accounts involved.nonces: One nonce per signing public account.public_post_states: New states of any public accounts modified by this transaction.encrypted_private_post_states: Encrypted details of each new private account output, decryptable by the respective recipient.new_commitments: New commitment values added to theCommitmentSet.new_nullifiers: Nullifiers revealed by this transaction, each paired with theCommitmentSetDigestof the commitment being spent.block_validity_window/timestamp_validity_window: Time-bound constraints propagated from theProgramOutput. Outputs outside their window are rejected.
Witness set
signatures_and_public_keys: BIP-340 Schnorr signature pairs for any public accounts requiring authorization.proof: A borsh-serializedrisc0_zkvm::InnerReceiptproving correct execution of the privacy-preserving circuit.
The privacy-preserving execution circuit
The circuit is a RISC-V program proven with the risc0 zkVM. It is executed entirely off-chain by the transaction sender; the sequencer only verifies the proof. It is not a built-in program in the NSSA sense — it wraps the execution of built-in programs inside a ZK proof.
The circuit supports a single chain of tail-calls (each program invocation may chain to at most one subsequent program).
Circuit input
pub struct PrivacyPreservingCircuitInput {
/// Outputs of the program execution chain.
pub program_outputs: Vec<ProgramOutput>,
/// One entry per pre-state, in the same order as they appear across program_outputs.
pub account_identities: Vec<InputAccountIdentity>,
/// Top-level program ID.
pub program_id: ProgramId,
}
pub enum InputAccountIdentity {
/// Public account. The guest reads pre/post state from program_outputs and emits no
/// commitment, ciphertext, or nullifier.
Public,
/// Initialization of a standalone private account the caller owns.
/// Pre-state must be Account::default().
/// AccountId = AccountId::from_private(npk(nsk), identifier).
PrivateAuthorizedInit {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
identifier: Identifier,
},
/// Update of an existing standalone private account the caller owns.
/// Membership proof for the current on-chain commitment is required.
PrivateAuthorizedUpdate {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
},
/// Initialization of a standalone private account the caller does not own
/// (e.g. a recipient who does not yet exist on chain). No nsk, no membership proof.
PrivateUnauthorized {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
},
/// Initialization of a private PDA.
/// Authorization comes via Claim::Pda(seed) or the caller's pda_seeds.
/// The identifier diversifies the PDA within the (program_id, seed, npk) family.
PrivatePdaInit {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
},
/// Update of an existing private PDA. npk is derived from nsk.
/// Membership proof is required.
PrivatePdaUpdate {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
},
}
The ssk (shared secret key) is the 32-byte x-coordinate of the ECDH shared point between the sender's ephemeral key and the recipient's viewing public key. It is used to encrypt the post-state.
Circuit output
pub struct PrivacyPreservingCircuitOutput {
pub public_pre_states: Vec<AccountWithMetadata>,
pub public_post_states: Vec<Account>,
pub ciphertexts: Vec<Ciphertext>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>,
pub block_validity_window: BlockValidityWindow,
pub timestamp_validity_window: TimestampValidityWindow,
}
Circuit logic summary
For each InputAccountIdentity the circuit performs the following:
| Variant | AccountId derivation | Nonce init | Nullifier emitted | Auth required | Claim::Authorized precondition |
|---|---|---|---|---|---|
Public |
from pre-state | +1 if authorized | none | signature | is_authorized must be true |
PrivateAuthorizedInit |
from_private(npk(nsk), ident) |
nonce_init(account_id) |
init nullifier | nsk | not enforced (skipped) |
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 | is_authorized must be true |
PrivatePdaUpdate |
for_private_pda(prog, seed, npk(nsk), ident) |
nonce_increment(nsk) |
update nullifier | nsk + membership proof | is_authorized must be true |
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), because operating these accounts already requires possession of the corresponding nsk. For public accounts and private PDAs, Claim::Authorized enforcement matches the public path.
Workflow:
- Verify that each
ProgramOutputin the chain is a valid proof of execution for the corresponding program. - Verify that
validate_executionpasses for each program call. - Check that chained-call instruction data and accounts are consistent across caller/callee boundaries.
- For each account:
- Public: collect pre/post state; increment nonce if authorized.
- Private init: verify pre-state is default; derive account_id; compute init nullifier; set nonce via
nonce_init; encrypt post-state. - Private update: verify membership proof; compute update nullifier; increment nonce via
nonce_increment; encrypt post-state.
- Emit
PrivacyPreservingCircuitOutput.
Encrypted private account discovery and tagging
Ephemeral view tags
Each private account output includes a 1-byte view tag to allow wallets to quickly filter outputs before attempting decryption:
\mathsf{ViewTag} = \mathsf{SHA256}(\text{"/LEE/v0.3/ViewTag/"} \;||\; \mathsf{Npk} \;||\; \mathsf{vpk})[0]
where Npk is the 32-byte nullifier public key and vpk is the 33-byte SEC1-compressed ViewingPublicKey of the recipient. On average only 1 in 256 outputs will pass this filter for a given account, avoiding expensive ECDH on irrelevant outputs.
Private account discovery with viewing keys
- For each encrypted output, compute the expected view tag from
(Npk, vpk). Skip if it does not match. - Perform ECDH:
ss = x-coordinate of (isk * Epk). - Run
kdf(ss, commitment, output_index)to derive the symmetric key. - Decrypt the ciphertext with ChaCha20.
- Parse the 81-byte header to recover
PrivateAccountKind. - Parse the remaining bytes to recover the
Account. - Recompute the account ID from the kind and verify that
Commitment::new(account_id, account)equals the on-chain commitment. Discard on mismatch (false positive).
fn private_account_discovery(
tx: &PrivacyPreservingTransaction,
isk: &ViewingSecretKey,
npk: &NullifierPublicKey,
vpk: &ViewingPublicKey,
) -> Vec<(PrivateAccountKind, Account)> {
let expected_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
let mut discovered = Vec::new();
for (output_index, (encrypted_account, commitment)) in tx.message.encrypted_private_post_states
.iter()
.zip(&tx.message.new_commitments)
.enumerate()
{
if encrypted_account.view_tag != expected_tag {
continue;
}
let ss = SharedSecretKey::new(isk, &encrypted_account.epk);
if let Some((kind, account)) = EncryptionScheme::decrypt(
&encrypted_account.ciphertext, &ss, commitment, output_index as u32
) {
let account_id = AccountId::for_private_account(npk, &kind);
if Commitment::new(&account_id, &account) == *commitment {
discovered.push((kind, account));
}
}
}
discovered
}
Domain separator summary
| Purpose | Domain separator |
|---|---|
| Commitment | b"/LEE/v0.3/Commitment/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" |
| Nullifier — initialization | b"/LEE/v0.3/Nullifier/Initialize/\x00" |
| Nullifier — update | b"/LEE/v0.3/Nullifier/Update/\x00\x00\x00\x00\x00" |
| Account ID — public | b"/LEE/v0.3/AccountId/Public/\x00\x00\x00\x00\x00" |
| Account ID — private | b"/LEE/v0.3/AccountId/Private/\x00\x00\x00\x00" |
| Account ID — public PDA | b"/NSSA/v0.2/AccountId/PDA/\x00\x00\x00\x00\x00\x00\x00" |
| Account ID — private PDA | b"/LEE/v0.3/AccountId/PrivatePDA/\x00" |
| Nullifier public key (from NSK) | b"LEE/keys" + nsk + [7] + [0; 23] |
| KDF | b"NSSA/v0.2/KDF-SHA256/" |
| View tag | b"/LEE/v0.3/ViewTag/" |
| Public transaction message hash | b"/LEE/v0.3/Message/Public/\x00\x00\x00\x00\x00\x00\x00" |
| Privacy transaction message hash | b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00" |
Public transaction acceptance criteria
For a public transaction to be accepted and applied to the state:
- No duplicate account IDs: The
account_idslist must not contain repeated entries. - Signature/nonce count match: The number of nonces in the message must equal the number of
(signature, public_key)pairs in the witness set. - Valid signatures: All BIP-340 Schnorr signatures must verify against the message hash.
- Nonce checks: For each signing account, the nonce in the transaction must match the account's current nonce in state.
- Program existence: The
program_idmust correspond to a deployed program. - Valid execution: The program is invoked with the provided accounts and instruction data.
validate_executionmust pass. Accounts are assembled asAccountWithMetadatawithis_authorized = truefor accounts whose public key appears in the witness set, plus any PDAs authorized via thepda_seedsmechanism in chained calls. - Pre-state consistency: Each
program_output.pre_states[i]must equal the current state ofpre_states[i].account_id(or the diffed value from earlier chained calls). Theis_authorizedflag in each pre-state must match the actual authorization status. - Program identity consistency: Each program output's
self_program_idmust equal the program ID it was invoked under, and itscaller_program_idmust equal the caller (orNonefor the top-level call). - Validity window enforcement: For each
ProgramOutputin the chain (top-level and chained calls), the currentblock_idmust fall withinprogram_output.block_validity_windowand the currenttimestampmust fall withinprogram_output.timestamp_validity_window. Out-of-window outputs are rejected. - Maximum chained-call depth: The total number of program executions (including the top-level call) must not exceed
MAX_NUMBER_CHAINED_CALLS(10). - Claiming: Any
AccountPostStatewith aClaimcauses the runtime to setprogram_owner = executing_program_idfor that account, but only if the account's currentprogram_owner == DEFAULT_PROGRAM_ID. In the public path all accounts are public, soClaim::Authorizedalways requiresis_authorized == true(signature, or PDA via caller'spda_seeds), andClaim::Pda(seed)requires the account ID to matchAccountId::for_public_pda(executing_program_id, seed). (The privacy path relaxes theClaim::Authorizedprecondition for standalone private accounts — see the Programs section.) - No silent default-account modifications: Any account whose pre-state has
program_owner == DEFAULT_PROGRAM_IDand whose post-state differs from the pre-state must have been claimed (i.e. its post-stateprogram_owneris no longer the default).
In pseudocode:
fn validate_and_produce_public_state_diff(
tx: &PublicTransaction,
nssa_state: &NssaState,
block_id: BlockId,
timestamp: Timestamp,
) -> HashMap<AccountId, Account> {
let message = &tx.message;
let witness_set = &tx.witness_set;
// No duplicate account ids
assert_eq!(
message.account_ids.iter().collect::<HashSet<_>>().len(),
message.account_ids.len()
);
// One nonce per signature
assert_eq!(message.nonces.len(), witness_set.signatures_and_public_keys.len());
// Verify signatures and nonces.
let mut signer_account_ids = Vec::new();
for ((signature, public_key), nonce) in
witness_set.signatures_and_public_keys.iter().zip(&message.nonces)
{
assert!(signature.is_valid_for(&message.hash(), public_key));
let account_id = AccountId::from(public_key);
let current_nonce = nssa_state.public_state.get(&account_id).nonce;
assert_eq!(current_nonce, *nonce);
signer_account_ids.push(account_id);
}
let input_pre_states: Vec<AccountWithMetadata> = message.account_ids.iter().map(|id| {
AccountWithMetadata {
account: nssa_state.public_state.get(id).clone(),
is_authorized: signer_account_ids.contains(id),
account_id: *id,
}
}).collect();
let mut state_diff: HashMap<AccountId, Account> = HashMap::new();
// The chained-call queue. Each entry carries the call to execute and the program ID
// of its caller (None for the top-level call).
let initial_call = ChainedCall {
program_id: message.program_id,
instruction_data: message.instruction_data.clone(),
pre_states: input_pre_states,
pda_seeds: vec![],
};
let mut queue: VecDeque<(ChainedCall, Option<ProgramId>)> =
VecDeque::from_iter([(initial_call, None)]);
let mut counter: usize = 0;
while let Some((call, caller_program_id)) = queue.pop_front() {
assert!(counter <= MAX_NUMBER_CHAINED_CALLS);
let program = nssa_state.programs.get(&call.program_id).expect("Unknown program");
let program_output = program.execute(
call.program_id,
caller_program_id,
&call.pre_states,
&call.instruction_data,
);
// Compute the set of public PDAs the callee is authorized to mutate via the
// caller's pda_seeds. For the top-level call (caller_program_id == None) this is
// always the empty set.
let authorized_pdas =
compute_public_authorized_pdas(caller_program_id, &call.pda_seeds);
let is_authorized = |account_id: &AccountId| {
signer_account_ids.contains(account_id) || authorized_pdas.contains(account_id)
};
// Verify pre-state and authorization consistency: programs cannot fabricate inputs.
for pre in &program_output.pre_states {
let expected_pre = state_diff
.get(&pre.account_id)
.cloned()
.unwrap_or_else(|| nssa_state.public_state.get(&pre.account_id).clone());
assert_eq!(pre.account, expected_pre);
assert_eq!(pre.is_authorized, is_authorized(&pre.account_id));
}
// Verify the output identifies its own program ID and caller correctly.
assert_eq!(program_output.self_program_id, call.program_id);
assert_eq!(program_output.caller_program_id, caller_program_id);
validate_execution(
&program_output.pre_states,
&program_output.post_states,
call.program_id,
).expect("Invalid execution");
// Validity window: this output is valid only within its declared block / timestamp range.
assert!(program_output.block_validity_window.is_valid_for(block_id));
assert!(program_output.timestamp_validity_window.is_valid_for(timestamp));
// Apply claims and update the state diff.
for (pre, mut post) in program_output.pre_states.iter().zip(program_output.post_states) {
if let Some(claim) = post.required_claim() {
// Claims only fire when the account currently has the default program owner.
assert_eq!(post.account().program_owner, DEFAULT_PROGRAM_ID);
match claim {
Claim::Authorized => assert!(is_authorized(&pre.account_id)),
Claim::Pda(seed) => {
let expected_id = AccountId::for_public_pda(&call.program_id, &seed);
assert_eq!(pre.account_id, expected_id);
}
}
post.account_mut().program_owner = call.program_id;
}
state_diff.insert(pre.account_id, post.into_account());
}
// Push chained calls (in declared order). Pushing them to the front of the queue
// produces a depth-first traversal.
for new_call in program_output.chained_calls.into_iter().rev() {
queue.push_front((new_call, Some(call.program_id)));
}
counter = counter.checked_add(1).expect("checked above");
}
// Default-owner accounts that were modified must have been claimed.
for (account_id, post) in &state_diff {
let pre = nssa_state.public_state.get(account_id).clone();
if pre.program_owner == DEFAULT_PROGRAM_ID && pre != *post {
assert_ne!(post.program_owner, DEFAULT_PROGRAM_ID);
}
}
state_diff
}
Note on replay attacks
The nonce mechanism ensures authorized public transactions cannot be replayed. Once accepted, nonces for all signing accounts are incremented — the same transaction can never be valid again. Purely unauthorized transactions (no signatures required) could theoretically be replayed.
Privacy-preserving transaction acceptance criteria
For a privacy transaction to be accepted:
- Non-empty commitments or nullifiers: At least one new commitment or nullifier must be present.
- No duplicate public account IDs: The
public_account_idslist must not contain repeated entries. - Commitment uniqueness: No two entries within the transaction's
new_commitmentslist may be equal. - Commitment freshness: No new commitment may already exist in the
CommitmentSet. - Nullifier uniqueness: No new nullifier may already exist in the
NullifierSet. No duplicates within the transaction'snew_nullifierslist either. - Valid commitment set digests: Each nullifier's associated
CommitmentSetDigestmust match a current or previous digest of theCommitmentSet. - Signature/nonce count match: The number of nonces in the message must equal the number of
(signature, public_key)pairs in the witness set. - Nonce checks and valid signatures: Same rules as for public transactions, applied to any public accounts present.
- Validity window enforcement: The current
block_idmust fall withinmessage.block_validity_windowand the currenttimestampmust fall withinmessage.timestamp_validity_window. These windows are the intersection of all per-ProgramOutputwindows in the chain, computed and committed by the circuit. - Proof verification: The ZK proof must verify against the circuit output values in the message and the current public pre-states.
In pseudocode:
fn verify_privacy_preserving_transaction(
tx: &PrivacyPreservingTransaction,
nssa_state: &NssaState,
block_id: BlockId,
timestamp: Timestamp,
) {
let message = &tx.message;
let witness_set = &tx.witness_set;
// 1. Non-empty
assert!(!message.new_commitments.is_empty() || !message.new_nullifiers.is_empty());
// 2. No duplicate public account ids
assert_eq!(
message.public_account_ids.iter().collect::<HashSet<_>>().len(),
message.public_account_ids.len(),
);
// 3. Commitment uniqueness within the transaction
assert_eq!(
message.new_commitments.iter().collect::<HashSet<_>>().len(),
message.new_commitments.len(),
);
// 4. Nullifier uniqueness within the transaction
assert_eq!(
message.new_nullifiers.iter().map(|(n, _)| n).collect::<HashSet<_>>().len(),
message.new_nullifiers.len(),
);
// 5. Nonce checks and valid signatures
assert_eq!(witness_set.signatures_and_public_keys.len(), message.nonces.len());
let mut authorized_ids = Vec::new();
for ((sig, pk), nonce) in witness_set.signatures_and_public_keys.iter().zip(&message.nonces) {
assert!(sig.is_valid_for(&message.hash(), pk));
let account_id = AccountId::from(pk);
assert_eq!(nssa_state.public_state.get(&account_id).nonce, *nonce);
authorized_ids.push(account_id);
}
// 6. Validity window enforcement
assert!(message.block_validity_window.is_valid_for(block_id));
assert!(message.timestamp_validity_window.is_valid_for(timestamp));
// 7. Build public pre-states for proof verification
let public_pre_states: Vec<AccountWithMetadata> = message.public_account_ids.iter()
.map(|id| AccountWithMetadata {
account: nssa_state.public_state.get(id).clone(),
is_authorized: authorized_ids.contains(id),
account_id: *id,
}).collect();
// 8. Proof verification
assert_privacy_circuit_proof_is_valid(
&witness_set.proof,
&public_pre_states,
&message.public_post_states,
&message.encrypted_private_post_states,
&message.new_commitments,
&message.new_nullifiers,
&message.block_validity_window,
&message.timestamp_validity_window,
);
// 9. Commitment freshness and valid digests are checked against the current CommitmentSet
// and NullifierSet.
for commitment in &message.new_commitments {
assert!(!nssa_state.private_state.0.contains(commitment));
}
for (nullifier, digest) in &message.new_nullifiers {
assert!(!nssa_state.private_state.1.contains(nullifier));
assert!(nssa_state.private_state.0.is_current_or_previous_digest(digest));
}
}
Note on replay attacks
Replay attacks are not possible for privacy transactions. Any accepted transaction either adds commitments or nullifiers to the state. A replay attempt is rejected by commitment freshness or nullifier uniqueness checks.
State transitions
State transition from public transactions
- Verify acceptance criteria — including the per-
ProgramOutputvalidity windows — and produce a state diff. - Apply the state diff: update (or insert) each account in
public_state. - Increment nonces for all signing accounts.
fn transition_from_public_transaction(
tx: &PublicTransaction,
nssa_state: &mut NssaState,
block_id: BlockId,
timestamp: Timestamp,
) {
let state_diff =
validate_and_produce_public_state_diff(tx, nssa_state, block_id, timestamp);
for (account_id, post) in state_diff {
*nssa_state.public_state.entry(account_id).or_default() = post;
}
for account_id in tx.signer_account_ids() {
nssa_state.public_state.get_mut(&account_id).unwrap().nonce += 1;
}
}
State transition from privacy-preserving transactions
- Verify acceptance criteria — including the message's validity windows.
- Add new commitments to the
CommitmentSet. - Add new nullifiers to the
NullifierSet. - Apply public account changes from
public_post_states. - Increment nonces for all signing public accounts.
fn transition_from_privacy_preserving_transaction(
tx: &PrivacyPreservingTransaction,
nssa_state: &mut NssaState,
block_id: BlockId,
timestamp: Timestamp,
) {
verify_privacy_preserving_transaction(tx, nssa_state, block_id, timestamp);
for commitment in &tx.message.new_commitments {
nssa_state.private_state.0.insert(commitment.clone());
}
for (nullifier, _) in &tx.message.new_nullifiers {
nssa_state.private_state.1.insert(nullifier.clone());
}
for (account_id, post) in tx.message.public_account_ids.iter()
.zip(&tx.message.public_post_states)
{
*nssa_state.public_state.entry(*account_id).or_default() = post.clone();
}
for account_id in tx.signer_account_ids() {
nssa_state.public_state.get_mut(&account_id).unwrap().nonce += 1;
}
}
For both transaction types, the validity-window check is part of the acceptance criteria — outputs outside their declared windows are rejected before any state mutation occurs.
State transition from program deployment transactions
A program deployment transaction contains only the program bytecode. The program ID is derived from the bytecode image.
struct ProgramDeploymentMessage {
bytecode: ByteString,
}
struct ProgramDeploymentTransaction {
message: ProgramDeploymentMessage,
}
- Verify the derived
ProgramIddoes not already exist inprograms. - Insert the new program.
fn transition_from_program_deployment_transaction(
tx: &ProgramDeploymentTransaction,
nssa_state: &mut NssaState,
) {
let program = Program::new(tx.message.bytecode.clone()).expect("Valid program bytecode");
assert!(!nssa_state.programs.contains_key(&program.id()), "Program already deployed");
nssa_state.programs.insert(program.id(), program);
}