Sergio Chouhy 34d613ed05 add specs
2026-05-07 18:27:46 -03:00

68 KiB
Raw Blame History

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_PREFIX is 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_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.

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)
    }
}

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,
];

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 (no elliptic-curve operation):

\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 (32 bytes) || 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:

  1. The executing program's own ID.
  2. The caller program's ID, or None if this is a top-level call.
  3. A list of accounts, each annotated with metadata.
  4. An instruction-specific data word list.

They output:

  • A list of input account states echoed from the input (used for circuit verification).
  • A list of accounts representing the post-execution state.
  • Optionally, a list of chained calls representing other program execution calls.

Formally:

struct AccountWithMetadata {
    account: Account,
    is_authorized: bool,
    account_id: AccountId,
}

pub struct AccountPostState {
    account: Account,
    claim: Option<Claim>,
}

/// A claim request indicating the executing program intends to take ownership of an account.
pub enum Claim {
    /// Ownership via user authorization (signature or nullifier key proof).
    Authorized,
    /// Ownership via a PDA seed.
    /// The AccountId is derived from (caller_program_id, seed) for public PDAs,
    /// or from (caller_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
) -> Result<(List<AccountWithMetadata>, List<AccountPostState>, List<ChainedCall>)>;

We will refer to Program parameters as:

  • Input parameters: self_program_id, caller_program_id, pre_states, instruction_data
  • Output values: pre_states (echoed), post_states, chained_calls

