merge main into feat-caller-program-id-and-flash-swap

This commit is contained in:
Moudy 2026-04-07 19:27:27 +02:00
parent 7d465dded7
commit b22a989fbc
37 changed files with 1674 additions and 586 deletions

13
Cargo.lock generated
View File

@ -1462,6 +1462,14 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "clock_core"
version = "0.1.0"
dependencies = [
"borsh",
"nssa_core",
]
[[package]]
name = "cobs"
version = "0.3.0"
@ -1511,6 +1519,7 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"borsh",
"clock_core",
"hex",
"log",
"logos-blockchain-common-http-client",
@ -5259,6 +5268,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"borsh",
"clock_core",
"env_logger",
"hex",
"hex-literal 1.1.0",
@ -5897,6 +5907,7 @@ dependencies = [
"amm_program",
"ata_core",
"ata_program",
"clock_core",
"nssa_core",
"risc0-zkvm",
"serde",
@ -7151,6 +7162,7 @@ dependencies = [
"serde_json",
"storage",
"tempfile",
"test_program_methods",
"testnet_initial_state",
"tokio",
"url",
@ -7831,6 +7843,7 @@ dependencies = [
name = "test_programs"
version = "0.1.0"
dependencies = [
"clock_core",
"nssa_core",
"risc0-zkvm",
"serde",

View File

@ -15,6 +15,7 @@ members = [
"nssa/core",
"programs/amm/core",
"programs/amm",
"programs/clock/core",
"programs/token/core",
"programs/token",
"programs/associated_token_account/core",
@ -56,6 +57,7 @@ indexer_service_protocol = { path = "indexer/service/protocol" }
indexer_service_rpc = { path = "indexer/service/rpc" }
wallet = { path = "wallet" }
wallet-ffi = { path = "wallet-ffi", default-features = false }
clock_core = { path = "programs/clock/core" }
token_core = { path = "programs/token/core" }
token_program = { path = "programs/token" }
amm_core = { path = "programs/amm/core" }

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -10,6 +10,7 @@ workspace = true
[dependencies]
nssa.workspace = true
nssa_core.workspace = true
clock_core.workspace = true
anyhow.workspace = true
thiserror.workspace = true

View File

@ -1,10 +1,10 @@
use borsh::{BorshDeserialize, BorshSerialize};
use nssa_core::{BlockId, Timestamp};
use nssa_core::BlockId;
pub use nssa_core::Timestamp;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256, digest::FixedOutput as _};
use crate::{HashType, transaction::NSSATransaction};
pub type MantleMsgId = [u8; 32];
pub type BlockHash = HashType;

View File

@ -1,6 +1,6 @@
use borsh::{BorshDeserialize, BorshSerialize};
use log::warn;
use nssa::{AccountId, V03State};
use nssa::{AccountId, V03State, ValidatedStateDiff};
use nssa_core::{BlockId, Timestamp};
use serde::{Deserialize, Serialize};
@ -66,21 +66,53 @@ impl NSSATransaction {
}
}
/// Validates the transaction against the current state and returns the resulting diff
/// without applying it. Rejects transactions that modify clock system accounts.
pub fn validate_on_state(
&self,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<ValidatedStateDiff, nssa::error::NssaError> {
let diff = match self {
Self::Public(tx) => {
ValidatedStateDiff::from_public_transaction(tx, state, block_id, timestamp)
}
Self::PrivacyPreserving(tx) => ValidatedStateDiff::from_privacy_preserving_transaction(
tx, state, block_id, timestamp,
),
Self::ProgramDeployment(tx) => {
ValidatedStateDiff::from_program_deployment_transaction(tx, state)
}
}?;
let public_diff = diff.public_diff();
let touches_clock = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().any(|id| {
public_diff
.get(id)
.is_some_and(|post| *post != state.get_account_by_id(*id))
});
if touches_clock {
return Err(nssa::error::NssaError::InvalidInput(
"Transaction modifies system clock accounts".into(),
));
}
Ok(diff)
}
/// Validates the transaction against the current state, rejects modifications to clock
/// system accounts, and applies the resulting diff to the state.
pub fn execute_check_on_state(
self,
state: &mut V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<Self, nssa::error::NssaError> {
match &self {
Self::Public(tx) => state.transition_from_public_transaction(tx, block_id, timestamp),
Self::PrivacyPreserving(tx) => {
state.transition_from_privacy_preserving_transaction(tx, block_id, timestamp)
}
Self::ProgramDeployment(tx) => state.transition_from_program_deployment_transaction(tx),
}
.inspect_err(|err| warn!("Error at transition {err:#?}"))?;
let diff = self
.validate_on_state(state, block_id, timestamp)
.inspect_err(|err| warn!("Error at transition {err:#?}"))?;
state.apply_state_diff(diff);
Ok(self)
}
}
@ -121,3 +153,20 @@ pub enum TransactionMalformationError {
#[error("Transaction size {size} exceeds maximum allowed size of {max} bytes")]
TransactionTooLarge { size: usize, max: usize },
}
/// Returns the canonical Clock Program invocation transaction for the given block timestamp.
/// Every valid block must end with exactly one occurrence of this transaction.
#[must_use]
pub fn clock_invocation(timestamp: clock_core::Instruction) -> nssa::PublicTransaction {
let message = nssa::public_transaction::Message::try_new(
nssa::program::Program::clock().id(),
clock_core::CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(),
vec![],
timestamp,
)
.expect("Clock invocation message should always be constructable");
nssa::PublicTransaction::new(
message,
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}

View File

@ -4,7 +4,7 @@ use anyhow::Result;
use bedrock_client::HeaderId;
use common::{
block::{BedrockStatus, Block},
transaction::NSSATransaction,
transaction::{NSSATransaction, clock_invocation},
};
use nssa::{Account, AccountId, V03State};
use nssa_core::BlockId;
@ -122,7 +122,18 @@ impl IndexerStore {
{
let mut state_guard = self.current_state.write().await;
for transaction in &block.body.transactions {
let (clock_tx, user_txs) = block
.body
.transactions
.split_last()
.ok_or_else(|| anyhow::anyhow!("Block has no transactions"))?;
anyhow::ensure!(
*clock_tx == NSSATransaction::Public(clock_invocation(block.header.timestamp)),
"Last transaction in block must be the clock invocation for the block timestamp"
);
for transaction in user_txs {
transaction
.clone()
.transaction_stateless_check()?
@ -132,6 +143,16 @@ impl IndexerStore {
block.header.timestamp,
)?;
}
// Apply the clock invocation directly (it is expected to modify clock accounts).
let NSSATransaction::Public(clock_public_tx) = clock_tx else {
anyhow::bail!("Clock invocation must be a public transaction");
};
state_guard.transition_from_public_transaction(
clock_public_tx,
block.header.block_id,
block.header.timestamp,
)?;
}
// ToDo: Currently we are fetching only finalized blocks
@ -177,7 +198,7 @@ mod tests {
let storage = IndexerStore::open_db_with_genesis(
home.as_ref(),
&genesis_block(),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[], 0),
)
.unwrap();
@ -195,7 +216,7 @@ mod tests {
let storage = IndexerStore::open_db_with_genesis(
home.as_ref(),
&genesis_block(),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[], 0),
)
.unwrap();
@ -213,11 +234,14 @@ mod tests {
10,
&sign_key,
);
let block_id = u64::try_from(i).unwrap();
let block_timestamp = block_id.saturating_mul(100);
let clock_tx = NSSATransaction::Public(clock_invocation(block_timestamp));
let next_block = common::test_utils::produce_dummy_block(
u64::try_from(i).unwrap(),
block_id,
Some(prev_hash),
vec![tx],
vec![tx, clock_tx],
);
prev_hash = next_block.header.hash;

View File

@ -92,6 +92,7 @@ impl IndexerCore {
let mut state = V03State::new_with_genesis_accounts(
&init_accs.unwrap_or_default(),
&initial_commitments.unwrap_or_default(),
genesis_block.header.timestamp,
);
// ToDo: Remove after testnet

View File

@ -138,7 +138,7 @@ pub struct Account {
}
pub type BlockId = u64;
pub type TimeStamp = u64;
pub type Timestamp = u64;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct Block {
@ -153,7 +153,7 @@ pub struct BlockHeader {
pub block_id: BlockId,
pub prev_block_hash: HashType,
pub hash: HashType,
pub timestamp: TimeStamp,
pub timestamp: Timestamp,
pub signature: Signature,
}

View File

@ -9,6 +9,7 @@ workspace = true
[dependencies]
nssa_core = { workspace = true, features = ["host"] }
clock_core.workspace = true
anyhow.workspace = true
thiserror.workspace = true

View File

@ -55,7 +55,7 @@ pub type NullifierSecretKey = [u8; 32];
#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
#[cfg_attr(
any(feature = "host", test),
derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)
derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)
)]
pub struct Nullifier(pub(super) [u8; 32]);

View File

@ -16,7 +16,11 @@ pub use program_deployment_transaction::ProgramDeploymentTransaction;
pub use program_methods::PRIVACY_PRESERVING_CIRCUIT_ID;
pub use public_transaction::PublicTransaction;
pub use signature::{PrivateKey, PublicKey, Signature};
pub use state::V03State;
pub use state::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
CLOCK_PROGRAM_ACCOUNT_IDS, V03State,
};
pub use validated_state_diff::ValidatedStateDiff;
pub mod encoding;
pub mod error;
@ -27,6 +31,7 @@ pub mod program_deployment_transaction;
pub mod public_transaction;
mod signature;
mod state;
mod validated_state_diff;
pub mod program_methods {
include!(concat!(env!("OUT_DIR"), "/program_methods/mod.rs"));

View File

@ -1,19 +1,10 @@
use std::{
collections::{HashMap, HashSet},
hash::Hash,
};
use std::collections::HashSet;
use borsh::{BorshDeserialize, BorshSerialize};
use nssa_core::{
BlockId, PrivacyPreservingCircuitOutput, Timestamp,
account::{Account, AccountWithMetadata},
};
use nssa_core::account::AccountId;
use sha2::{Digest as _, digest::FixedOutput as _};
use super::{message::Message, witness_set::WitnessSet};
use crate::{
AccountId, V03State, error::NssaError, privacy_preserving_transaction::circuit::Proof,
};
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct PrivacyPreservingTransaction {
@ -30,108 +21,6 @@ impl PrivacyPreservingTransaction {
}
}
pub(crate) fn validate_and_produce_public_state_diff(
&self,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<HashMap<AccountId, Account>, NssaError> {
let message = &self.message;
let witness_set = &self.witness_set;
// 1. Commitments or nullifiers are non empty
if message.new_commitments.is_empty() && message.new_nullifiers.is_empty() {
return Err(NssaError::InvalidInput(
"Empty commitments and empty nullifiers found in message".into(),
));
}
// 2. Check there are no duplicate account_ids in the public_account_ids list.
if n_unique(&message.public_account_ids) != message.public_account_ids.len() {
return Err(NssaError::InvalidInput(
"Duplicate account_ids found in message".into(),
));
}
// Check there are no duplicate nullifiers in the new_nullifiers list
if n_unique(&message.new_nullifiers) != message.new_nullifiers.len() {
return Err(NssaError::InvalidInput(
"Duplicate nullifiers found in message".into(),
));
}
// Check there are no duplicate commitments in the new_commitments list
if n_unique(&message.new_commitments) != message.new_commitments.len() {
return Err(NssaError::InvalidInput(
"Duplicate commitments found in message".into(),
));
}
// 3. Nonce checks and Valid signatures
// Check exactly one nonce is provided for each signature
if message.nonces.len() != witness_set.signatures_and_public_keys.len() {
return Err(NssaError::InvalidInput(
"Mismatch between number of nonces and signatures/public keys".into(),
));
}
// Check the signatures are valid
if !witness_set.signatures_are_valid_for(message) {
return Err(NssaError::InvalidInput(
"Invalid signature for given message and public key".into(),
));
}
let signer_account_ids = self.signer_account_ids();
// Check nonces corresponds to the current nonces on the public state.
for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) {
let current_nonce = state.get_account_by_id(*account_id).nonce;
if current_nonce != *nonce {
return Err(NssaError::InvalidInput("Nonce mismatch".into()));
}
}
// Verify validity window
if !message.block_validity_window.is_valid_for(block_id)
|| !message.timestamp_validity_window.is_valid_for(timestamp)
{
return Err(NssaError::OutOfValidityWindow);
}
// Build pre_states for proof verification
let public_pre_states: Vec<_> = message
.public_account_ids
.iter()
.map(|account_id| {
AccountWithMetadata::new(
state.get_account_by_id(*account_id),
signer_account_ids.contains(account_id),
*account_id,
)
})
.collect();
// 4. Proof verification
check_privacy_preserving_circuit_proof_is_valid(
&witness_set.proof,
&public_pre_states,
message,
)?;
// 5. Commitment freshness
state.check_commitments_are_new(&message.new_commitments)?;
// 6. Nullifier uniqueness
state.check_nullifiers_are_valid(&message.new_nullifiers)?;
Ok(message
.public_account_ids
.iter()
.copied()
.zip(message.public_post_states.clone())
.collect())
}
#[must_use]
pub const fn message(&self) -> &Message {
&self.message
@ -170,36 +59,6 @@ impl PrivacyPreservingTransaction {
}
}
fn check_privacy_preserving_circuit_proof_is_valid(
proof: &Proof,
public_pre_states: &[AccountWithMetadata],
message: &Message,
) -> Result<(), NssaError> {
let output = PrivacyPreservingCircuitOutput {
public_pre_states: public_pre_states.to_vec(),
public_post_states: message.public_post_states.clone(),
ciphertexts: message
.encrypted_private_post_states
.iter()
.cloned()
.map(|value| value.ciphertext)
.collect(),
new_commitments: message.new_commitments.clone(),
new_nullifiers: message.new_nullifiers.clone(),
block_validity_window: message.block_validity_window,
timestamp_validity_window: message.timestamp_validity_window,
};
proof
.is_valid_for(&output)
.then_some(())
.ok_or(NssaError::InvalidPrivacyPreservingProof)
}
fn n_unique<T: Eq + Hash>(data: &[T]) -> usize {
let set: HashSet<&T> = data.iter().collect();
set.len()
}
#[cfg(test)]
mod tests {
use crate::{

View File

@ -9,7 +9,8 @@ use serde::Serialize;
use crate::{
error::NssaError,
program_methods::{
AMM_ELF, ASSOCIATED_TOKEN_ACCOUNT_ELF, AUTHENTICATED_TRANSFER_ELF, PINATA_ELF, TOKEN_ELF,
AMM_ELF, ASSOCIATED_TOKEN_ACCOUNT_ELF, AUTHENTICATED_TRANSFER_ELF, CLOCK_ELF, PINATA_ELF,
TOKEN_ELF,
},
};
@ -126,6 +127,11 @@ impl Program {
Self::new(AMM_ELF.to_vec()).expect("The AMM program must be a valid Risc0 program")
}
#[must_use]
pub fn clock() -> Self {
Self::new(CLOCK_ELF.to_vec()).expect("The clock program must be a valid Risc0 program")
}
#[must_use]
pub fn ata() -> Self {
Self::new(ASSOCIATED_TOKEN_ACCOUNT_ELF.to_vec())
@ -352,6 +358,18 @@ mod tests {
Self::new(MALICIOUS_CALLER_PROGRAM_ID_ELF.to_vec())
.expect("malicious_caller_program_id must be a valid Risc0 program")
}
#[must_use]
pub fn time_locked_transfer() -> Self {
use test_program_methods::TIME_LOCKED_TRANSFER_ELF;
Self::new(TIME_LOCKED_TRANSFER_ELF.to_vec()).unwrap()
}
#[must_use]
pub fn pinata_cooldown() -> Self {
use test_program_methods::PINATA_COOLDOWN_ELF;
Self::new(PINATA_COOLDOWN_ELF.to_vec()).unwrap()
}
}
#[test]

View File

@ -2,9 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize};
use nssa_core::account::AccountId;
use sha2::{Digest as _, digest::FixedOutput as _};
use crate::{
V03State, error::NssaError, program::Program, program_deployment_transaction::message::Message,
};
use crate::program_deployment_transaction::message::Message;
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct ProgramDeploymentTransaction {
@ -22,19 +20,6 @@ impl ProgramDeploymentTransaction {
self.message
}
pub(crate) fn validate_and_produce_public_state_diff(
&self,
state: &V03State,
) -> Result<Program, NssaError> {
// TODO: remove clone
let program = Program::new(self.message.bytecode.clone())?;
if state.programs().contains_key(&program.id()) {
Err(NssaError::ProgramAlreadyExists)
} else {
Ok(program)
}
}
#[must_use]
pub fn hash(&self) -> [u8; 32] {
let bytes = self.to_bytes();

View File

@ -1,20 +1,10 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::collections::HashSet;
use borsh::{BorshDeserialize, BorshSerialize};
use log::debug;
use nssa_core::{
BlockId, Timestamp,
account::{Account, AccountId, AccountWithMetadata},
program::{ChainedCall, Claim, DEFAULT_PROGRAM_ID, validate_execution},
};
use nssa_core::account::AccountId;
use sha2::{Digest as _, digest::FixedOutput as _};
use crate::{
V03State, ensure,
error::NssaError,
public_transaction::{Message, WitnessSet},
state::MAX_NUMBER_CHAINED_CALLS,
};
use crate::public_transaction::{Message, WitnessSet};
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct PublicTransaction {
@ -67,226 +57,6 @@ impl PublicTransaction {
hasher.update(&bytes);
hasher.finalize_fixed().into()
}
pub(crate) fn validate_and_produce_public_state_diff(
&self,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<HashMap<AccountId, Account>, NssaError> {
let message = self.message();
let witness_set = self.witness_set();
// All account_ids must be different
ensure!(
message.account_ids.iter().collect::<HashSet<_>>().len() == message.account_ids.len(),
NssaError::InvalidInput("Duplicate account_ids found in message".into(),)
);
// Check exactly one nonce is provided for each signature
ensure!(
message.nonces.len() == witness_set.signatures_and_public_keys.len(),
NssaError::InvalidInput(
"Mismatch between number of nonces and signatures/public keys".into(),
)
);
// Check the signatures are valid
ensure!(
witness_set.is_valid_for(message),
NssaError::InvalidInput("Invalid signature for given message and public key".into())
);
let signer_account_ids = self.signer_account_ids();
// Check nonces corresponds to the current nonces on the public state.
for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) {
let current_nonce = state.get_account_by_id(*account_id).nonce;
ensure!(
current_nonce == *nonce,
NssaError::InvalidInput("Nonce mismatch".into())
);
}
// Build pre_states for execution
let input_pre_states: Vec<_> = message
.account_ids
.iter()
.map(|account_id| {
AccountWithMetadata::new(
state.get_account_by_id(*account_id),
signer_account_ids.contains(account_id),
*account_id,
)
})
.collect();
let mut state_diff: HashMap<AccountId, Account> = HashMap::new();
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 chained_calls = VecDeque::from_iter([(initial_call, None)]);
let mut chain_calls_counter = 0;
while let Some((chained_call, caller_program_id)) = chained_calls.pop_front() {
ensure!(
chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS,
NssaError::MaxChainedCallsDepthExceeded
);
// Check that the `program_id` corresponds to a deployed program
let Some(program) = state.programs().get(&chained_call.program_id) else {
return Err(NssaError::InvalidInput("Unknown program".into()));
};
debug!(
"Program {:?} pre_states: {:?}, instruction_data: {:?}",
chained_call.program_id, chained_call.pre_states, chained_call.instruction_data
);
let mut program_output = program.execute(
caller_program_id,
&chained_call.pre_states,
&chained_call.instruction_data,
)?;
debug!(
"Program {:?} output: {:?}",
chained_call.program_id, program_output
);
let authorized_pdas = nssa_core::program::compute_authorized_pdas(
caller_program_id,
&chained_call.pda_seeds,
);
let is_authorized = |account_id: &AccountId| {
signer_account_ids.contains(account_id) || authorized_pdas.contains(account_id)
};
for pre in &program_output.pre_states {
let account_id = pre.account_id;
// Check that the program output pre_states coincide with the values in the public
// state or with any modifications to those values during the chain of calls.
let expected_pre = state_diff
.get(&account_id)
.cloned()
.unwrap_or_else(|| state.get_account_by_id(account_id));
ensure!(
pre.account == expected_pre,
NssaError::InvalidProgramBehavior
);
// Check that authorization flags are consistent with the provided ones or
// authorized by program through the PDA mechanism
ensure!(
pre.is_authorized == is_authorized(&account_id),
NssaError::InvalidProgramBehavior
);
}
// Verify that the program output's self_program_id matches the expected program ID.
ensure!(
program_output.self_program_id == chained_call.program_id,
NssaError::InvalidProgramBehavior
);
// Verify that the program output's caller_program_id matches the actual caller.
ensure!(
program_output.caller_program_id == caller_program_id,
NssaError::InvalidProgramBehavior
);
// Verify execution corresponds to a well-behaved program.
// See the # Programs section for the definition of the `validate_execution` method.
ensure!(
validate_execution(
&program_output.pre_states,
&program_output.post_states,
chained_call.program_id,
),
NssaError::InvalidProgramBehavior
);
// Verify validity window
ensure!(
program_output.block_validity_window.is_valid_for(block_id)
&& program_output
.timestamp_validity_window
.is_valid_for(timestamp),
NssaError::OutOfValidityWindow
);
for (i, post) in program_output.post_states.iter_mut().enumerate() {
let Some(claim) = post.required_claim() else {
continue;
};
// The invoked program can only claim accounts with default program id.
ensure!(
post.account().program_owner == DEFAULT_PROGRAM_ID,
NssaError::InvalidProgramBehavior
);
let account_id = program_output.pre_states[i].account_id;
match claim {
Claim::Authorized => {
// The program can only claim accounts that were authorized by the signer.
ensure!(
is_authorized(&account_id),
NssaError::InvalidProgramBehavior
);
}
Claim::Pda(seed) => {
// The program can only claim accounts that correspond to the PDAs it is
// authorized to claim.
let pda = AccountId::from((&chained_call.program_id, &seed));
ensure!(account_id == pda, NssaError::InvalidProgramBehavior);
}
}
post.account_mut().program_owner = chained_call.program_id;
}
// Update the state diff
for (pre, post) in program_output
.pre_states
.iter()
.zip(program_output.post_states.iter())
{
state_diff.insert(pre.account_id, post.account().clone());
}
for new_call in program_output.chained_calls.into_iter().rev() {
chained_calls.push_front((new_call, Some(chained_call.program_id)));
}
chain_calls_counter = chain_calls_counter
.checked_add(1)
.expect("we check the max depth at the beginning of the loop");
}
// Check that all modified uninitialized accounts where claimed
for post in state_diff.iter().filter_map(|(account_id, post)| {
let pre = state.get_account_by_id(*account_id);
if pre.program_owner != DEFAULT_PROGRAM_ID {
return None;
}
if pre == *post {
return None;
}
Some(post)
}) {
ensure!(
post.program_owner != DEFAULT_PROGRAM_ID,
NssaError::InvalidProgramBehavior
);
}
Ok(state_diff)
}
}
#[cfg(test)]
@ -298,6 +68,7 @@ pub mod tests {
error::NssaError,
program::Program,
public_transaction::{Message, WitnessSet},
validated_state_diff::ValidatedStateDiff,
};
fn keys_for_tests() -> (PrivateKey, PrivateKey, AccountId, AccountId) {
@ -311,7 +82,7 @@ pub mod tests {
fn state_for_tests() -> V03State {
let (_, _, addr1, addr2) = keys_for_tests();
let initial_data = [(addr1, 10000), (addr2, 20000)];
V03State::new_with_genesis_accounts(&initial_data, &[])
V03State::new_with_genesis_accounts(&initial_data, &[], 0)
}
fn transaction_for_tests() -> PublicTransaction {
@ -406,7 +177,7 @@ pub mod tests {
let witness_set = WitnessSet::for_message(&message, &[&key1, &key1]);
let tx = PublicTransaction::new(message, witness_set);
let result = tx.validate_and_produce_public_state_diff(&state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
@ -426,7 +197,7 @@ pub mod tests {
let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]);
let tx = PublicTransaction::new(message, witness_set);
let result = tx.validate_and_produce_public_state_diff(&state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
@ -447,7 +218,7 @@ pub mod tests {
let mut witness_set = WitnessSet::for_message(&message, &[&key1, &key2]);
witness_set.signatures_and_public_keys[0].0 = Signature::new_for_tests([1; 64]);
let tx = PublicTransaction::new(message, witness_set);
let result = tx.validate_and_produce_public_state_diff(&state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
@ -467,7 +238,7 @@ pub mod tests {
let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]);
let tx = PublicTransaction::new(message, witness_set);
let result = tx.validate_and_produce_public_state_diff(&state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
@ -483,7 +254,7 @@ pub mod tests {
let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]);
let tx = PublicTransaction::new(message, witness_set);
let result = tx.validate_and_produce_public_state_diff(&state, 1, 0);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
}
}

View File

@ -1,6 +1,11 @@
use std::collections::{BTreeSet, HashMap, HashSet};
use borsh::{BorshDeserialize, BorshSerialize};
use clock_core::ClockAccountData;
pub use clock_core::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
CLOCK_PROGRAM_ACCOUNT_IDS,
};
use nssa_core::{
BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier,
Timestamp,
@ -9,10 +14,13 @@ use nssa_core::{
};
use crate::{
error::NssaError, merkle_tree::MerkleTree,
privacy_preserving_transaction::PrivacyPreservingTransaction, program::Program,
error::NssaError,
merkle_tree::MerkleTree,
privacy_preserving_transaction::PrivacyPreservingTransaction,
program::Program,
program_deployment_transaction::ProgramDeploymentTransaction,
public_transaction::PublicTransaction,
validated_state_diff::{StateDiff, ValidatedStateDiff},
};
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
@ -73,7 +81,7 @@ impl NullifierSet {
Self(BTreeSet::new())
}
fn extend(&mut self, new_nullifiers: Vec<Nullifier>) {
fn extend(&mut self, new_nullifiers: &[Nullifier]) {
self.0.extend(new_nullifiers);
}
@ -119,6 +127,7 @@ impl V03State {
pub fn new_with_genesis_accounts(
initial_data: &[(AccountId, u128)],
initial_commitments: &[nssa_core::Commitment],
genesis_timestamp: nssa_core::Timestamp,
) -> Self {
let authenticated_transfer_program = Program::authenticated_transfer_program();
let public_state = initial_data
@ -144,6 +153,9 @@ impl V03State {
programs: HashMap::new(),
};
this.insert_program(Program::clock());
this.insert_clock_accounts(genesis_timestamp);
this.insert_program(Program::authenticated_transfer_program());
this.insert_program(Program::token());
this.insert_program(Program::amm());
@ -152,33 +164,67 @@ impl V03State {
this
}
fn insert_clock_accounts(&mut self, genesis_timestamp: nssa_core::Timestamp) {
let data = ClockAccountData {
block_id: 0,
timestamp: genesis_timestamp,
}
.to_bytes();
let clock_program_id = Program::clock().id();
for account_id in CLOCK_PROGRAM_ACCOUNT_IDS {
self.public_state.insert(
account_id,
Account {
program_owner: clock_program_id,
data: data
.clone()
.try_into()
.expect("Clock account data should fit within accounts data"),
..Account::default()
},
);
}
}
pub(crate) fn insert_program(&mut self, program: Program) {
self.programs.insert(program.id(), program);
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
let StateDiff {
signer_account_ids,
public_diff,
new_commitments,
new_nullifiers,
program,
} = diff.into_state_diff();
#[expect(
clippy::iter_over_hash_type,
reason = "Iteration order doesn't matter here"
)]
for (account_id, account) in public_diff {
*self.get_account_by_id_mut(account_id) = account;
}
for account_id in signer_account_ids {
self.get_account_by_id_mut(account_id)
.nonce
.public_account_nonce_increment();
}
self.private_state.0.extend(&new_commitments);
self.private_state.1.extend(&new_nullifiers);
if let Some(program) = program {
self.insert_program(program);
}
}
pub fn transition_from_public_transaction(
&mut self,
tx: &PublicTransaction,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<(), NssaError> {
let state_diff = tx.validate_and_produce_public_state_diff(self, block_id, timestamp)?;
#[expect(
clippy::iter_over_hash_type,
reason = "Iteration order doesn't matter here"
)]
for (account_id, post) in state_diff {
let current_account = self.get_account_by_id_mut(account_id);
*current_account = post;
}
for account_id in tx.signer_account_ids() {
let current_account = self.get_account_by_id_mut(account_id);
current_account.nonce.public_account_nonce_increment();
}
let diff = ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp)?;
self.apply_state_diff(diff);
Ok(())
}
@ -188,40 +234,9 @@ impl V03State {
block_id: BlockId,
timestamp: Timestamp,
) -> Result<(), NssaError> {
// 1. Verify the transaction satisfies acceptance criteria
let public_state_diff =
tx.validate_and_produce_public_state_diff(self, block_id, timestamp)?;
let message = tx.message();
// 2. Add new commitments
self.private_state.0.extend(&message.new_commitments);
// 3. Add new nullifiers
let new_nullifiers = message
.new_nullifiers
.iter()
.cloned()
.map(|(nullifier, _)| nullifier)
.collect::<Vec<Nullifier>>();
self.private_state.1.extend(new_nullifiers);
// 4. Update public accounts
#[expect(
clippy::iter_over_hash_type,
reason = "Iteration order doesn't matter here"
)]
for (account_id, post) in public_state_diff {
let current_account = self.get_account_by_id_mut(account_id);
*current_account = post;
}
// 5. Increment nonces for public signers
for account_id in tx.signer_account_ids() {
let current_account = self.get_account_by_id_mut(account_id);
current_account.nonce.public_account_nonce_increment();
}
let diff =
ValidatedStateDiff::from_privacy_preserving_transaction(tx, self, block_id, timestamp)?;
self.apply_state_diff(diff);
Ok(())
}
@ -229,8 +244,8 @@ impl V03State {
&mut self,
tx: &ProgramDeploymentTransaction,
) -> Result<(), NssaError> {
let program = tx.validate_and_produce_public_state_diff(self)?;
self.insert_program(program);
let diff = ValidatedStateDiff::from_program_deployment_transaction(tx, self)?;
self.apply_state_diff(diff);
Ok(())
}
@ -362,7 +377,10 @@ pub mod tests {
program::Program,
public_transaction,
signature::PrivateKey,
state::MAX_NUMBER_CHAINED_CALLS,
state::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS,
},
};
impl V03State {
@ -386,6 +404,8 @@ pub mod tests {
self.insert_program(Program::flash_swap_callback());
self.insert_program(Program::malicious_self_program_id());
self.insert_program(Program::malicious_caller_program_id());
self.insert_program(Program::time_locked_transfer());
self.insert_program(Program::pinata_cooldown());
self
}
@ -528,6 +548,7 @@ pub mod tests {
let addr2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let initial_data = [(addr1, 100_u128), (addr2, 151_u128)];
let authenticated_transfers_program = Program::authenticated_transfer_program();
let clock_program = Program::clock();
let expected_public_state = {
let mut this = HashMap::new();
this.insert(
@ -546,6 +567,16 @@ pub mod tests {
..Account::default()
},
);
for account_id in CLOCK_PROGRAM_ACCOUNT_IDS {
this.insert(
account_id,
Account {
program_owner: clock_program.id(),
data: [0_u8; 16].to_vec().try_into().unwrap(),
..Account::default()
},
);
}
this
};
let expected_builtin_programs = {
@ -554,13 +585,14 @@ pub mod tests {
authenticated_transfers_program.id(),
authenticated_transfers_program,
);
this.insert(clock_program.id(), clock_program);
this.insert(Program::token().id(), Program::token());
this.insert(Program::amm().id(), Program::amm());
this.insert(Program::ata().id(), Program::ata());
this
};
let state = V03State::new_with_genesis_accounts(&initial_data, &[]);
let state = V03State::new_with_genesis_accounts(&initial_data, &[], 0);
assert_eq!(state.public_state, expected_public_state);
assert_eq!(state.programs, expected_builtin_programs);
@ -568,7 +600,7 @@ pub mod tests {
#[test]
fn insert_program() {
let mut state = V03State::new_with_genesis_accounts(&[], &[]);
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
let program_to_insert = Program::simple_balance_transfer();
let program_id = program_to_insert.id();
assert!(!state.programs.contains_key(&program_id));
@ -583,7 +615,7 @@ pub mod tests {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let initial_data = [(account_id, 100_u128)];
let state = V03State::new_with_genesis_accounts(&initial_data, &[]);
let state = V03State::new_with_genesis_accounts(&initial_data, &[], 0);
let expected_account = &state.public_state[&account_id];
let account = state.get_account_by_id(account_id);
@ -594,7 +626,7 @@ pub mod tests {
#[test]
fn get_account_by_account_id_default_account() {
let addr2 = AccountId::new([0; 32]);
let state = V03State::new_with_genesis_accounts(&[], &[]);
let state = V03State::new_with_genesis_accounts(&[], &[], 0);
let expected_account = Account::default();
let account = state.get_account_by_id(addr2);
@ -604,7 +636,7 @@ pub mod tests {
#[test]
fn builtin_programs_getter() {
let state = V03State::new_with_genesis_accounts(&[], &[]);
let state = V03State::new_with_genesis_accounts(&[], &[], 0);
let builtin_programs = state.programs();
@ -616,7 +648,7 @@ pub mod tests {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let initial_data = [(account_id, 100)];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]);
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0);
let from = account_id;
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
@ -637,7 +669,7 @@ pub mod tests {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let initial_data = [(account_id, 100)];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]);
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0);
let from = account_id;
let from_key = key;
let to_key = PrivateKey::try_new([2; 32]).unwrap();
@ -662,7 +694,7 @@ pub mod tests {
let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1));
let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let initial_data = [(account_id1, 100), (account_id2, 200)];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]);
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0);
let from = account_id2;
let from_key = key2;
let to = account_id1;
@ -686,7 +718,7 @@ pub mod tests {
let key2 = PrivateKey::try_new([2; 32]).unwrap();
let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let initial_data = [(account_id1, 100)];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]);
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0);
let key3 = PrivateKey::try_new([3; 32]).unwrap();
let account_id3 = AccountId::from(&PublicKey::new_from_private_key(&key3));
let balance_to_move = 5;
@ -721,11 +753,154 @@ pub mod tests {
assert_eq!(state.get_account_by_id(account_id3).nonce, Nonce(1));
}
fn clock_transaction(timestamp: nssa_core::Timestamp) -> PublicTransaction {
let message = public_transaction::Message::try_new(
Program::clock().id(),
CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(),
vec![],
timestamp,
)
.unwrap();
PublicTransaction::new(
message,
public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}
fn clock_account_data(state: &V03State, account_id: AccountId) -> (u64, nssa_core::Timestamp) {
let data = state.get_account_by_id(account_id).data.into_inner();
let parsed = clock_core::ClockAccountData::from_bytes(&data);
(parsed.block_id, parsed.timestamp)
}
#[test]
fn clock_genesis_state_has_zero_block_id_and_genesis_timestamp() {
let genesis_timestamp = 1_000_000_u64;
let state = V03State::new_with_genesis_accounts(&[], &[], genesis_timestamp);
let (block_id, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id, 0);
assert_eq!(timestamp, genesis_timestamp);
}
#[test]
fn clock_invocation_increments_block_id() {
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
let tx = clock_transaction(1234);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let (block_id, _) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id, 1);
}
#[test]
fn clock_invocation_stores_timestamp_from_instruction() {
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
let block_timestamp = 1_700_000_000_000_u64;
let tx = clock_transaction(block_timestamp);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let (_, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
assert_eq!(timestamp, block_timestamp);
}
#[test]
fn clock_invocation_sequence_correctly_increments_block_id() {
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
for expected_block_id in 1_u64..=5 {
let tx = clock_transaction(expected_block_id * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let (block_id, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id, expected_block_id);
assert_eq!(timestamp, expected_block_id * 1000);
}
}
#[test]
fn clock_10_account_not_updated_when_block_id_not_multiple_of_10() {
let genesis_timestamp = 0_u64;
let mut state = V03State::new_with_genesis_accounts(&[], &[], genesis_timestamp);
// Run 9 clock ticks (block_ids 1..=9), none of which are multiples of 10.
for tick in 1_u64..=9 {
let tx = clock_transaction(tick * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
}
let (block_id_10, timestamp_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID);
// The 10-block account should still reflect genesis state.
assert_eq!(block_id_10, 0);
assert_eq!(timestamp_10, genesis_timestamp);
}
#[test]
fn clock_10_account_updated_when_block_id_is_multiple_of_10() {
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
// Run 10 clock ticks so block_id reaches 10.
for tick in 1_u64..=10 {
let tx = clock_transaction(tick * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
}
let (block_id_1, timestamp_1) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
let (block_id_10, timestamp_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id_1, 10);
assert_eq!(block_id_10, 10);
assert_eq!(timestamp_10, timestamp_1);
}
#[test]
fn clock_50_account_only_updated_at_multiples_of_50() {
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
// After 49 ticks the 50-block account should be unchanged.
for tick in 1_u64..=49 {
let tx = clock_transaction(tick * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
}
let (block_id_50, _) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id_50, 0);
// Tick 50 — now the 50-block account should update.
let tx = clock_transaction(50 * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let (block_id_50, timestamp_50) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id_50, 50);
assert_eq!(timestamp_50, 50 * 1000);
}
#[test]
fn all_three_clock_accounts_updated_at_multiple_of_50() {
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
// Advance to block 50 (a multiple of both 10 and 50).
for tick in 1_u64..=50 {
let tx = clock_transaction(tick * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
}
let (block_id_1, ts_1) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
let (block_id_10, ts_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID);
let (block_id_50, ts_50) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id_1, 50);
assert_eq!(block_id_10, 50);
assert_eq!(block_id_50, 50);
assert_eq!(ts_1, ts_10);
assert_eq!(ts_1, ts_50);
}
#[test]
fn program_should_fail_if_modifies_nonces() {
let initial_data = [(AccountId::new([1; 32]), 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let account_ids = vec![AccountId::new([1; 32])];
let program_id = Program::nonce_changer_program().id();
let message =
@ -742,7 +917,7 @@ pub mod tests {
fn program_should_fail_if_output_accounts_exceed_inputs() {
let initial_data = [(AccountId::new([1; 32]), 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let account_ids = vec![AccountId::new([1; 32])];
let program_id = Program::extra_output_program().id();
let message =
@ -759,7 +934,7 @@ pub mod tests {
fn program_should_fail_with_missing_output_accounts() {
let initial_data = [(AccountId::new([1; 32]), 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])];
let program_id = Program::missing_output_program().id();
let message =
@ -776,7 +951,7 @@ pub mod tests {
fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() {
let initial_data = [(AccountId::new([1; 32]), 0)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let account_id = AccountId::new([1; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in the program owner
@ -799,7 +974,7 @@ pub mod tests {
#[test]
fn program_should_fail_if_modifies_program_owner_with_only_non_default_balance() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[])
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0)
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([255; 32]);
@ -823,7 +998,7 @@ pub mod tests {
#[test]
fn program_should_fail_if_modifies_program_owner_with_only_non_default_nonce() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[])
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0)
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([254; 32]);
@ -847,7 +1022,7 @@ pub mod tests {
#[test]
fn program_should_fail_if_modifies_program_owner_with_only_non_default_data() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[])
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0)
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([253; 32]);
@ -872,7 +1047,7 @@ pub mod tests {
fn program_should_fail_if_transfers_balance_from_non_owned_account() {
let initial_data = [(AccountId::new([1; 32]), 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let sender_account_id = AccountId::new([1; 32]);
let receiver_account_id = AccountId::new([2; 32]);
let balance_to_move: u128 = 1;
@ -899,7 +1074,7 @@ pub mod tests {
#[test]
fn program_should_fail_if_modifies_data_of_non_owned_account() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[])
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0)
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([255; 32]);
@ -925,7 +1100,7 @@ pub mod tests {
fn program_should_fail_if_does_not_preserve_total_balance_by_minting() {
let initial_data = [];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let account_id = AccountId::new([1; 32]);
let program_id = Program::minter().id();
@ -942,7 +1117,7 @@ pub mod tests {
#[test]
fn program_should_fail_if_does_not_preserve_total_balance_by_burning() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[])
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0)
.with_test_programs()
.with_account_owned_by_burner_program();
let program_id = Program::burner().id();
@ -1134,7 +1309,7 @@ pub mod tests {
let recipient_keys = test_private_account_keys_1();
let mut state =
V03State::new_with_genesis_accounts(&[(sender_keys.account_id(), 200)], &[]);
V03State::new_with_genesis_accounts(&[(sender_keys.account_id(), 200)], &[], 0);
let balance_to_move = 37;
@ -1182,7 +1357,7 @@ pub mod tests {
};
let recipient_keys = test_private_account_keys_2();
let mut state = V03State::new_with_genesis_accounts(&[], &[])
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0)
.with_private_account(&sender_keys, &sender_private_account);
let balance_to_move = 37;
@ -1252,6 +1427,7 @@ pub mod tests {
let mut state = V03State::new_with_genesis_accounts(
&[(recipient_keys.account_id(), recipient_initial_balance)],
&[],
0,
)
.with_private_account(&sender_keys, &sender_private_account);
@ -2203,7 +2379,7 @@ pub mod tests {
};
let recipient_keys = test_private_account_keys_2();
let mut state = V03State::new_with_genesis_accounts(&[], &[])
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0)
.with_private_account(&sender_keys, &sender_private_account);
let balance_to_move = 37;
@ -2288,7 +2464,7 @@ pub mod tests {
let initial_balance = 100;
let initial_data = [(from, initial_balance)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
let amount: u128 = 37;
@ -2326,7 +2502,7 @@ pub mod tests {
let program = Program::authenticated_transfer_program();
let account_key = PrivateKey::try_new([9; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key));
let mut state = V03State::new_with_genesis_accounts(&[], &[]);
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
assert_eq!(state.get_account_by_id(account_id), Account::default());
@ -2347,7 +2523,7 @@ pub mod tests {
let program = Program::authenticated_transfer_program();
let account_key = PrivateKey::try_new([10; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key));
let mut state = V03State::new_with_genesis_accounts(&[], &[]);
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
assert_eq!(state.get_account_by_id(account_id), Account::default());
@ -2382,7 +2558,7 @@ pub mod tests {
let initial_balance = 1000;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let from_key = key;
let amount: u128 = 37;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
@ -2427,7 +2603,7 @@ pub mod tests {
let initial_balance = 100;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let from_key = key;
let amount: u128 = 0;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
@ -2465,7 +2641,7 @@ pub mod tests {
let initial_balance = 1000;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let amount: u128 = 58;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
@ -2511,7 +2687,7 @@ pub mod tests {
let initial_balance = 100;
let initial_data = [(from, initial_balance)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
let amount: u128 = 37;
@ -2586,7 +2762,7 @@ pub mod tests {
};
let sender_commitment = Commitment::new(&sender_keys.npk(), &sender_private_account);
let mut state =
V03State::new_with_genesis_accounts(&[], std::slice::from_ref(&sender_commitment));
V03State::new_with_genesis_accounts(&[], std::slice::from_ref(&sender_commitment), 0);
let sender_pre = AccountWithMetadata::new(sender_private_account, true, &sender_keys.npk());
let recipient_private_key = PrivateKey::try_new([2; 32]).unwrap();
let recipient_account_id =
@ -2669,6 +2845,7 @@ pub mod tests {
let mut state = V03State::new_with_genesis_accounts(
&[],
&[from_commitment.clone(), to_commitment.clone()],
0,
)
.with_test_programs();
let amount: u128 = 37;
@ -2775,7 +2952,7 @@ pub mod tests {
..Account::default()
};
let mut state = V03State::new_with_genesis_accounts(&[], &[]);
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
state.add_pinata_token_program(pinata_definition_id);
// Set up the token accounts directly (bypassing public transactions which
@ -2847,7 +3024,7 @@ pub mod tests {
#[test]
fn claiming_mechanism_cannot_claim_initialied_accounts() {
let claimer = Program::claimer();
let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0).with_test_programs();
let account_id = AccountId::new([2; 32]);
// Insert an account with non-default program owner
@ -2888,6 +3065,7 @@ pub mod tests {
(recipient_id, recipient_init_balance),
],
&[],
0,
);
state.insert_program(Program::modified_transfer_program());
@ -2937,7 +3115,7 @@ pub mod tests {
#[test]
fn private_authorized_uninitialized_account() {
let mut state = V03State::new_with_genesis_accounts(&[], &[]);
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0);
// Set up keys for the authorized private account
let private_keys = test_private_account_keys_1();
@ -2989,7 +3167,7 @@ pub mod tests {
#[test]
fn private_unauthorized_uninitialized_account_can_still_be_claimed() {
let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0).with_test_programs();
let private_keys = test_private_account_keys_1();
// This is intentional: claim authorization was introduced to protect public accounts,
@ -3036,7 +3214,7 @@ pub mod tests {
#[test]
fn private_account_claimed_then_used_without_init_flag_should_fail() {
let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0).with_test_programs();
// Set up keys for the private account
let private_keys = test_private_account_keys_1();
@ -3117,7 +3295,7 @@ pub mod tests {
fn public_changer_claimer_no_data_change_no_claim_succeeds() {
let initial_data = [];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let account_id = AccountId::new([1; 32]);
let program_id = Program::changer_claimer().id();
// Don't change data (None) and don't claim (false)
@ -3141,7 +3319,7 @@ pub mod tests {
fn public_changer_claimer_data_change_no_claim_fails() {
let initial_data = [];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let account_id = AccountId::new([1; 32]);
let program_id = Program::changer_claimer().id();
// Change data but don't claim (false) - should fail
@ -3238,6 +3416,7 @@ pub mod tests {
let state = V03State::new_with_genesis_accounts(
&[(sender_account.account_id, sender_account.account.balance)],
std::slice::from_ref(&recipient_commitment),
0,
)
.with_test_programs();
@ -3287,7 +3466,7 @@ pub mod tests {
let validity_window_program = Program::validity_window();
let account_keys = test_public_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id());
let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0).with_test_programs();
let tx = {
let account_ids = vec![pre.account_id];
let nonces = vec![];
@ -3339,7 +3518,7 @@ pub mod tests {
let validity_window_program = Program::validity_window();
let account_keys = test_public_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id());
let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0).with_test_programs();
let tx = {
let account_ids = vec![pre.account_id];
let nonces = vec![];
@ -3392,7 +3571,7 @@ pub mod tests {
let validity_window_program = Program::validity_window();
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, &account_keys.npk());
let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &account_keys.vpk());
@ -3461,7 +3640,7 @@ pub mod tests {
let validity_window_program = Program::validity_window();
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, &account_keys.npk());
let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs();
let mut state = V03State::new_with_genesis_accounts(&[], &[], 0).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &account_keys.vpk());
@ -3510,12 +3689,233 @@ pub mod tests {
}
}
fn time_locked_transfer_transaction(
from: AccountId,
from_key: &PrivateKey,
from_nonce: u128,
to: AccountId,
clock_account_id: AccountId,
amount: u128,
deadline: u64,
) -> PublicTransaction {
let program_id = Program::time_locked_transfer().id();
let message = public_transaction::Message::try_new(
program_id,
vec![from, to, clock_account_id],
vec![Nonce(from_nonce)],
(amount, deadline),
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[from_key]);
PublicTransaction::new(message, witness_set)
}
#[test]
fn time_locked_transfer_succeeds_when_deadline_has_passed() {
let recipient_id = AccountId::new([42; 32]);
let genesis_timestamp = 500_u64;
let mut state =
V03State::new_with_genesis_accounts(&[(recipient_id, 0)], &[], genesis_timestamp)
.with_test_programs();
let key1 = PrivateKey::try_new([1; 32]).unwrap();
let sender_id = AccountId::from(&PublicKey::new_from_private_key(&key1));
state.force_insert_account(
sender_id,
Account {
program_owner: Program::time_locked_transfer().id(),
balance: 100,
..Account::default()
},
);
let amount = 100_u128;
// Deadline in the past: transfer should succeed.
let deadline = 0_u64;
let tx = time_locked_transfer_transaction(
sender_id,
&key1,
0,
recipient_id,
CLOCK_01_PROGRAM_ACCOUNT_ID,
amount,
deadline,
);
let block_id = 1;
let timestamp = genesis_timestamp + 100;
state
.transition_from_public_transaction(&tx, block_id, timestamp)
.unwrap();
// Balances changed.
assert_eq!(state.get_account_by_id(sender_id).balance, 0);
assert_eq!(state.get_account_by_id(recipient_id).balance, 100);
}
#[test]
fn time_locked_transfer_fails_when_deadline_is_in_the_future() {
let recipient_id = AccountId::new([42; 32]);
let genesis_timestamp = 500_u64;
let mut state =
V03State::new_with_genesis_accounts(&[(recipient_id, 0)], &[], genesis_timestamp)
.with_test_programs();
let key1 = PrivateKey::try_new([1; 32]).unwrap();
let sender_id = AccountId::from(&PublicKey::new_from_private_key(&key1));
state.force_insert_account(
sender_id,
Account {
program_owner: Program::time_locked_transfer().id(),
balance: 100,
..Account::default()
},
);
let amount = 100_u128;
// Far-future deadline: program should panic.
let deadline = u64::MAX;
let tx = time_locked_transfer_transaction(
sender_id,
&key1,
0,
recipient_id,
CLOCK_01_PROGRAM_ACCOUNT_ID,
amount,
deadline,
);
let block_id = 1;
let timestamp = genesis_timestamp + 100;
let result = state.transition_from_public_transaction(&tx, block_id, timestamp);
assert!(
result.is_err(),
"Transfer should fail when deadline is in the future"
);
// Balances unchanged.
assert_eq!(state.get_account_by_id(sender_id).balance, 100);
assert_eq!(state.get_account_by_id(recipient_id).balance, 0);
}
fn pinata_cooldown_data(prize: u128, cooldown_ms: u64, last_claim_timestamp: u64) -> Vec<u8> {
let mut buf = Vec::with_capacity(32);
buf.extend_from_slice(&prize.to_le_bytes());
buf.extend_from_slice(&cooldown_ms.to_le_bytes());
buf.extend_from_slice(&last_claim_timestamp.to_le_bytes());
buf
}
fn pinata_cooldown_transaction(
pinata_id: AccountId,
winner_id: AccountId,
clock_account_id: AccountId,
) -> PublicTransaction {
let program_id = Program::pinata_cooldown().id();
let message = public_transaction::Message::try_new(
program_id,
vec![pinata_id, winner_id, clock_account_id],
vec![],
(),
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
PublicTransaction::new(message, witness_set)
}
#[test]
fn pinata_cooldown_claim_succeeds_after_cooldown() {
let winner_id = AccountId::new([11; 32]);
let pinata_id = AccountId::new([99; 32]);
let genesis_timestamp = 1000_u64;
let mut state =
V03State::new_with_genesis_accounts(&[(winner_id, 0)], &[], genesis_timestamp)
.with_test_programs();
let prize = 50_u128;
let cooldown_ms = 500_u64;
// Last claim was at genesis, so any timestamp >= genesis + cooldown should work.
let last_claim_timestamp = genesis_timestamp;
state.force_insert_account(
pinata_id,
Account {
program_owner: Program::pinata_cooldown().id(),
balance: 1000,
data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp)
.try_into()
.unwrap(),
..Account::default()
},
);
let tx = pinata_cooldown_transaction(pinata_id, winner_id, CLOCK_01_PROGRAM_ACCOUNT_ID);
let block_id = 1;
let block_timestamp = genesis_timestamp + cooldown_ms;
// Advance clock so the cooldown check reads an updated timestamp.
let clock_tx = clock_transaction(block_timestamp);
state
.transition_from_public_transaction(&clock_tx, block_id, block_timestamp)
.unwrap();
state
.transition_from_public_transaction(&tx, block_id, block_timestamp)
.unwrap();
assert_eq!(state.get_account_by_id(pinata_id).balance, 1000 - prize);
assert_eq!(state.get_account_by_id(winner_id).balance, prize);
}
#[test]
fn pinata_cooldown_claim_fails_during_cooldown() {
let winner_id = AccountId::new([11; 32]);
let pinata_id = AccountId::new([99; 32]);
let genesis_timestamp = 1000_u64;
let mut state =
V03State::new_with_genesis_accounts(&[(winner_id, 0)], &[], genesis_timestamp)
.with_test_programs();
let prize = 50_u128;
let cooldown_ms = 500_u64;
let last_claim_timestamp = genesis_timestamp;
state.force_insert_account(
pinata_id,
Account {
balance: 1000,
data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp)
.try_into()
.unwrap(),
..Account::default()
},
);
let tx = pinata_cooldown_transaction(pinata_id, winner_id, CLOCK_01_PROGRAM_ACCOUNT_ID);
let block_id = 1;
// Timestamp is only 100ms after last claim, well within the 500ms cooldown.
let block_timestamp = genesis_timestamp + 100;
let clock_tx = clock_transaction(block_timestamp);
state
.transition_from_public_transaction(&clock_tx, block_id, block_timestamp)
.unwrap();
let result = state.transition_from_public_transaction(&tx, block_id, block_timestamp);
assert!(result.is_err(), "Claim should fail during cooldown period");
assert_eq!(state.get_account_by_id(pinata_id).balance, 1000);
assert_eq!(state.get_account_by_id(winner_id).balance, 0);
}
#[test]
fn state_serialization_roundtrip() {
let account_id_1 = AccountId::new([1; 32]);
let account_id_2 = AccountId::new([2; 32]);
let initial_data = [(account_id_1, 100_u128), (account_id_2, 151_u128)];
let state = V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs();
let state = V03State::new_with_genesis_accounts(&initial_data, &[], 0).with_test_programs();
let bytes = borsh::to_vec(&state).unwrap();
let state_from_bytes: V03State = borsh::from_slice(&bytes).unwrap();
assert_eq!(state, state_from_bytes);

View File

@ -0,0 +1,433 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
hash::Hash,
};
use log::debug;
use nssa_core::{
BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp,
account::{Account, AccountId, AccountWithMetadata},
program::{
ChainedCall, Claim, DEFAULT_PROGRAM_ID, compute_authorized_pdas, validate_execution,
},
};
use crate::{
V03State, ensure,
error::NssaError,
privacy_preserving_transaction::{
PrivacyPreservingTransaction, circuit::Proof, message::Message,
},
program::Program,
program_deployment_transaction::ProgramDeploymentTransaction,
public_transaction::PublicTransaction,
state::MAX_NUMBER_CHAINED_CALLS,
};
pub struct StateDiff {
pub signer_account_ids: Vec<AccountId>,
pub public_diff: HashMap<AccountId, Account>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<Nullifier>,
pub program: Option<Program>,
}
/// The validated output of executing or verifying a transaction, ready to be applied to the state.
///
/// Can only be constructed by the transaction validation functions inside this crate, ensuring the
/// diff has been checked before any state mutation occurs.
pub struct ValidatedStateDiff(StateDiff);
impl ValidatedStateDiff {
pub fn from_public_transaction(
tx: &PublicTransaction,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<Self, NssaError> {
let message = tx.message();
let witness_set = tx.witness_set();
// All account_ids must be different
ensure!(
message.account_ids.iter().collect::<HashSet<_>>().len() == message.account_ids.len(),
NssaError::InvalidInput("Duplicate account_ids found in message".into(),)
);
// Check exactly one nonce is provided for each signature
ensure!(
message.nonces.len() == witness_set.signatures_and_public_keys.len(),
NssaError::InvalidInput(
"Mismatch between number of nonces and signatures/public keys".into(),
)
);
// Check the signatures are valid
ensure!(
witness_set.is_valid_for(message),
NssaError::InvalidInput("Invalid signature for given message and public key".into())
);
let signer_account_ids = tx.signer_account_ids();
// Check nonces corresponds to the current nonces on the public state.
for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) {
let current_nonce = state.get_account_by_id(*account_id).nonce;
ensure!(
current_nonce == *nonce,
NssaError::InvalidInput("Nonce mismatch".into())
);
}
// Build pre_states for execution
let input_pre_states: Vec<_> = message
.account_ids
.iter()
.map(|account_id| {
AccountWithMetadata::new(
state.get_account_by_id(*account_id),
signer_account_ids.contains(account_id),
*account_id,
)
})
.collect();
let mut state_diff: HashMap<AccountId, Account> = HashMap::new();
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 chained_calls = VecDeque::from_iter([(initial_call, None)]);
let mut chain_calls_counter = 0;
while let Some((chained_call, caller_program_id)) = chained_calls.pop_front() {
ensure!(
chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS,
NssaError::MaxChainedCallsDepthExceeded
);
// Check that the `program_id` corresponds to a deployed program
let Some(program) = state.programs().get(&chained_call.program_id) else {
return Err(NssaError::InvalidInput("Unknown program".into()));
};
debug!(
"Program {:?} pre_states: {:?}, instruction_data: {:?}",
chained_call.program_id, chained_call.pre_states, chained_call.instruction_data
);
let mut program_output =
program.execute(&chained_call.pre_states, &chained_call.instruction_data)?;
debug!(
"Program {:?} output: {:?}",
chained_call.program_id, program_output
);
let authorized_pdas =
compute_authorized_pdas(caller_program_id, &chained_call.pda_seeds);
let is_authorized = |account_id: &AccountId| {
signer_account_ids.contains(account_id) || authorized_pdas.contains(account_id)
};
for pre in &program_output.pre_states {
let account_id = pre.account_id;
// Check that the program output pre_states coincide with the values in the public
// state or with any modifications to those values during the chain of calls.
let expected_pre = state_diff
.get(&account_id)
.cloned()
.unwrap_or_else(|| state.get_account_by_id(account_id));
ensure!(
pre.account == expected_pre,
NssaError::InvalidProgramBehavior
);
// Check that authorization flags are consistent with the provided ones or
// authorized by program through the PDA mechanism
ensure!(
pre.is_authorized == is_authorized(&account_id),
NssaError::InvalidProgramBehavior
);
}
// Verify that the program output's self_program_id matches the expected program ID.
ensure!(
program_output.self_program_id == chained_call.program_id,
NssaError::InvalidProgramBehavior
);
// Verify execution corresponds to a well-behaved program.
// See the # Programs section for the definition of the `validate_execution` method.
ensure!(
validate_execution(
&program_output.pre_states,
&program_output.post_states,
chained_call.program_id,
),
NssaError::InvalidProgramBehavior
);
// Verify validity window
ensure!(
program_output.block_validity_window.is_valid_for(block_id)
&& program_output
.timestamp_validity_window
.is_valid_for(timestamp),
NssaError::OutOfValidityWindow
);
for (i, post) in program_output.post_states.iter_mut().enumerate() {
let Some(claim) = post.required_claim() else {
continue;
};
// The invoked program can only claim accounts with default program id.
ensure!(
post.account().program_owner == DEFAULT_PROGRAM_ID,
NssaError::InvalidProgramBehavior
);
let account_id = program_output.pre_states[i].account_id;
match claim {
Claim::Authorized => {
// The program can only claim accounts that were authorized by the signer.
ensure!(
is_authorized(&account_id),
NssaError::InvalidProgramBehavior
);
}
Claim::Pda(seed) => {
// The program can only claim accounts that correspond to the PDAs it is
// authorized to claim.
let pda = AccountId::from((&chained_call.program_id, &seed));
ensure!(account_id == pda, NssaError::InvalidProgramBehavior);
}
}
post.account_mut().program_owner = chained_call.program_id;
}
// Update the state diff
for (pre, post) in program_output
.pre_states
.iter()
.zip(program_output.post_states.iter())
{
state_diff.insert(pre.account_id, post.account().clone());
}
for new_call in program_output.chained_calls.into_iter().rev() {
chained_calls.push_front((new_call, Some(chained_call.program_id)));
}
chain_calls_counter = chain_calls_counter
.checked_add(1)
.expect("we check the max depth at the beginning of the loop");
}
// Check that all modified uninitialized accounts where claimed
for post in state_diff.iter().filter_map(|(account_id, post)| {
let pre = state.get_account_by_id(*account_id);
if pre.program_owner != DEFAULT_PROGRAM_ID {
return None;
}
if pre == *post {
return None;
}
Some(post)
}) {
ensure!(
post.program_owner != DEFAULT_PROGRAM_ID,
NssaError::InvalidProgramBehavior
);
}
Ok(Self(StateDiff {
signer_account_ids,
public_diff: state_diff,
new_commitments: vec![],
new_nullifiers: vec![],
program: None,
}))
}
pub fn from_privacy_preserving_transaction(
tx: &PrivacyPreservingTransaction,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<Self, NssaError> {
let message = &tx.message;
let witness_set = &tx.witness_set;
// 1. Commitments or nullifiers are non empty
if message.new_commitments.is_empty() && message.new_nullifiers.is_empty() {
return Err(NssaError::InvalidInput(
"Empty commitments and empty nullifiers found in message".into(),
));
}
// 2. Check there are no duplicate account_ids in the public_account_ids list.
if n_unique(&message.public_account_ids) != message.public_account_ids.len() {
return Err(NssaError::InvalidInput(
"Duplicate account_ids found in message".into(),
));
}
// Check there are no duplicate nullifiers in the new_nullifiers list
if n_unique(&message.new_nullifiers) != message.new_nullifiers.len() {
return Err(NssaError::InvalidInput(
"Duplicate nullifiers found in message".into(),
));
}
// Check there are no duplicate commitments in the new_commitments list
if n_unique(&message.new_commitments) != message.new_commitments.len() {
return Err(NssaError::InvalidInput(
"Duplicate commitments found in message".into(),
));
}
// 3. Nonce checks and Valid signatures
// Check exactly one nonce is provided for each signature
if message.nonces.len() != witness_set.signatures_and_public_keys.len() {
return Err(NssaError::InvalidInput(
"Mismatch between number of nonces and signatures/public keys".into(),
));
}
// Check the signatures are valid
if !witness_set.signatures_are_valid_for(message) {
return Err(NssaError::InvalidInput(
"Invalid signature for given message and public key".into(),
));
}
let signer_account_ids = tx.signer_account_ids();
// Check nonces corresponds to the current nonces on the public state.
for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) {
let current_nonce = state.get_account_by_id(*account_id).nonce;
if current_nonce != *nonce {
return Err(NssaError::InvalidInput("Nonce mismatch".into()));
}
}
// Verify validity window
if !message.block_validity_window.is_valid_for(block_id)
|| !message.timestamp_validity_window.is_valid_for(timestamp)
{
return Err(NssaError::OutOfValidityWindow);
}
// Build pre_states for proof verification
let public_pre_states: Vec<_> = message
.public_account_ids
.iter()
.map(|account_id| {
AccountWithMetadata::new(
state.get_account_by_id(*account_id),
signer_account_ids.contains(account_id),
*account_id,
)
})
.collect();
// 4. Proof verification
check_privacy_preserving_circuit_proof_is_valid(
&witness_set.proof,
&public_pre_states,
message,
)?;
// 5. Commitment freshness
state.check_commitments_are_new(&message.new_commitments)?;
// 6. Nullifier uniqueness
state.check_nullifiers_are_valid(&message.new_nullifiers)?;
let public_diff = message
.public_account_ids
.iter()
.copied()
.zip(message.public_post_states.clone())
.collect();
let new_nullifiers = message
.new_nullifiers
.iter()
.copied()
.map(|(nullifier, _)| nullifier)
.collect();
Ok(Self(StateDiff {
signer_account_ids,
public_diff,
new_commitments: message.new_commitments.clone(),
new_nullifiers,
program: None,
}))
}
pub fn from_program_deployment_transaction(
tx: &ProgramDeploymentTransaction,
state: &V03State,
) -> Result<Self, NssaError> {
// TODO: remove clone
let program = Program::new(tx.message.bytecode.clone())?;
if state.programs().contains_key(&program.id()) {
return Err(NssaError::ProgramAlreadyExists);
}
Ok(Self(StateDiff {
signer_account_ids: vec![],
public_diff: HashMap::new(),
new_commitments: vec![],
new_nullifiers: vec![],
program: Some(program),
}))
}
/// Returns the public account changes produced by this transaction.
///
/// Used by callers (e.g. the sequencer) to inspect the diff before committing it, for example
/// to enforce that system accounts are not modified by user transactions.
#[must_use]
pub fn public_diff(&self) -> HashMap<AccountId, Account> {
self.0.public_diff.clone()
}
pub(crate) fn into_state_diff(self) -> StateDiff {
self.0
}
}
fn check_privacy_preserving_circuit_proof_is_valid(
proof: &Proof,
public_pre_states: &[AccountWithMetadata],
message: &Message,
) -> Result<(), NssaError> {
let output = PrivacyPreservingCircuitOutput {
public_pre_states: public_pre_states.to_vec(),
public_post_states: message.public_post_states.clone(),
ciphertexts: message
.encrypted_private_post_states
.iter()
.cloned()
.map(|value| value.ciphertext)
.collect(),
new_commitments: message.new_commitments.clone(),
new_nullifiers: message.new_nullifiers.clone(),
block_validity_window: message.block_validity_window,
timestamp_validity_window: message.timestamp_validity_window,
};
proof
.is_valid_for(&output)
.then_some(())
.ok_or(NssaError::InvalidPrivacyPreservingProof)
}
fn n_unique<T: Eq + Hash>(data: &[T]) -> usize {
let set: HashSet<&T> = data.iter().collect();
set.len()
}

View File

@ -9,6 +9,7 @@ workspace = true
[dependencies]
nssa_core.workspace = true
clock_core.workspace = true
token_core.workspace = true
token_program.workspace = true
amm_core.workspace = true

View File

@ -0,0 +1,92 @@
//! Clock Program.
//!
//! A system program that records the current block ID and timestamp into dedicated clock accounts.
//! Three accounts are maintained, updated at different block intervals (every 1, 10, and 50
//! blocks), allowing programs to read recent timestamps at various granularities.
//!
//! This program can only be invoked exclusively by the sequencer as the last transaction in every
//! block. Clock accounts are assigned to the clock program at genesis, so no claiming is required
//! here.
use clock_core::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
ClockAccountData, Instruction,
};
use nssa_core::{
account::AccountWithMetadata,
program::{AccountPostState, ProgramInput, ProgramOutput, read_nssa_inputs},
};
fn update_if_multiple(
pre: AccountWithMetadata,
divisor: u64,
current_block_id: u64,
updated_data: &[u8],
) -> (AccountWithMetadata, AccountPostState) {
if current_block_id.is_multiple_of(divisor) {
let mut post_account = pre.account.clone();
post_account.data = updated_data
.to_vec()
.try_into()
.expect("Clock account data should fit in account data");
(pre, AccountPostState::new(post_account))
} else {
let post = AccountPostState::new(pre.account.clone());
(pre, post)
}
}
fn main() {
let (
ProgramInput {
self_program_id,
pre_states,
instruction: timestamp,
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let Ok([pre_01, pre_10, pre_50]) = <[_; 3]>::try_from(pre_states) else {
panic!("Invalid number of input accounts");
};
// Verify pre-states correspond to the expected clock account IDs.
if pre_01.account_id != CLOCK_01_PROGRAM_ACCOUNT_ID
|| pre_10.account_id != CLOCK_10_PROGRAM_ACCOUNT_ID
|| pre_50.account_id != CLOCK_50_PROGRAM_ACCOUNT_ID
{
panic!("Invalid input accounts");
}
// Verify all clock accounts are owned by this program (assigned at genesis).
if pre_01.account.program_owner != self_program_id
|| pre_10.account.program_owner != self_program_id
|| pre_50.account.program_owner != self_program_id
{
panic!("Clock accounts must be owned by the clock program");
}
let prev_data = ClockAccountData::from_bytes(&pre_01.account.data.clone().into_inner());
let current_block_id = prev_data
.block_id
.checked_add(1)
.expect("Next block id should be within u64 boundaries");
let updated_data = ClockAccountData {
block_id: current_block_id,
timestamp,
}
.to_bytes();
let (pre_01, post_01) = update_if_multiple(pre_01, 1, current_block_id, &updated_data);
let (pre_10, post_10) = update_if_multiple(pre_10, 10, current_block_id, &updated_data);
let (pre_50, post_50) = update_if_multiple(pre_50, 50, current_block_id, &updated_data);
ProgramOutput::new(
self_program_id,
instruction_words,
vec![pre_01, pre_10, pre_50],
vec![post_01, post_10, post_50],
)
.write();
}

View File

@ -3036,7 +3036,7 @@ fn new_definition_lp_symmetric_amounts() {
fn state_for_amm_tests() -> V03State {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]);
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0);
state.force_insert_account(
IdForExeTests::pool_definition_id(),
AccountsForExeTests::pool_definition_init(),
@ -3079,7 +3079,7 @@ fn state_for_amm_tests() -> V03State {
fn state_for_amm_tests_with_new_def() -> V03State {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]);
let mut state = V03State::new_with_genesis_accounts(&initial_data, &[], 0);
state.force_insert_account(
IdForExeTests::token_a_definition_id(),
AccountsForExeTests::token_a_definition_account(),

View File

@ -0,0 +1,12 @@
[package]
name = "clock_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
nssa_core.workspace = true
borsh.workspace = true

View File

@ -0,0 +1,42 @@
//! Core data structures and constants for the Clock Program.
use borsh::{BorshDeserialize, BorshSerialize};
use nssa_core::{Timestamp, account::AccountId};
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");
/// All clock program account ID in the order expected by the clock program.
pub const CLOCK_PROGRAM_ACCOUNT_IDS: [AccountId; 3] = [
CLOCK_01_PROGRAM_ACCOUNT_ID,
CLOCK_10_PROGRAM_ACCOUNT_ID,
CLOCK_50_PROGRAM_ACCOUNT_ID,
];
/// The instruction type for the Clock Program. The sequencer passes the current block timestamp.
pub type Instruction = Timestamp;
/// The data stored in a clock account.
#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct ClockAccountData {
pub block_id: u64,
pub timestamp: Timestamp,
}
impl ClockAccountData {
#[must_use]
pub fn to_bytes(self) -> Vec<u8> {
borsh::to_vec(&self).expect("ClockAccountData serialization should not fail")
}
#[must_use]
pub fn from_bytes(bytes: &[u8]) -> Self {
borsh::from_slice(bytes).expect("ClockAccountData deserialization should not fail")
}
}

View File

@ -40,3 +40,5 @@ mock = []
[dev-dependencies]
futures.workspace = true
test_program_methods.workspace = true
nssa = { workspace = true, features = ["test-utils"] }

View File

@ -150,7 +150,7 @@ mod tests {
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(None, retrieved_tx);
// Add the block with the transaction
let dummy_state = V03State::new_with_genesis_accounts(&[], &[]);
let dummy_state = V03State::new_with_genesis_accounts(&[], &[], 0);
node_store.update(&block, [1; 32], &dummy_state).unwrap();
// Try again
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
@ -209,7 +209,7 @@ mod tests {
let block_hash = block.header.hash;
let block_msg_id = [1; 32];
let dummy_state = V03State::new_with_genesis_accounts(&[], &[]);
let dummy_state = V03State::new_with_genesis_accounts(&[], &[], 0);
node_store
.update(&block, block_msg_id, &dummy_state)
.unwrap();
@ -244,7 +244,7 @@ mod tests {
let block = common::test_utils::produce_dummy_block(1, None, vec![tx]);
let block_id = block.header.block_id;
let dummy_state = V03State::new_with_genesis_accounts(&[], &[]);
let dummy_state = V03State::new_with_genesis_accounts(&[], &[], 0);
node_store.update(&block, [1; 32], &dummy_state).unwrap();
// Verify initial status is Pending

View File

@ -24,9 +24,10 @@ pub struct SequencerConfig {
pub genesis_id: u64,
/// If `True`, then adds random sequence of bytes to genesis block.
pub is_genesis_random: bool,
/// Maximum number of transactions in block.
/// Maximum number of user transactions in a block (excludes the mandatory clock transaction).
pub max_num_tx_in_block: usize,
/// Maximum block size (includes header and transactions).
/// Maximum block size (includes header, user transactions, and the mandatory clock
/// transaction).
#[serde(default = "default_max_block_size")]
pub max_block_size: ByteSize,
/// Mempool maximum size.

View File

@ -7,7 +7,7 @@ use common::PINATA_BASE58;
use common::{
HashType,
block::{BedrockStatus, Block, HashableBlockData},
transaction::NSSATransaction,
transaction::{NSSATransaction, clock_invocation},
};
use config::SequencerConfig;
use log::{error, info, warn};
@ -16,7 +16,6 @@ use mempool::{MemPool, MemPoolHandle};
#[cfg(feature = "mock")]
pub use mock::SequencerCoreWithMockClients;
use nssa::V03State;
use nssa_core::{BlockId, Timestamp};
pub use storage::error::DbError;
use testnet_initial_state::initial_state;
@ -139,6 +138,7 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
V03State::new_with_genesis_accounts(
&init_accs.unwrap_or_default(),
&initial_commitments.unwrap_or_default(),
genesis_block.header.timestamp,
)
} else {
initial_state()
@ -163,28 +163,6 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
(sequencer_core, mempool_handle)
}
fn execute_check_transaction_on_state(
&mut self,
tx: NSSATransaction,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<NSSATransaction, nssa::error::NssaError> {
match &tx {
NSSATransaction::Public(tx) => self
.state
.transition_from_public_transaction(tx, block_id, timestamp),
NSSATransaction::PrivacyPreserving(tx) => self
.state
.transition_from_privacy_preserving_transaction(tx, block_id, timestamp),
NSSATransaction::ProgramDeployment(tx) => self
.state
.transition_from_program_deployment_transaction(tx),
}
.inspect_err(|err| warn!("Error at transition {err:#?}"))?;
Ok(tx)
}
pub async fn produce_new_block(&mut self) -> Result<u64> {
let (tx, _msg_id) = self
.produce_new_block_with_mempool_transactions()
@ -224,12 +202,20 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis())
.expect("Timestamp must be positive");
// Pre-create the mandatory clock tx so its size is included in the block size check.
let clock_tx = clock_invocation(new_block_timestamp);
let clock_nssa_tx = NSSATransaction::Public(clock_tx.clone());
while let Some(tx) = self.mempool.pop() {
let tx_hash = tx.hash();
// Check if block size exceeds limit
let temp_valid_transactions =
[valid_transactions.as_slice(), std::slice::from_ref(&tx)].concat();
// Check if block size exceeds limit (including the mandatory clock tx).
let temp_valid_transactions = [
valid_transactions.as_slice(),
std::slice::from_ref(&tx),
std::slice::from_ref(&clock_nssa_tx),
]
.concat();
let temp_hashable_data = HashableBlockData {
block_id: new_block_height,
transactions: temp_valid_transactions,
@ -252,26 +238,35 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
break;
}
match self.execute_check_transaction_on_state(tx, new_block_height, new_block_timestamp)
{
Ok(valid_tx) => {
valid_transactions.push(valid_tx);
info!("Validated transaction with hash {tx_hash}, including it in block");
if valid_transactions.len() >= self.sequencer_config.max_num_tx_in_block {
break;
}
}
let validated_diff = match tx.validate_on_state(
&self.state,
new_block_height,
new_block_timestamp,
) {
Ok(diff) => diff,
Err(err) => {
error!(
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
);
// TODO: Probably need to handle unsuccessful transaction execution?
continue;
}
};
self.state.apply_state_diff(validated_diff);
valid_transactions.push(tx);
info!("Validated transaction with hash {tx_hash}, including it in block");
if valid_transactions.len() >= self.sequencer_config.max_num_tx_in_block {
break;
}
}
// Append the Clock Program invocation as the mandatory last transaction.
self.state
.transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp)
.context("Clock transaction failed. Aborting block production.")?;
valid_transactions.push(clock_nssa_tx);
let hashable_data = HashableBlockData {
block_id: new_block_height,
transactions: valid_transactions,
@ -395,7 +390,10 @@ mod tests {
use std::{pin::pin, time::Duration};
use bedrock_client::BackoffConfig;
use common::{test_utils::sequencer_sign_key_for_testing, transaction::NSSATransaction};
use common::{
test_utils::sequencer_sign_key_for_testing,
transaction::{NSSATransaction, clock_invocation},
};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use mempool::MemPoolHandle;
use testnet_initial_state::{initial_accounts, initial_pub_accounts_private_keys};
@ -524,7 +522,7 @@ mod tests {
let tx = tx.transaction_stateless_check().unwrap();
// Signature is not from sender. Execution fails
let result = sequencer.execute_check_transaction_on_state(tx, 0, 0);
let result = tx.execute_check_on_state(&mut sequencer.state, 0, 0);
assert!(matches!(
result,
@ -550,7 +548,9 @@ mod tests {
// Passed pre-check
assert!(result.is_ok());
let result = sequencer.execute_check_transaction_on_state(result.unwrap(), 0, 0);
let result = result
.unwrap()
.execute_check_on_state(&mut sequencer.state, 0, 0);
let is_failed_at_balance_mismatch = matches!(
result.err().unwrap(),
nssa::error::NssaError::ProgramExecutionFailed(_)
@ -572,8 +572,7 @@ mod tests {
acc1, 0, acc2, 100, &sign_key1,
);
sequencer
.execute_check_transaction_on_state(tx, 0, 0)
tx.execute_check_on_state(&mut sequencer.state, 0, 0)
.unwrap();
let bal_from = sequencer.state.get_account_by_id(acc1).balance;
@ -652,8 +651,14 @@ mod tests {
.unwrap()
.unwrap();
// Only one should be included in the block
assert_eq!(block.body.transactions, vec![tx.clone()]);
// Only one user tx should be included; the clock tx is always appended last.
assert_eq!(
block.body.transactions,
vec![
tx.clone(),
NSSATransaction::Public(clock_invocation(block.header.timestamp))
]
);
}
#[tokio::test]
@ -679,7 +684,13 @@ mod tests {
.get_block_at_id(sequencer.chain_height)
.unwrap()
.unwrap();
assert_eq!(block.body.transactions, vec![tx.clone()]);
assert_eq!(
block.body.transactions,
vec![
tx.clone(),
NSSATransaction::Public(clock_invocation(block.header.timestamp))
]
);
// Add same transaction should fail
mempool_handle.push(tx.clone()).await.unwrap();
@ -691,7 +702,13 @@ mod tests {
.get_block_at_id(sequencer.chain_height)
.unwrap()
.unwrap();
assert!(block.body.transactions.is_empty());
// The replay is rejected, so only the clock tx is in the block.
assert_eq!(
block.body.transactions,
vec![NSSATransaction::Public(clock_invocation(
block.header.timestamp
))]
);
}
#[tokio::test]
@ -726,7 +743,13 @@ mod tests {
.get_block_at_id(sequencer.chain_height)
.unwrap()
.unwrap();
assert_eq!(block.body.transactions, vec![tx.clone()]);
assert_eq!(
block.body.transactions,
vec![
tx.clone(),
NSSATransaction::Public(clock_invocation(block.header.timestamp))
]
);
}
// Instantiating a new sequencer from the same config. This should load the existing block
@ -856,8 +879,54 @@ mod tests {
);
assert_eq!(
new_block.body.transactions,
vec![tx],
"New block should contain the submitted transaction"
vec![
tx,
NSSATransaction::Public(clock_invocation(new_block.header.timestamp))
],
"New block should contain the submitted transaction and the clock invocation"
);
}
#[tokio::test]
async fn transactions_touching_clock_account_are_dropped_from_block() {
let (mut sequencer, mempool_handle) = common_setup().await;
// Canonical clock invocation and a crafted variant with a different timestamp — both must
// be dropped because their diffs touch the clock accounts.
let crafted_clock_tx = {
let message = nssa::public_transaction::Message::try_new(
nssa::program::Program::clock().id(),
nssa::CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(),
vec![],
42_u64,
)
.unwrap();
NSSATransaction::Public(nssa::PublicTransaction::new(
message,
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
))
};
mempool_handle
.push(NSSATransaction::Public(clock_invocation(0)))
.await
.unwrap();
mempool_handle.push(crafted_clock_tx).await.unwrap();
sequencer
.produce_new_block_with_mempool_transactions()
.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.unwrap()
.unwrap();
// Both transactions were dropped. Only the system-appended clock tx remains.
assert_eq!(
block.body.transactions,
vec![NSSATransaction::Public(clock_invocation(
block.header.timestamp
))]
);
}
@ -909,4 +978,86 @@ mod tests {
"Chain height should NOT match the modified config.genesis_id"
);
}
#[tokio::test]
async fn user_tx_that_chain_calls_clock_is_dropped() {
let (mut sequencer, mempool_handle) = common_setup().await;
// Deploy the clock_chain_caller test program.
let deploy_tx =
NSSATransaction::ProgramDeployment(nssa::ProgramDeploymentTransaction::new(
nssa::program_deployment_transaction::Message::new(
test_program_methods::CLOCK_CHAIN_CALLER_ELF.to_vec(),
),
));
mempool_handle.push(deploy_tx).await.unwrap();
sequencer
.produce_new_block_with_mempool_transactions()
.unwrap();
// Build a user transaction that invokes clock_chain_caller, which in turn chain-calls the
// clock program with the clock accounts. The sequencer should detect that the resulting
// state diff modifies clock accounts and drop the transaction.
let clock_chain_caller_id =
nssa::program::Program::new(test_program_methods::CLOCK_CHAIN_CALLER_ELF.to_vec())
.unwrap()
.id();
let clock_program_id = nssa::program::Program::clock().id();
let timestamp: u64 = 0;
let message = nssa::public_transaction::Message::try_new(
clock_chain_caller_id,
nssa::CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(),
vec![], // no signers
(clock_program_id, timestamp),
)
.unwrap();
let user_tx = NSSATransaction::Public(nssa::PublicTransaction::new(
message,
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
));
mempool_handle.push(user_tx).await.unwrap();
sequencer
.produce_new_block_with_mempool_transactions()
.unwrap();
let block = sequencer
.store
.get_block_at_id(sequencer.chain_height)
.unwrap()
.unwrap();
// The user tx must have been dropped; only the mandatory clock invocation remains.
assert_eq!(
block.body.transactions,
vec![NSSATransaction::Public(clock_invocation(
block.header.timestamp
))]
);
}
#[tokio::test]
async fn block_production_aborts_when_clock_account_data_is_corrupted() {
let (mut sequencer, mempool_handle) = common_setup().await;
// Corrupt the clock 01 account data so the clock program panics on deserialization.
let clock_account_id = nssa::CLOCK_01_PROGRAM_ACCOUNT_ID;
let mut corrupted = sequencer.state.get_account_by_id(clock_account_id);
corrupted.data = vec![0xff; 3].try_into().unwrap();
sequencer
.state
.force_insert_account(clock_account_id, corrupted);
// Push a dummy transaction so the mempool is non-empty.
let tx = common::test_utils::produce_dummy_empty_transaction();
mempool_handle.push(tx).await.unwrap();
// Block production must fail because the appended clock tx cannot execute.
let result = sequencer.produce_new_block_with_mempool_transactions();
assert!(
result.is_err(),
"Block production should abort when clock account data is corrupted"
);
}
}

View File

@ -257,7 +257,7 @@ mod tests {
let dbio = RocksDBIO::open_or_create(
temdir_path,
&genesis_block(),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[], 0),
)
.unwrap();
@ -294,7 +294,7 @@ mod tests {
let dbio = RocksDBIO::open_or_create(
temdir_path,
&genesis_block(),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[], 0),
)
.unwrap();
@ -347,7 +347,7 @@ mod tests {
let dbio = RocksDBIO::open_or_create(
temdir_path,
&genesis_block(),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[], 0),
)
.unwrap();
@ -420,7 +420,7 @@ mod tests {
let dbio = RocksDBIO::open_or_create(
temdir_path,
&genesis_block(),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[], 0),
)
.unwrap();
@ -503,7 +503,7 @@ mod tests {
let dbio = RocksDBIO::open_or_create(
temdir_path,
&genesis_block(),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[], 0),
)
.unwrap();
@ -599,7 +599,7 @@ mod tests {
let dbio = RocksDBIO::open_or_create(
temdir_path,
&genesis_block(),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]),
&nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[], 0),
)
.unwrap();

View File

@ -9,5 +9,7 @@ workspace = true
[dependencies]
nssa_core.workspace = true
clock_core.workspace = true
risc0-zkvm.workspace = true
serde = { workspace = true, default-features = false }

View File

@ -0,0 +1,39 @@
use nssa_core::{
Timestamp,
program::{
AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs,
},
};
use risc0_zkvm::serde::to_vec;
type Instruction = (ProgramId, Timestamp); // (clock_program_id, timestamp)
/// A program that chain-calls the clock program with the clock accounts it received as pre-states.
/// Used in tests to verify that user transactions cannot modify clock accounts, even indirectly
/// via chain calls.
fn main() {
let (
ProgramInput {
self_program_id,
pre_states,
instruction: (clock_program_id, timestamp),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let post_states: Vec<_> = pre_states
.iter()
.map(|pre| AccountPostState::new(pre.account.clone()))
.collect();
let chained_call = ChainedCall {
program_id: clock_program_id,
instruction_data: to_vec(&timestamp).unwrap(),
pre_states: pre_states.clone(),
pda_seeds: vec![],
};
ProgramOutput::new(self_program_id, instruction_words, pre_states, post_states)
.with_chained_calls(vec![chained_call])
.write();
}

View File

@ -0,0 +1,114 @@
//! Cooldown-based pinata program.
//!
//! A Piñata program that uses the on-chain clock to prevent abuse.
//! After each prize claim the program records the current timestamp; the next claim is only
//! allowed once a configurable cooldown period has elapsed.
//!
//! Expected pre-states (in order):
//! 0 - pinata account (authorized, owned by this program)
//! 1 - winner account
//! 2 - clock account `CLOCK_01`.
//!
//! Pinata account data layout (24 bytes):
//! [prize: u64 LE | `cooldown_ms`: u64 LE | `last_claim_timestamp`: u64 LE].
use clock_core::{CLOCK_01_PROGRAM_ACCOUNT_ID, ClockAccountData};
use nssa_core::program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_nssa_inputs};
type Instruction = ();
struct PinataState {
prize: u128,
cooldown_ms: u64,
last_claim_timestamp: u64,
}
impl PinataState {
fn from_bytes(bytes: &[u8]) -> Self {
assert!(bytes.len() >= 32, "Pinata account data too short");
let prize = u128::from_le_bytes(bytes[..16].try_into().unwrap());
let cooldown_ms = u64::from_le_bytes(bytes[16..24].try_into().unwrap());
let last_claim_timestamp = u64::from_le_bytes(bytes[24..32].try_into().unwrap());
Self {
prize,
cooldown_ms,
last_claim_timestamp,
}
}
fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(32);
buf.extend_from_slice(&self.prize.to_le_bytes());
buf.extend_from_slice(&self.cooldown_ms.to_le_bytes());
buf.extend_from_slice(&self.last_claim_timestamp.to_le_bytes());
buf
}
}
fn main() {
let (
ProgramInput {
self_program_id,
pre_states,
instruction: (),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let Ok([pinata, winner, clock_pre]) = <[_; 3]>::try_from(pre_states) else {
panic!("Expected exactly 3 input accounts: pinata, winner, clock");
};
// Check the clock account is the system clock account
assert_eq!(clock_pre.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID);
let clock_data = ClockAccountData::from_bytes(&clock_pre.account.data.clone().into_inner());
let current_timestamp = clock_data.timestamp;
let pinata_state = PinataState::from_bytes(&pinata.account.data.clone().into_inner());
// Enforce cooldown: the elapsed time since the last claim must exceed the cooldown period.
let elapsed = current_timestamp.saturating_sub(pinata_state.last_claim_timestamp);
assert!(
elapsed >= pinata_state.cooldown_ms,
"Cooldown not elapsed: {elapsed}ms since last claim, need {}ms",
pinata_state.cooldown_ms,
);
let mut pinata_post = pinata.account.clone();
let mut winner_post = winner.account.clone();
pinata_post.balance = pinata_post
.balance
.checked_sub(pinata_state.prize)
.expect("Not enough balance in the pinata");
winner_post.balance = winner_post
.balance
.checked_add(pinata_state.prize)
.expect("Overflow when adding prize to winner");
// Update the last claim timestamp.
let updated_state = PinataState {
last_claim_timestamp: current_timestamp,
..pinata_state
};
pinata_post.data = updated_state
.to_bytes()
.try_into()
.expect("Pinata state should fit in account data");
// Clock account is read-only.
let clock_post = clock_pre.account.clone();
ProgramOutput::new(
self_program_id,
instruction_words,
vec![pinata, winner, clock_pre],
vec![
AccountPostState::new_claimed_if_default(pinata_post, Claim::Authorized),
AccountPostState::new(winner_post),
AccountPostState::new(clock_post),
],
)
.write();
}

View File

@ -0,0 +1,70 @@
//! Time-locked transfer program.
//!
//! Demonstrates how a program can include a clock account among its inputs and use the on-chain
//! timestamp in its logic. The transfer only executes when the clock timestamp is at or past a
//! caller-supplied deadline; otherwise the program panics.
//!
//! Expected pre-states (in order):
//! 0 - sender account (authorized)
//! 1 - receiver account
//! 2 - clock account (read-only, e.g. `CLOCK_01`).
use clock_core::{CLOCK_01_PROGRAM_ACCOUNT_ID, ClockAccountData};
use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nssa_inputs};
/// (`amount`, `deadline_timestamp`).
type Instruction = (u128, u64);
fn main() {
let (
ProgramInput {
self_program_id,
pre_states,
instruction: (amount, deadline),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let Ok([sender_pre, receiver_pre, clock_pre]) = <[_; 3]>::try_from(pre_states) else {
panic!("Expected exactly 3 input accounts: sender, receiver, clock");
};
// Check the clock account is the system clock account
assert_eq!(clock_pre.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID);
// Read the current timestamp from the clock account.
let clock_data = ClockAccountData::from_bytes(&clock_pre.account.data.clone().into_inner());
assert!(
clock_data.timestamp >= deadline,
"Transfer is time-locked until timestamp {deadline}, current is {}",
clock_data.timestamp,
);
let mut sender_post = sender_pre.account.clone();
let mut receiver_post = receiver_pre.account.clone();
sender_post.balance = sender_post
.balance
.checked_sub(amount)
.expect("Insufficient balance");
receiver_post.balance = receiver_post
.balance
.checked_add(amount)
.expect("Balance overflow");
// Clock account is read-only: post state equals pre state.
let clock_post = clock_pre.account.clone();
ProgramOutput::new(
self_program_id,
instruction_words,
vec![sender_pre, receiver_pre, clock_pre],
vec![
AccountPostState::new(sender_post),
AccountPostState::new(receiver_post),
AccountPostState::new(clock_post),
],
)
.write();
}

View File

@ -214,7 +214,7 @@ pub fn initial_state() -> V03State {
.map(|acc_data| (acc_data.account_id, acc_data.balance))
.collect();
nssa::V03State::new_with_genesis_accounts(&init_accs, &initial_commitments)
nssa::V03State::new_with_genesis_accounts(&init_accs, &initial_commitments, 0)
}
#[must_use]