From d82f06593da312dba61757f17357c8855b9f3b87 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Thu, 27 Nov 2025 12:08:27 -0300 Subject: [PATCH 01/11] add pda_seeds field --- nssa/core/src/program.rs | 5 +++++ nssa/src/public_transaction/transaction.rs | 1 + nssa/test_program_methods/guest/src/bin/chain_caller.rs | 2 ++ 3 files changed, 8 insertions(+) diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index 054f993..927d5fc 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -12,12 +12,17 @@ pub struct ProgramInput { pub instruction: T, } +#[derive(Serialize, Deserialize, Clone)] +#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] +pub struct PdaSeed([u8; 32]); + #[derive(Serialize, Deserialize, Clone)] #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] pub struct ChainedCall { pub program_id: ProgramId, pub instruction_data: InstructionData, pub pre_states: Vec, + pub pda_seeds: Vec } #[derive(Serialize, Deserialize, Clone)] diff --git a/nssa/src/public_transaction/transaction.rs b/nssa/src/public_transaction/transaction.rs index 28f33fb..081fe2f 100644 --- a/nssa/src/public_transaction/transaction.rs +++ b/nssa/src/public_transaction/transaction.rs @@ -107,6 +107,7 @@ impl PublicTransaction { program_id: message.program_id, instruction_data: message.instruction_data.clone(), pre_states: input_pre_states, + pda_seeds: vec![], }; let mut chained_calls = VecDeque::from_iter([initial_call]); diff --git a/nssa/test_program_methods/guest/src/bin/chain_caller.rs b/nssa/test_program_methods/guest/src/bin/chain_caller.rs index 028f8a0..23b0244 100644 --- a/nssa/test_program_methods/guest/src/bin/chain_caller.rs +++ b/nssa/test_program_methods/guest/src/bin/chain_caller.rs @@ -25,6 +25,7 @@ fn main() { program_id, instruction_data: instruction_data.clone(), pre_states: vec![receiver_pre.clone(), sender_pre.clone()], // <- Account order permutation here + pda_seeds: vec![] }; num_chain_calls as usize - 1 ]; @@ -33,6 +34,7 @@ fn main() { program_id, instruction_data, pre_states: vec![receiver_pre.clone(), sender_pre.clone()], // <- Account order permutation here + pda_seeds: vec![], }); write_nssa_outputs_with_chained_call( From 3fbf1e1fec33e57de6c0018d695ffa4f695e5033 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Thu, 27 Nov 2025 13:10:38 -0300 Subject: [PATCH 02/11] add pda mechanism --- nssa/core/src/program.rs | 26 +++++++++++++++-- nssa/src/public_transaction/transaction.rs | 33 ++++++++++++++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index 927d5fc..ad9bbab 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -1,7 +1,7 @@ use risc0_zkvm::{DeserializeOwned, guest::env, serde::Deserializer}; use serde::{Deserialize, Serialize}; -use crate::account::{Account, AccountWithMetadata}; +use crate::account::{Account, AccountId, AccountWithMetadata}; pub type ProgramId = [u32; 8]; pub type InstructionData = Vec; @@ -16,13 +16,35 @@ pub struct ProgramInput { #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] pub struct PdaSeed([u8; 32]); +#[cfg(feature = "host")] +impl From<(&ProgramId, &PdaSeed)> for AccountId { + fn from(value: (&ProgramId, &PdaSeed)) -> Self { + use risc0_zkvm::sha::{Impl, Sha256}; + const PROGRAM_DERIVED_ACCOUNT_ID_PREFIX: &[u8; 32] = + b"/NSSA/v0.2/AccountId/PDA/\x00\x00\x00\x00\x00\x00\x00"; + + let mut bytes = [0; 96]; + bytes[0..32].copy_from_slice(PROGRAM_DERIVED_ACCOUNT_ID_PREFIX); + let program_id_bytes: &[u8] = + bytemuck::try_cast_slice(value.0).expect("ProgramId should be castable to &[u8]"); + bytes[32..64].copy_from_slice(program_id_bytes); + bytes[64..].copy_from_slice(&value.1.0); + AccountId::new( + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("Hash output must be exactly 32 bytes long"), + ) + } +} + #[derive(Serialize, Deserialize, Clone)] #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] pub struct ChainedCall { pub program_id: ProgramId, pub instruction_data: InstructionData, pub pre_states: Vec, - pub pda_seeds: Vec + pub pda_seeds: Vec, } #[derive(Serialize, Deserialize, Clone)] diff --git a/nssa/src/public_transaction/transaction.rs b/nssa/src/public_transaction/transaction.rs index 081fe2f..cafa27b 100644 --- a/nssa/src/public_transaction/transaction.rs +++ b/nssa/src/public_transaction/transaction.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::{ account::{Account, AccountId, AccountWithMetadata}, - program::{ChainedCall, DEFAULT_PROGRAM_ID, validate_execution}, + program::{ChainedCall, DEFAULT_PROGRAM_ID, PdaSeed, ProgramId, validate_execution}, }; use sha2::{Digest, digest::FixedOutput}; @@ -110,10 +110,10 @@ impl PublicTransaction { pda_seeds: vec![], }; - let mut chained_calls = VecDeque::from_iter([initial_call]); + let mut chained_calls = VecDeque::from_iter([(initial_call, None)]); let mut chain_calls_counter = 0; - while let Some(chained_call) = chained_calls.pop_front() { + while let Some((chained_call, caller_program_id)) = chained_calls.pop_front() { if chain_calls_counter > MAX_NUMBER_CHAINED_CALLS { return Err(NssaError::MaxChainedCallsDepthExceeded); } @@ -126,6 +126,9 @@ impl PublicTransaction { let mut program_output = program.execute(&chained_call.pre_states, &chained_call.instruction_data)?; + let authorized_pdas = + self.compute_authorized_pdas(&caller_program_id, &chained_call.pda_seeds); + for pre in &program_output.pre_states { let account_id = pre.account_id; // Check that the program output pre_states coinicide with the values in the public @@ -138,8 +141,11 @@ impl PublicTransaction { return Err(NssaError::InvalidProgramBehavior); } - // Check that authorization flags are consistent with the provided ones - if pre.is_authorized && !signer_account_ids.contains(&account_id) { + // Check that authorization flags are consistent with the provided ones or + // authorized by program through the PDA mechanism + let is_authorized = signer_account_ids.contains(&account_id) + || authorized_pdas.contains(&account_id); + if pre.is_authorized && !is_authorized { return Err(NssaError::InvalidProgramBehavior); } } @@ -171,7 +177,7 @@ impl PublicTransaction { } for new_call in program_output.chained_calls.into_iter().rev() { - chained_calls.push_front(new_call); + chained_calls.push_front((new_call, Some(chained_call.program_id))); } chain_calls_counter += 1; @@ -179,6 +185,21 @@ impl PublicTransaction { Ok(state_diff) } + + fn compute_authorized_pdas( + &self, + caller_program_id: &Option, + pda_seeds: &[PdaSeed], + ) -> HashSet { + if let Some(caller_program_id) = caller_program_id { + pda_seeds + .iter() + .map(|pda_seed| AccountId::from((caller_program_id, pda_seed))) + .collect() + } else { + HashSet::new() + } + } } #[cfg(test)] From e61a971790cf5ba9cde4e7de9a34b401f2460aff Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Thu, 27 Nov 2025 13:49:56 -0300 Subject: [PATCH 03/11] add test and refactor chain_caller program --- nssa/core/src/program.rs | 6 ++ nssa/src/state.rs | 60 +++++++++++++++++-- .../guest/src/bin/chain_caller.rs | 50 +++++++++------- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index ad9bbab..dfd52e4 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -16,6 +16,12 @@ pub struct ProgramInput { #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] pub struct PdaSeed([u8; 32]); +impl PdaSeed { + pub fn new(value: [u8; 32]) -> Self { + Self(value) + } +} + #[cfg(feature = "host")] impl From<(&ProgramId, &PdaSeed)> for AccountId { fn from(value: (&ProgramId, &PdaSeed)) -> Self { diff --git a/nssa/src/state.rs b/nssa/src/state.rs index cef7791..79541a3 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -250,7 +250,7 @@ pub mod tests { Commitment, Nullifier, NullifierPublicKey, NullifierSecretKey, SharedSecretKey, account::{Account, AccountId, AccountWithMetadata, Nonce}, encryption::{EphemeralPublicKey, IncomingViewingPublicKey, Scalar}, - program::ProgramId, + program::{PdaSeed, ProgramId}, }; use crate::{ @@ -2092,14 +2092,18 @@ pub mod tests { let key = PrivateKey::try_new([1; 32]).unwrap(); let from = AccountId::from(&PublicKey::new_from_private_key(&key)); let to = AccountId::new([2; 32]); - let initial_balance = 100; + let initial_balance = 1000; let initial_data = [(from, initial_balance), (to, 0)]; let mut state = V02State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); let from_key = key; - let amount: u128 = 0; - let instruction: (u128, ProgramId, u32) = - (amount, Program::authenticated_transfer_program().id(), 2); + let amount: u128 = 37; + let instruction: (u128, ProgramId, u32, Option) = ( + amount, + Program::authenticated_transfer_program().id(), + 2, + None, + ); let expected_to_post = Account { program_owner: Program::authenticated_transfer_program().id(), @@ -2139,10 +2143,11 @@ pub mod tests { V02State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); let from_key = key; let amount: u128 = 0; - let instruction: (u128, ProgramId, u32) = ( + let instruction: (u128, ProgramId, u32, Option) = ( amount, Program::authenticated_transfer_program().id(), MAX_NUMBER_CHAINED_CALLS as u32 + 1, + None, ); let message = public_transaction::Message::try_new( @@ -2162,4 +2167,47 @@ pub mod tests { Err(NssaError::MaxChainedCallsDepthExceeded) )); } + #[test] + fn test_execution_that_requires_authentication_of_a_program_derived_account_id_succeeds() { + let chain_caller = Program::chain_caller(); + let pda_seed = PdaSeed::new([37; 32]); + let from = AccountId::from((&chain_caller.id(), &pda_seed)); + let to = AccountId::new([2; 32]); + let initial_balance = 1000; + let initial_data = [(from, initial_balance), (to, 0)]; + let mut state = + V02State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + let amount: u128 = 58; + let instruction: (u128, ProgramId, u32, Option) = ( + amount, + Program::authenticated_transfer_program().id(), + 1, + Some(pda_seed), + ); + + let expected_to_post = Account { + program_owner: Program::authenticated_transfer_program().id(), + balance: amount, // The `chain_caller` chains the program twice + ..Account::default() + }; + + let message = public_transaction::Message::try_new( + chain_caller.id(), + vec![to, from], // The chain_caller program permutes the account order in the chain + // call + vec![], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + state.transition_from_public_transaction(&tx).unwrap(); + + let from_post = state.get_account_by_id(&from); + let to_post = state.get_account_by_id(&to); + // The `chain_caller` program calls the program twice + assert_eq!(from_post.balance, initial_balance - amount); + assert_eq!(to_post, expected_to_post); + } } diff --git a/nssa/test_program_methods/guest/src/bin/chain_caller.rs b/nssa/test_program_methods/guest/src/bin/chain_caller.rs index 23b0244..1885da2 100644 --- a/nssa/test_program_methods/guest/src/bin/chain_caller.rs +++ b/nssa/test_program_methods/guest/src/bin/chain_caller.rs @@ -1,45 +1,51 @@ use nssa_core::program::{ - ChainedCall, ProgramId, ProgramInput, read_nssa_inputs, write_nssa_outputs_with_chained_call, + ChainedCall, PdaSeed, ProgramId, ProgramInput, read_nssa_inputs, + write_nssa_outputs_with_chained_call, }; use risc0_zkvm::serde::to_vec; -type Instruction = (u128, ProgramId, u32); +type Instruction = (u128, ProgramId, u32, Option); /// A program that calls another program `num_chain_calls` times. /// It permutes the order of the input accounts on the subsequent call +/// The `ProgramId` in the instruction must be the program_id of the authenticated transfers program fn main() { let ProgramInput { pre_states, - instruction: (balance, program_id, num_chain_calls), + instruction: (balance, auth_transfer_id, num_chain_calls, pda_seed), } = read_nssa_inputs::(); - let [sender_pre, receiver_pre] = match pre_states.try_into() { + let [recipient_pre, sender_pre] = match pre_states.try_into() { Ok(array) => array, Err(_) => return, }; let instruction_data = to_vec(&balance).unwrap(); - let mut chained_call = vec![ - ChainedCall { - program_id, - instruction_data: instruction_data.clone(), - pre_states: vec![receiver_pre.clone(), sender_pre.clone()], // <- Account order permutation here - pda_seeds: vec![] - }; - num_chain_calls as usize - 1 - ]; + let mut running_recipient_pre = recipient_pre.clone(); + let mut running_sender_pre = sender_pre.clone(); - chained_call.push(ChainedCall { - program_id, - instruction_data, - pre_states: vec![receiver_pre.clone(), sender_pre.clone()], // <- Account order permutation here - pda_seeds: vec![], - }); + if pda_seed.is_some() { + running_sender_pre.is_authorized = true; + } + + let mut chained_calls = Vec::new(); + for _i in 0..num_chain_calls { + let new_chained_call = ChainedCall { + program_id: auth_transfer_id, + instruction_data: instruction_data.clone(), + pre_states: vec![running_sender_pre.clone(), running_recipient_pre.clone()], // <- Account order permutation here + pda_seeds: pda_seed.iter().cloned().collect(), + }; + chained_calls.push(new_chained_call); + + running_sender_pre.account.balance -= balance; + running_recipient_pre.account.balance += balance; + } write_nssa_outputs_with_chained_call( - vec![sender_pre.clone(), receiver_pre.clone()], - vec![sender_pre.account, receiver_pre.account], - chained_call, + vec![recipient_pre.clone(), sender_pre.clone()], + vec![recipient_pre.account, sender_pre.account], + chained_calls, ); } From 1989fd25a178eca653992ef3c47e1541eb490378 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Fri, 28 Nov 2025 11:10:00 -0300 Subject: [PATCH 04/11] add pinata token example and test --- nssa/core/Cargo.toml | 2 +- .../guest/src/bin/pinata_token.rs | 103 ++++++++++++++++++ nssa/program_methods/guest/src/bin/token.rs | 49 ++++++++- nssa/src/program.rs | 5 + nssa/src/state.rs | 91 ++++++++++++++++ 5 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 nssa/program_methods/guest/src/bin/pinata_token.rs diff --git a/nssa/core/Cargo.toml b/nssa/core/Cargo.toml index 67f40b2..0e16a3f 100644 --- a/nssa/core/Cargo.toml +++ b/nssa/core/Cargo.toml @@ -12,7 +12,7 @@ chacha20 = { version = "0.9", default-features = false } k256 = { version = "0.13.3", optional = true } base58 = { version = "0.2.0", optional = true } anyhow = { version = "1.0.98", optional = true } -borsh.workspace = true +borsh = "1.5.7" [features] default = [] diff --git a/nssa/program_methods/guest/src/bin/pinata_token.rs b/nssa/program_methods/guest/src/bin/pinata_token.rs new file mode 100644 index 0000000..ab04237 --- /dev/null +++ b/nssa/program_methods/guest/src/bin/pinata_token.rs @@ -0,0 +1,103 @@ +use nssa_core::program::{ + ChainedCall, PdaSeed, ProgramInput, read_nssa_inputs, write_nssa_outputs_with_chained_call, +}; +use risc0_zkvm::serde::to_vec; +use risc0_zkvm::sha::{Impl, Sha256}; + +const PRIZE: u128 = 150; + +type Instruction = u128; + +struct Challenge { + difficulty: u8, + seed: [u8; 32], +} + +impl Challenge { + fn new(bytes: &[u8]) -> Self { + assert_eq!(bytes.len(), 33); + let difficulty = bytes[0]; + assert!(difficulty <= 32); + + let mut seed = [0; 32]; + seed.copy_from_slice(&bytes[1..]); + Self { difficulty, seed } + } + + // Checks if the leftmost `self.difficulty` number of bytes of SHA256(self.data || solution) are + // zero. + fn validate_solution(&self, solution: Instruction) -> bool { + let mut bytes = [0; 32 + 16]; + bytes[..32].copy_from_slice(&self.seed); + bytes[32..].copy_from_slice(&solution.to_le_bytes()); + let digest: [u8; 32] = Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap(); + let difficulty = self.difficulty as usize; + digest[..difficulty].iter().all(|&b| b == 0) + } + + fn next_data(self) -> [u8; 33] { + let mut result = [0; 33]; + result[0] = self.difficulty; + result[1..].copy_from_slice(Impl::hash_bytes(&self.seed).as_bytes()); + result + } +} + +/// A pinata program +fn main() { + // Read input accounts. + // It is expected to receive three accounts: [pinata_definition, pinata_token_holding, winner_token_holding] + let ProgramInput { + pre_states, + instruction: solution, + } = read_nssa_inputs::(); + + let [ + pinata_definition, + pinata_token_holding, + winner_token_holding, + ] = match pre_states.try_into() { + Ok(array) => array, + Err(_) => return, + }; + + let data = Challenge::new(&pinata_definition.account.data); + + if !data.validate_solution(solution) { + return; + } + + let mut pinata_definition_post = pinata_definition.account.clone(); + let pinata_token_holding_post = pinata_token_holding.account.clone(); + let winner_token_holding_post = winner_token_holding.account.clone(); + pinata_definition_post.data = data.next_data().to_vec(); + + let mut instruction_data: [u8; 23] = [0; 23]; + instruction_data[0] = 1; + instruction_data[1..17].copy_from_slice(&PRIZE.to_le_bytes()); + + // Flip authorization to true for chained call + let mut pinata_token_holding_for_chain_call = pinata_token_holding.clone(); + pinata_token_holding_for_chain_call.is_authorized = true; + + let chained_calls = vec![ChainedCall { + program_id: pinata_token_holding_post.program_owner, + instruction_data: to_vec(&instruction_data).unwrap(), + pre_states: vec![pinata_token_holding_for_chain_call, winner_token_holding.clone()], + pda_seeds: vec![PdaSeed::new([0; 32])], + }]; + + write_nssa_outputs_with_chained_call( + vec![ + pinata_definition, + pinata_token_holding, + winner_token_holding, + ], + vec![ + pinata_definition_post, + pinata_token_holding_post, + winner_token_holding_post, + ], + chained_calls, + ); +} diff --git a/nssa/program_methods/guest/src/bin/token.rs b/nssa/program_methods/guest/src/bin/token.rs index e5680be..59aae6a 100644 --- a/nssa/program_methods/guest/src/bin/token.rs +++ b/nssa/program_methods/guest/src/bin/token.rs @@ -45,6 +45,21 @@ impl TokenDefinition { bytes[7..].copy_from_slice(&self.total_supply.to_le_bytes()); bytes.into() } + + fn parse(data: &[u8]) -> Option { + if data.len() != TOKEN_DEFINITION_DATA_SIZE || data[0] != TOKEN_DEFINITION_TYPE { + None + } else { + let account_type = data[0]; + let name = data[1..7].try_into().unwrap(); + let total_supply = u128::from_le_bytes(data[7..].try_into().unwrap()); + Some(Self { + account_type, + name, + total_supply, + }) + } + } } impl TokenHolding { @@ -196,15 +211,47 @@ fn main() { let post_states = transfer(&pre_states, balance_to_move); write_nssa_outputs(pre_states, post_states); } + 2 => { + // Initialize account + assert_eq!(instruction[1..], [0; 22]); + let post_states = initialize(&pre_states); + write_nssa_outputs(pre_states, post_states); + } _ => panic!("Invalid instruction"), }; } +fn initialize(pre_states: &[AccountWithMetadata]) -> Vec { + if pre_states.len() != 2 { + panic!("Invalid number of accounts"); + } + + let definition = &pre_states[0]; + let account_to_initialize = &pre_states[1]; + + if account_to_initialize.account != Account::default() { + panic!("Only uninitialized accounts can be initialized"); + } + + // TODO: We should check that this is an account owned by the token program. + // This check can't be done here since the ID of the program is known only after compiling it + // Check definition account is valid + let _definition_values = + TokenDefinition::parse(&definition.account.data).expect("Definition account must be valid"); + let holding_for_definition = TokenHolding::new(&definition.account_id); + + let definition_post = definition.account.clone(); + let mut account_to_initialize_post = account_to_initialize.account.clone(); + account_to_initialize_post.data = holding_for_definition.into_data(); + + vec![definition_post, account_to_initialize_post] +} + #[cfg(test)] mod tests { use nssa_core::account::{Account, AccountId, AccountWithMetadata}; - use crate::{new_definition, transfer, TOKEN_HOLDING_DATA_SIZE, TOKEN_HOLDING_TYPE}; + use crate::{TOKEN_HOLDING_DATA_SIZE, TOKEN_HOLDING_TYPE, new_definition, transfer}; #[should_panic(expected = "Invalid number of input accounts")] #[test] diff --git a/nssa/src/program.rs b/nssa/src/program.rs index d3f28b5..4d2232d 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -104,6 +104,11 @@ impl Program { // `program_methods` Self::new(PINATA_ELF.to_vec()).unwrap() } + + pub fn pinata_token() -> Self { + use crate::program_methods::PINATA_TOKEN_ELF; + Self::new(PINATA_TOKEN_ELF.to_vec()).unwrap() + } } #[cfg(test)] diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 79541a3..78b8b7b 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -239,6 +239,20 @@ impl V02State { }, ); } + + pub fn add_pinata_token_program(&mut self, account_id: AccountId) { + self.insert_program(Program::pinata_token()); + + self.public_state.insert( + account_id, + Account { + program_owner: Program::pinata_token().id(), + // Difficulty: 3 + data: vec![3; 33], + ..Account::default() + }, + ); + } } #[cfg(test)] @@ -2210,4 +2224,81 @@ pub mod tests { assert_eq!(from_post.balance, initial_balance - amount); assert_eq!(to_post, expected_to_post); } + + #[test] + fn test_pda_mechanism_with_pinata_token_program() { + let pinata_token = Program::pinata_token(); + let token = Program::token(); + + let pinata_definition_id = AccountId::new([1; 32]); + let pinata_token_definition_id = AccountId::new([2; 32]); + // Total supply of pinata token will be in an account under a PDA. + let pinata_token_holding_id = AccountId::from((&pinata_token.id(), &PdaSeed::new([0; 32]))); + let winner_token_holding_id = AccountId::new([3; 32]); + + let mut expected_winner_account_data = [0; 49]; + expected_winner_account_data[0] = 1; + expected_winner_account_data[1..33].copy_from_slice(pinata_token_definition_id.value()); + expected_winner_account_data[33..].copy_from_slice(&150u128.to_le_bytes()); + let expected_winner_token_holding_post = Account { + program_owner: token.id(), + data: expected_winner_account_data.to_vec(), + ..Account::default() + }; + + let mut state = V02State::new_with_genesis_accounts(&[], &[]); + state.add_pinata_token_program(pinata_definition_id); + + // Execution of the token program to create new token for the pinata token + // definition and supply accounts + let total_supply: u128 = 10_000_000; + // instruction: [0x00 || total_supply (little-endian 16 bytes) || name (6 bytes)] + let mut instruction: [u8; 23] = [0; 23]; + instruction[1..17].copy_from_slice(&total_supply.to_le_bytes()); + instruction[17..].copy_from_slice(b"PINATA"); + let message = public_transaction::Message::try_new( + token.id(), + vec![pinata_token_definition_id, pinata_token_holding_id], + vec![], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx).unwrap(); + + // Execution of the token program transfer just to initialize the winner token account + let mut instruction: [u8; 23] = [0; 23]; + instruction[0] = 2; + let message = public_transaction::Message::try_new( + token.id(), + vec![pinata_token_definition_id, winner_token_holding_id], + vec![], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx).unwrap(); + + // Submit a solution to the pinata program to claim the prize + let solution: u128 = 989106; + let message = public_transaction::Message::try_new( + pinata_token.id(), + vec![ + pinata_definition_id, + pinata_token_holding_id, + winner_token_holding_id, + ], + vec![], + solution, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx).unwrap(); + + let winner_token_holding_post = state.get_account_by_id(&winner_token_holding_id); + assert_eq!(winner_token_holding_post, expected_winner_token_holding_post); + } } From dd5db5b32c77dc62a3dd872e0301d7e96d222eb8 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Fri, 28 Nov 2025 11:37:49 -0300 Subject: [PATCH 05/11] add init function to the token program --- nssa/program_methods/guest/src/bin/token.rs | 135 ++++++++++++++++++-- 1 file changed, 125 insertions(+), 10 deletions(-) diff --git a/nssa/program_methods/guest/src/bin/token.rs b/nssa/program_methods/guest/src/bin/token.rs index e5680be..e7f7e43 100644 --- a/nssa/program_methods/guest/src/bin/token.rs +++ b/nssa/program_methods/guest/src/bin/token.rs @@ -18,6 +18,11 @@ use nssa_core::{ // * Two accounts: [sender_account, recipient_account]. // * An instruction data byte string of length 23, indicating the total supply with the following layout // [0x01 || amount (little-endian 16 bytes) || 0x00 || 0x00 || 0x00 || 0x00 || 0x00 || 0x00]. +// 3. Initialize account with zero balance +// Arguments to this function are: +// * Two accounts: [definition_account, account_to_initialize]. +// * An dummy byte string of length 23, with the following layout +// [0x02 || 0x00 || 0x00 || 0x00 || ... || 0x00 || 0x00]. const TOKEN_DEFINITION_TYPE: u8 = 0; const TOKEN_DEFINITION_DATA_SIZE: usize = 23; @@ -45,6 +50,25 @@ impl TokenDefinition { bytes[7..].copy_from_slice(&self.total_supply.to_le_bytes()); bytes.into() } + + fn parse(data: &[u8]) -> Option { + if data.len() != TOKEN_DEFINITION_DATA_SIZE || data[0] != TOKEN_DEFINITION_TYPE { + None + } else { + let account_type = data[0]; + let name = data[1..7].try_into().unwrap(); + let total_supply = u128::from_le_bytes( + data[7..] + .try_into() + .expect("Total supply must be 16 bytes little-endian"), + ); + Some(Self { + account_type, + name, + total_supply, + }) + } + } } impl TokenHolding { @@ -61,8 +85,16 @@ impl TokenHolding { None } else { let account_type = data[0]; - let definition_id = AccountId::new(data[1..33].try_into().unwrap()); - let balance = u128::from_le_bytes(data[33..].try_into().unwrap()); + let definition_id = AccountId::new( + data[1..33] + .try_into() + .expect("Defintion ID must be 32 bytes long"), + ); + let balance = u128::from_le_bytes( + data[33..] + .try_into() + .expect("balance must be 16 bytes little-endian"), + ); Some(Self { definition_id, balance, @@ -167,6 +199,33 @@ fn new_definition( vec![definition_target_account_post, holding_target_account_post] } +fn initialize_account(pre_states: &[AccountWithMetadata]) -> Vec { + if pre_states.len() != 2 { + panic!("Invalid number of accounts"); + } + + let definition = &pre_states[0]; + let account_to_initialize = &pre_states[1]; + + if account_to_initialize.account != Account::default() { + panic!("Only uninitialized accounts can be initialized"); + } + + // TODO: We should check that this is an account owned by the token program. + // This check can't be done here since the ID of the program is known only after compiling it + // + // Check definition account is valid + let _definition_values = + TokenDefinition::parse(&definition.account.data).expect("Definition account must be valid"); + let holding_values = TokenHolding::new(&definition.account_id); + + let definition_post = definition.account.clone(); + let mut account_to_initialize_post = account_to_initialize.account.clone(); + account_to_initialize_post.data = holding_values.into_data(); + + vec![definition_post, account_to_initialize_post] +} + type Instruction = [u8; 23]; fn main() { @@ -175,36 +234,59 @@ fn main() { instruction, } = read_nssa_inputs::(); - match instruction[0] { + let (pre_states, post_states) = match instruction[0] { 0 => { // Parse instruction - let total_supply = u128::from_le_bytes(instruction[1..17].try_into().unwrap()); - let name: [u8; 6] = instruction[17..].try_into().unwrap(); + let total_supply = u128::from_le_bytes( + instruction[1..17] + .try_into() + .expect("Total supply must be 16 bytes little-endian"), + ); + let name: [u8; 6] = instruction[17..] + .try_into() + .expect("Name must be 6 bytes long"); assert_ne!(name, [0; 6]); // Execute let post_states = new_definition(&pre_states, name, total_supply); - write_nssa_outputs(pre_states, post_states); + (pre_states, post_states) } 1 => { // Parse instruction - let balance_to_move = u128::from_le_bytes(instruction[1..17].try_into().unwrap()); - let name: [u8; 6] = instruction[17..].try_into().unwrap(); + let balance_to_move = u128::from_le_bytes( + instruction[1..17] + .try_into() + .expect("Balance to move must be 16 bytes little-endian"), + ); + let name: [u8; 6] = instruction[17..] + .try_into() + .expect("Name must be 6 bytes long"); assert_eq!(name, [0; 6]); // Execute let post_states = transfer(&pre_states, balance_to_move); - write_nssa_outputs(pre_states, post_states); + (pre_states, post_states) + } + 2 => { + // Initialize account + assert_eq!(instruction[1..], [0; 22]); + let post_states = initialize_account(&pre_states); + (pre_states, post_states) } _ => panic!("Invalid instruction"), }; + + write_nssa_outputs(pre_states, post_states); } #[cfg(test)] mod tests { use nssa_core::account::{Account, AccountId, AccountWithMetadata}; - use crate::{new_definition, transfer, TOKEN_HOLDING_DATA_SIZE, TOKEN_HOLDING_TYPE}; + use crate::{ + TOKEN_DEFINITION_DATA_SIZE, TOKEN_HOLDING_DATA_SIZE, TOKEN_HOLDING_TYPE, + initialize_account, new_definition, transfer, + }; #[should_panic(expected = "Invalid number of input accounts")] #[test] @@ -551,4 +633,37 @@ mod tests { ] ); } + + #[test] + fn test_token_initialize_account_succeeds() { + let pre_states = vec![ + AccountWithMetadata { + account: Account { + // Definition ID with + data: vec![0; TOKEN_DEFINITION_DATA_SIZE - 16] + .into_iter() + .chain(u128::to_le_bytes(1000)) + .collect(), + ..Account::default() + }, + is_authorized: false, + account_id: AccountId::new([1; 32]), + }, + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: AccountId::new([2; 32]), + }, + ]; + let post_states = initialize_account(&pre_states); + let [definition, holding] = post_states.try_into().ok().unwrap(); + assert_eq!(definition.data, pre_states[0].account.data); + assert_eq!( + holding.data, + vec![ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + ); + } } From bfbd50e8cbe2ebf17efda6841dca98553cb75cfd Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Fri, 28 Nov 2025 17:09:38 -0300 Subject: [PATCH 06/11] add docs --- nssa/core/src/program.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index dfd52e4..1028d54 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -12,6 +12,11 @@ pub struct ProgramInput { pub instruction: T, } +/// A 32-byte seed used to compute a *Program-Derived AccountId* (PDA). +/// +/// Each program can derive up to `2^32` unique account IDs by choosing different +/// seeds. PDAs allow programs to control namespaced account identifiers without +/// collisions between programs. #[derive(Serialize, Deserialize, Clone)] #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] pub struct PdaSeed([u8; 32]); From 3d529d19fa82e5f37c88fd4cdc0f11c62b8875b1 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Tue, 2 Dec 2025 10:48:21 -0300 Subject: [PATCH 07/11] solve comments --- nssa/core/src/program.rs | 2 +- nssa/program_methods/guest/src/bin/token.rs | 39 ++++++++++----------- nssa/src/public_transaction/transaction.rs | 2 +- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index 1028d54..b48a08b 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -14,7 +14,7 @@ pub struct ProgramInput { /// A 32-byte seed used to compute a *Program-Derived AccountId* (PDA). /// -/// Each program can derive up to `2^32` unique account IDs by choosing different +/// Each program can derive up to `2^256` unique account IDs by choosing different /// seeds. PDAs allow programs to control namespaced account identifiers without /// collisions between programs. #[derive(Serialize, Deserialize, Clone)] diff --git a/nssa/program_methods/guest/src/bin/token.rs b/nssa/program_methods/guest/src/bin/token.rs index 0109c2b..b527045 100644 --- a/nssa/program_methods/guest/src/bin/token.rs +++ b/nssa/program_methods/guest/src/bin/token.rs @@ -82,25 +82,25 @@ impl TokenHolding { fn parse(data: &[u8]) -> Option { if data.len() != TOKEN_HOLDING_DATA_SIZE || data[0] != TOKEN_HOLDING_TYPE { - None - } else { - let account_type = data[0]; - let definition_id = AccountId::new( - data[1..33] - .try_into() - .expect("Defintion ID must be 32 bytes long"), - ); - let balance = u128::from_le_bytes( - data[33..] - .try_into() - .expect("balance must be 16 bytes little-endian"), - ); - Some(Self { - definition_id, - balance, - account_type, - }) + return None; } + + let account_type = data[0]; + let definition_id = AccountId::new( + data[1..33] + .try_into() + .expect("Defintion ID must be 32 bytes long"), + ); + let balance = u128::from_le_bytes( + data[33..] + .try_into() + .expect("balance must be 16 bytes little-endian"), + ); + Some(Self { + definition_id, + balance, + account_type, + }) } fn into_data(self) -> Data { @@ -211,7 +211,7 @@ fn initialize_account(pre_states: &[AccountWithMetadata]) -> Vec { panic!("Only uninitialized accounts can be initialized"); } - // TODO: We should check that this is an account owned by the token program. + // TODO: #212 We should check that this is an account owned by the token program. // This check can't be done here since the ID of the program is known only after compiling it // // Check definition account is valid @@ -278,7 +278,6 @@ fn main() { write_nssa_outputs(pre_states, post_states); } - #[cfg(test)] mod tests { use nssa_core::account::{Account, AccountId, AccountWithMetadata}; diff --git a/nssa/src/public_transaction/transaction.rs b/nssa/src/public_transaction/transaction.rs index cafa27b..b8c0a8d 100644 --- a/nssa/src/public_transaction/transaction.rs +++ b/nssa/src/public_transaction/transaction.rs @@ -145,7 +145,7 @@ impl PublicTransaction { // authorized by program through the PDA mechanism let is_authorized = signer_account_ids.contains(&account_id) || authorized_pdas.contains(&account_id); - if pre.is_authorized && !is_authorized { + if pre.is_authorized != is_authorized { return Err(NssaError::InvalidProgramBehavior); } } From fdc53927ca5f2a33c8e81243ac2efa397f47a7e6 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy <41742639+schouhy@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:50:06 -0300 Subject: [PATCH 08/11] Update nssa/src/program.rs Co-authored-by: Daniil Polyakov --- nssa/src/program.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nssa/src/program.rs b/nssa/src/program.rs index 4d2232d..f60237a 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -107,7 +107,7 @@ impl Program { pub fn pinata_token() -> Self { use crate::program_methods::PINATA_TOKEN_ELF; - Self::new(PINATA_TOKEN_ELF.to_vec()).unwrap() + Self::new(PINATA_TOKEN_ELF.to_vec()).expect("pinata token elf is defined in risc0 build of `program_methods`") } } From dcef017f9b95c839429fa77002dee6d862304fd9 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Tue, 2 Dec 2025 12:12:56 -0300 Subject: [PATCH 09/11] nit --- nssa/program_methods/guest/src/bin/token.rs | 2 +- nssa/src/program.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/nssa/program_methods/guest/src/bin/token.rs b/nssa/program_methods/guest/src/bin/token.rs index b527045..71a4b8d 100644 --- a/nssa/program_methods/guest/src/bin/token.rs +++ b/nssa/program_methods/guest/src/bin/token.rs @@ -639,7 +639,7 @@ mod tests { AccountWithMetadata { account: Account { // Definition ID with - data: vec![0; TOKEN_DEFINITION_DATA_SIZE - 16] + data: [0; TOKEN_DEFINITION_DATA_SIZE - 16] .into_iter() .chain(u128::to_le_bytes(1000)) .collect(), diff --git a/nssa/src/program.rs b/nssa/src/program.rs index f60237a..b522c8a 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -107,7 +107,8 @@ impl Program { pub fn pinata_token() -> Self { use crate::program_methods::PINATA_TOKEN_ELF; - Self::new(PINATA_TOKEN_ELF.to_vec()).expect("pinata token elf is defined in risc0 build of `program_methods`") + Self::new(PINATA_TOKEN_ELF.to_vec()) + .expect("pinata token elf is defined in risc0 build of `program_methods`") } } From 407c3f3c9581667055993341a4353624058ec964 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Tue, 2 Dec 2025 16:27:22 -0300 Subject: [PATCH 10/11] improve expect message --- nssa/src/program.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nssa/src/program.rs b/nssa/src/program.rs index b522c8a..21a03ce 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -107,8 +107,7 @@ impl Program { pub fn pinata_token() -> Self { use crate::program_methods::PINATA_TOKEN_ELF; - Self::new(PINATA_TOKEN_ELF.to_vec()) - .expect("pinata token elf is defined in risc0 build of `program_methods`") + Self::new(PINATA_TOKEN_ELF.to_vec()).expect("PiƱata program must be a valid R0BF file") } } From 925ae8d0c165f0c8b5aaad528ecbb62deb25ea1f Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Thu, 4 Dec 2025 21:34:47 -0300 Subject: [PATCH 11/11] fmt --- nssa/core/src/program.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index 03a2b7e..37a87da 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -1,10 +1,9 @@ use risc0_zkvm::{DeserializeOwned, guest::env, serde::Deserializer}; use serde::{Deserialize, Serialize}; -use crate::account::{Account, AccountWithMetadata}; - #[cfg(feature = "host")] use crate::account::AccountId; +use crate::account::{Account, AccountWithMetadata}; pub type ProgramId = [u32; 8]; pub type InstructionData = Vec;