enforce valid window construction

This commit is contained in:
Sergio Chouhy 2026-03-20 13:16:52 -03:00
parent 607a34058d
commit 3257440448
9 changed files with 119 additions and 63 deletions

View File

@ -302,13 +302,13 @@ impl From<nssa::privacy_preserving_transaction::message::Message> for PrivacyPre
.into_iter()
.map(|(n, d)| (n.into(), d.into()))
.collect(),
validity_window: ValidityWindow(validity_window),
validity_window: ValidityWindow((validity_window.from(), validity_window.to())),
}
}
}
impl TryFrom<PrivacyPreservingMessage> for nssa::privacy_preserving_transaction::message::Message {
type Error = nssa_core::account::data::DataTooBigError;
type Error = nssa::error::NssaError;
fn try_from(value: PrivacyPreservingMessage) -> Result<Self, Self::Error> {
let PrivacyPreservingMessage {
@ -329,7 +329,8 @@ impl TryFrom<PrivacyPreservingMessage> for nssa::privacy_preserving_transaction:
public_post_states: public_post_states
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()?,
.collect::<Result<Vec<_>, _>>()
.map_err(|e| nssa::error::NssaError::InvalidInput(format!("{e}")))?,
encrypted_private_post_states: encrypted_private_post_states
.into_iter()
.map(Into::into)
@ -339,7 +340,10 @@ impl TryFrom<PrivacyPreservingMessage> for nssa::privacy_preserving_transaction:
.into_iter()
.map(|(n, d)| (n.into(), d.into()))
.collect(),
validity_window: validity_window.0,
validity_window: validity_window
.0
.try_into()
.map_err(|e| nssa::error::NssaError::InvalidInput(format!("{e}")))?,
})
}
}
@ -483,14 +487,7 @@ impl TryFrom<PrivacyPreservingTransaction> for nssa::PrivacyPreservingTransactio
witness_set,
} = value;
Ok(Self::new(
message
.try_into()
.map_err(|err: nssa_core::account::data::DataTooBigError| {
nssa::error::NssaError::InvalidInput(err.to_string())
})?,
witness_set.try_into()?,
))
Ok(Self::new(message.try_into()?, witness_set.try_into()?))
}
}

View File

@ -102,7 +102,7 @@ mod tests {
),
[0xab; 32],
)],
validity_window: (Some(1), None),
validity_window: (Some(1), None).try_into().unwrap(),
};
let bytes = output.to_bytes();
let output_from_slice: PrivacyPreservingCircuitOutput = from_slice(&bytes).unwrap();

View File

@ -1,5 +1,7 @@
use std::collections::HashSet;
#[cfg(feature = "host")]
use borsh::{BorshDeserialize, BorshSerialize};
use risc0_zkvm::{DeserializeOwned, guest::env, serde::Deserializer};
use serde::{Deserialize, Serialize};
@ -152,7 +154,68 @@ impl AccountPostState {
}
pub type BlockId = u64;
pub type ValidityWindow = (Option<BlockId>, Option<BlockId>);
#[derive(Serialize, Deserialize, Clone, Copy)]
#[cfg_attr(
any(feature = "host", test),
derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)
)]
pub struct ValidityWindow {
from: Option<BlockId>,
to: Option<BlockId>,
}
impl ValidityWindow {
#[must_use]
pub const fn new_unbounded() -> Self {
Self {
from: None,
to: None,
}
}
/// Valid for block IDs in the range [from, to), where `from` is included and `to` is excluded.
#[must_use]
pub fn is_valid_for_block_id(&self, id: BlockId) -> bool {
self.from.is_none_or(|start| id >= start) && self.to.is_none_or(|end| id < end)
}
const fn check_window(&self) -> Result<(), InvalidWindow> {
if let (Some(from_id), Some(until_id)) = (self.from, self.to)
&& from_id >= until_id
{
Err(InvalidWindow)
} else {
Ok(())
}
}
#[must_use]
pub const fn from(&self) -> Option<BlockId> {
self.from
}
#[must_use]
pub const fn to(&self) -> Option<BlockId> {
self.to
}
}
impl TryFrom<(Option<BlockId>, Option<BlockId>)> for ValidityWindow {
type Error = InvalidWindow;
fn try_from(value: (Option<BlockId>, Option<BlockId>)) -> Result<Self, Self::Error> {
let this = Self {
from: value.0,
to: value.1,
};
this.check_window()?;
Ok(this)
}
}
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
#[error("Invalid window")]
pub struct InvalidWindow;
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))]
@ -166,6 +229,8 @@ pub struct ProgramOutput {
/// The list of chained calls to other programs.
pub chained_calls: Vec<ChainedCall>,
/// The window where the program output is valid.
/// Valid for block IDs in the range [from, to), where `from` is included and `to` is excluded.
/// `None` means unbounded on that side.
pub validity_window: ValidityWindow,
}
@ -181,7 +246,7 @@ impl ProgramOutput {
pre_states,
post_states,
chained_calls: Vec::new(),
validity_window: (None, None),
validity_window: ValidityWindow::new_unbounded(),
}
}
@ -195,16 +260,16 @@ impl ProgramOutput {
self
}
#[must_use]
pub const fn valid_from_id(mut self, id: BlockId) -> Self {
self.validity_window.0 = Some(id);
self
pub fn valid_from_id(mut self, id: Option<BlockId>) -> Result<Self, InvalidWindow> {
self.validity_window.from = id;
self.validity_window.check_window()?;
Ok(self)
}
#[must_use]
pub const fn valid_until_id(mut self, id: BlockId) -> Self {
self.validity_window.1 = Some(id);
self
pub fn valid_until_id(mut self, id: Option<BlockId>) -> Result<Self, InvalidWindow> {
self.validity_window.to = id;
self.validity_window.check_window()?;
Ok(self)
}
}

View File

@ -165,7 +165,7 @@ pub mod tests {
encrypted_private_post_states,
new_commitments,
new_nullifiers,
validity_window: (None, None),
validity_window: (None, None).try_into().unwrap(),
}
}