All programs must satisfy the following constraints:

  1. Program receives a list of unique accounts as input. Each AccountId in the list is unique.
  2. Program receives and outputs the same number of account states. pre_states and post_states must have the same length N.
  3. Program cannot update an account's nonce. For all i in 0..N, pre_states[i].account.nonce == post_states[i].account.nonce.
  4. 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.
  5. Program can only decrease the native token balance for accounts that the program owns. For all i in 0..N, if post_states[i].account.balance < pre_states[i].account.balance, then pre_states[i].account.program_owner == executing_program_id.
  6. 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, if pre_states[i].account.data != post_states[i].account.data then either pre_states[i].account == Account::default() or pre_states[i].account.program_owner == executing_program_id.
  7. Any account that has default program owner after execution must have been a default account before execution. For all i in 0..N, if post_states[i].account.program_owner == DEFAULT_PROGRAM_ID then pre_states[i].account == Account::default().
  8. The sum of balances across all pre_states equals the sum across all post_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_authorized indicates 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 the pda_seeds mechanism.
  • program_owner indicates 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 — sets program_owner = executing_program_id, but only if the account currently has DEFAULT_PROGRAM_ID. The authorization precondition depends on the account's kind:
    • Public account or private PDA: the account must be authorized — i.e. its is_authorized flag must be true. Authorization comes from a signature (public) or from a caller's pda_seeds matching the PDA derivation under the appropriate npk (private PDA).
    • Standalone private account (the Regular kinds — PrivateAuthorizedInit, PrivateAuthorizedUpdate, PrivateUnauthorized): the privacy circuit does not enforce the is_authorized precondition for Claim::Authorized. This is intentional: any party producing a valid update for a standalone private account already needs the corresponding nsk (to compute the update nullifier), so the authorization is implicit in the proof. The claim is therefore allowed unconditionally for these kinds, even when pre_state.is_authorized == false.
  • Claim::Pda(seed) — sets program_owner = executing_program_id (when currently default) after verifying the account's ID matches the PDA derivation. For public accounts this 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. Claim::Pda is meaningless on a standalone private account and is not produced by well-behaved programs there.

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 corresponding Account for all public accounts. The entire AccountId space 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: A BTreeSet of all revealed nullifiers.
  • programs: A map from ProgramId to 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.

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 == 0 with a single pre-state: claims the pre-state account on behalf of the caller (a self-init).
  • balance_to_move != 0 with two pre-states [sender, recipient]: standard transfer. The recipient is claimed only if its program_owner is 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-workstyle 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 when Account::default(). NFT master copies and printed copies are also transferred via this instruction (with amount_to_transfer == print_balance for masters and 1 for printed copies). The recipient holding is claimed only if its program_owner is currently default (new_claimed_if_default).
  • NewFungibleDefinition { name: String, total_supply: u128 } — accounts: [definition_target, holding_target]. Both must be Account::default(). The definition holds the supply; the holding is initialized to total_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 to definition.account_id. Only the holding is claimed.
  • Burn { amount_to_burn: u128 } — accounts: [definition, user_holding]. Holding must be authorized. Decreases both holding.balance and definition.total_supply. Neither account is claimed.
  • Mint { amount_to_mint: u128 } — accounts: [definition, holding_target]. Definition must be authorized. Increases both holding.balance and definition.total_supply. Holding is claimed if currently default.
  • PrintNft — accounts: [nft_master_holding, nft_printed_copy_target]. Decrements print_balance on 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's InitializeAccount to populate the holding data.
  • Transfer { ata_program_id, amount } — accounts: [owner, sender_ata, recipient_holding]. Owner must be authorized. The ATA program verifies sender_ata.account_id == for_public_pda(ata_program_id, compute_ata_seed(owner.id, definition_id)), then chains to the Token Program's Transfer, passing the ATA seed in pda_seeds to authorize the ATA inside the Token Program.
  • Burn { ata_program_id, amount } — accounts: [owner, holder_ata, token_definition]. Same PDA-seed authorization mechanism as Transfer; chains to the Token Program's Burn.

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: The ProgramId of 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 of u32 words.

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

  1. public_account_ids: Account IDs of all public accounts involved.
  2. nonces: One nonce per signing public account.
  3. public_post_states: New states of any public accounts modified by this transaction.
  4. encrypted_private_post_states: Encrypted details of each new private account output, decryptable by the respective recipient.
  5. new_commitments: New commitment values added to the CommitmentSet.
  6. new_nullifiers: Nullifiers revealed by this transaction, each paired with the CommitmentSetDigest of the commitment being spent.
  7. block_validity_window / timestamp_validity_window: Time-bound constraints propagated from the ProgramOutput. 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-serialized risc0_zkvm::InnerReceipt proving 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:

  1. Verify that each ProgramOutput in the chain is a valid proof of execution for the corresponding program.
  2. Verify that validate_execution passes for each program call.
  3. Check that chained-call instruction data and accounts are consistent across caller/callee boundaries.
  4. 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.
  5. 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

  1. For each encrypted output, compute the expected view tag from (Npk, vpk). Skip if it does not match.
  2. Perform ECDH: ss = x-coordinate of (isk * Epk).
  3. Run kdf(ss, commitment, output_index) to derive the symmetric key.
  4. Decrypt the ciphertext with ChaCha20.
  5. Parse the 81-byte header to recover PrivateAccountKind.
  6. Parse the remaining bytes to recover the Account.
  7. 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_ids list 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_id must correspond to a deployed program.
  • Valid execution: The program is invoked with the provided accounts and instruction data. validate_execution must pass. Accounts are assembled as AccountWithMetadata with is_authorized = true for accounts whose public key appears in the witness set, plus any PDAs authorized via the pda_seeds mechanism in chained calls.
  • Pre-state consistency: Each program_output.pre_states[i] must equal the current state of pre_states[i].account_id (or the diffed value from earlier chained calls). The is_authorized flag in each pre-state must match the actual authorization status.
  • Program identity consistency: Each program output's self_program_id must equal the program ID it was invoked under, and its caller_program_id must equal the caller (or None for the top-level call).
  • Validity window enforcement: For each ProgramOutput in the chain (top-level and chained calls), the current block_id must fall within program_output.block_validity_window and the current timestamp must fall within program_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 AccountPostState with a Claim causes the runtime to set program_owner = executing_program_id for that account, but only if the account's current program_owner == DEFAULT_PROGRAM_ID. In the public path all accounts are public, so Claim::Authorized always requires is_authorized == true (signature, or PDA via caller's pda_seeds), and Claim::Pda(seed) requires the account ID to match AccountId::for_public_pda(executing_program_id, seed). (The privacy path relaxes the Claim::Authorized precondition for standalone private accounts — see the Programs section.)
  • No silent default-account modifications: Any account whose pre-state has program_owner == DEFAULT_PROGRAM_ID and whose post-state differs from the pre-state must have been claimed (i.e. its post-state program_owner is 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_ids list must not contain repeated entries.
  • Commitment uniqueness: No two entries within the transaction's new_commitments list 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's new_nullifiers list either.
  • Valid commitment set digests: Each nullifier's associated CommitmentSetDigest must match a current or previous digest of the CommitmentSet.
  • 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_id must fall within message.block_validity_window and the current timestamp must fall within message.timestamp_validity_window. These windows are the intersection of all per-ProgramOutput windows 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

  1. Verify acceptance criteria — including the per-ProgramOutput validity windows — and produce a state diff.
  2. Apply the state diff: update (or insert) each account in public_state.
  3. 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

  1. Verify acceptance criteria — including the message's validity windows.
  2. Add new commitments to the CommitmentSet.
  3. Add new nullifiers to the NullifierSet.
  4. Apply public account changes from public_post_states.
  5. 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,
}
  1. Verify the derived ProgramId does not already exist in programs.
  2. 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);
}