View File

@ -93,6 +93,11 @@ impl PrivacyPreservingTransaction {
}
}
// Verify validity window
if !message.validity_window.is_valid_for_block_id(block_id) {
return Err(NssaError::OutOfValidityWindow);
}
// Build pre_states for proof verification
let public_pre_states: Vec<_> = message
.public_account_ids
@ -123,18 +128,6 @@ impl PrivacyPreservingTransaction {
// 6. Nullifier uniqueness
state.check_nullifiers_are_valid(&message.new_nullifiers)?;
// 7. Verify validity window
if let Some(from_id) = message.validity_window.0
&& block_id < from_id
{
return Err(NssaError::OutOfValidityWindow);
}
if let Some(until_id) = message.validity_window.1
&& until_id < block_id
{
return Err(NssaError::OutOfValidityWindow);
}
Ok(message
.public_account_ids
.iter()

View File

@ -192,12 +192,12 @@ impl PublicTransaction {
);
// Verify validity window
if let Some(from_id) = program_output.validity_window.0 {
ensure!(from_id <= block_id, NssaError::OutOfValidityWindow);
}
if let Some(until_id) = program_output.validity_window.1 {
ensure!(until_id >= block_id, NssaError::OutOfValidityWindow);
}
ensure!(
program_output
.validity_window
.is_valid_for_block_id(block_id),
NssaError::OutOfValidityWindow
);
for post in program_output
.post_states

View File

@ -340,7 +340,7 @@ pub mod tests {
Commitment, Nullifier, NullifierPublicKey, NullifierSecretKey, SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey},
program::{BlockId, PdaSeed, ProgramId, ValidityWindow},
program::{BlockId, PdaSeed, ProgramId},
};
use crate::{
@ -3013,7 +3013,7 @@ pub mod tests {
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn validity_window_works_in_public_transactions(
validity_window: ValidityWindow,
validity_window: (Option<BlockId>, Option<BlockId>),
block_id: BlockId,
) {
let validity_window_program = Program::validity_window();
@ -3035,10 +3035,10 @@ pub mod tests {
PublicTransaction::new(message, witness_set)
};
let result = state.transition_from_public_transaction(&tx, block_id);
let is_inside_validity_window = match (validity_window.0, validity_window.1) {
(Some(s), Some(e)) => s <= block_id && block_id <= e,
let is_inside_validity_window = match validity_window {
(Some(s), Some(e)) => s <= block_id && block_id < e,
(Some(s), None) => s <= block_id,
(None, Some(e)) => block_id <= e,
(None, Some(e)) => block_id < e,
(None, None) => true,
};
if is_inside_validity_window {
@ -3062,7 +3062,7 @@ pub mod tests {
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn validity_window_works_in_privacy_preserving_transactions(
validity_window: ValidityWindow,
validity_window: (Option<BlockId>, Option<BlockId>),
block_id: BlockId,
) {
let validity_window_program = Program::validity_window();
@ -3097,10 +3097,10 @@ pub mod tests {
PrivacyPreservingTransaction::new(message, witness_set)
};
let result = state.transition_from_privacy_preserving_transaction(&tx, block_id);
let is_inside_validity_window = match (validity_window.0, validity_window.1) {
(Some(s), Some(e)) => s <= block_id && block_id <= e,
let is_inside_validity_window = match validity_window {
(Some(s), Some(e)) => s <= block_id && block_id < e,
(Some(s), None) => s <= block_id,
(None, Some(e)) => block_id <= e,
(None, Some(e)) => block_id < e,
(None, None) => true,
};
if is_inside_validity_window {

View File

@ -28,17 +28,21 @@ impl ExecutionState {
pub fn derive_from_outputs(program_id: ProgramId, program_outputs: Vec<ProgramOutput>) -> Self {
let valid_from_id = program_outputs
.iter()
.filter_map(|output| output.validity_window.0)
.filter_map(|output| output.validity_window.from())
.max();
let valid_until_id = program_outputs
.iter()
.filter_map(|output| output.validity_window.1)
.filter_map(|output| output.validity_window.to())
.min();
let validity_window = (valid_from_id, valid_until_id).try_into().expect(
"There should be non empty intersection in the program output validity windows",
);
let mut execution_state = Self {
pre_states: Vec::new(),
post_states: HashMap::new(),
validity_window: (valid_from_id, valid_until_id),
validity_window,
};
let Some(first_output) = program_outputs.first() else {

View File

@ -19,18 +19,15 @@ fn main() {
let post = pre.account.clone();
let mut output = ProgramOutput::new(
let output = ProgramOutput::new(
instruction_words,
vec![pre],
vec![AccountPostState::new(post)],
);
if let Some(id) = from_id {
output = output.valid_from_id(id);
}
if let Some(id) = until_id {
output = output.valid_until_id(id);
}
)
.valid_from_id(from_id)
.unwrap()
.valid_until_id(until_id)
.unwrap();
output.write();
}