diff --git a/Cargo.lock b/Cargo.lock index fa55320f..f225074a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7843,6 +7843,7 @@ dependencies = [ name = "test_programs" version = "0.1.0" dependencies = [ + "clock_core", "nssa_core", "risc0-zkvm", ] diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index 23fbcd88..9f519eef 100644 Binary files a/artifacts/test_program_methods/burner.bin and b/artifacts/test_program_methods/burner.bin differ diff --git a/artifacts/test_program_methods/chain_caller.bin b/artifacts/test_program_methods/chain_caller.bin index b871eaf5..e3eaca78 100644 Binary files a/artifacts/test_program_methods/chain_caller.bin and b/artifacts/test_program_methods/chain_caller.bin differ diff --git a/artifacts/test_program_methods/changer_claimer.bin b/artifacts/test_program_methods/changer_claimer.bin index 22ce654e..b2b8895b 100644 Binary files a/artifacts/test_program_methods/changer_claimer.bin and b/artifacts/test_program_methods/changer_claimer.bin differ diff --git a/artifacts/test_program_methods/claimer.bin b/artifacts/test_program_methods/claimer.bin index 9a99c27f..b97cf30e 100644 Binary files a/artifacts/test_program_methods/claimer.bin and b/artifacts/test_program_methods/claimer.bin differ diff --git a/artifacts/test_program_methods/clock_chain_caller.bin b/artifacts/test_program_methods/clock_chain_caller.bin index 993f9f12..d836f2c9 100644 Binary files a/artifacts/test_program_methods/clock_chain_caller.bin and b/artifacts/test_program_methods/clock_chain_caller.bin differ diff --git a/artifacts/test_program_methods/data_changer.bin b/artifacts/test_program_methods/data_changer.bin index 014cf755..02b0d1d7 100644 Binary files a/artifacts/test_program_methods/data_changer.bin and b/artifacts/test_program_methods/data_changer.bin differ diff --git a/artifacts/test_program_methods/extra_output.bin b/artifacts/test_program_methods/extra_output.bin index 06a84868..e29d9557 100644 Binary files a/artifacts/test_program_methods/extra_output.bin and b/artifacts/test_program_methods/extra_output.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index 231cdbf4..60d0ee2e 100644 Binary files a/artifacts/test_program_methods/malicious_authorization_changer.bin and b/artifacts/test_program_methods/malicious_authorization_changer.bin differ diff --git a/artifacts/test_program_methods/minter.bin b/artifacts/test_program_methods/minter.bin index a08633c1..9f0f9731 100644 Binary files a/artifacts/test_program_methods/minter.bin and b/artifacts/test_program_methods/minter.bin differ diff --git a/artifacts/test_program_methods/missing_output.bin b/artifacts/test_program_methods/missing_output.bin index 1afe8ff1..d8312323 100644 Binary files a/artifacts/test_program_methods/missing_output.bin and b/artifacts/test_program_methods/missing_output.bin differ diff --git a/artifacts/test_program_methods/modified_transfer.bin b/artifacts/test_program_methods/modified_transfer.bin index 86f26d9a..2fff50cb 100644 Binary files a/artifacts/test_program_methods/modified_transfer.bin and b/artifacts/test_program_methods/modified_transfer.bin differ diff --git a/artifacts/test_program_methods/nonce_changer.bin b/artifacts/test_program_methods/nonce_changer.bin index b855919f..f9a50a54 100644 Binary files a/artifacts/test_program_methods/nonce_changer.bin and b/artifacts/test_program_methods/nonce_changer.bin differ diff --git a/artifacts/test_program_methods/noop.bin b/artifacts/test_program_methods/noop.bin index c1518d10..39e1161b 100644 Binary files a/artifacts/test_program_methods/noop.bin and b/artifacts/test_program_methods/noop.bin differ diff --git a/artifacts/test_program_methods/pinata_cooldown.bin b/artifacts/test_program_methods/pinata_cooldown.bin new file mode 100644 index 00000000..c735e157 Binary files /dev/null and b/artifacts/test_program_methods/pinata_cooldown.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index a7447878..45ada93d 100644 Binary files a/artifacts/test_program_methods/program_owner_changer.bin and b/artifacts/test_program_methods/program_owner_changer.bin differ diff --git a/artifacts/test_program_methods/simple_balance_transfer.bin b/artifacts/test_program_methods/simple_balance_transfer.bin index 92fce657..06e575a6 100644 Binary files a/artifacts/test_program_methods/simple_balance_transfer.bin and b/artifacts/test_program_methods/simple_balance_transfer.bin differ diff --git a/artifacts/test_program_methods/time_locked_transfer.bin b/artifacts/test_program_methods/time_locked_transfer.bin new file mode 100644 index 00000000..885a6579 Binary files /dev/null and b/artifacts/test_program_methods/time_locked_transfer.bin differ diff --git a/artifacts/test_program_methods/validity_window.bin b/artifacts/test_program_methods/validity_window.bin index 3c8e2955..6fd4c787 100644 Binary files a/artifacts/test_program_methods/validity_window.bin and b/artifacts/test_program_methods/validity_window.bin differ diff --git a/artifacts/test_program_methods/validity_window_chain_caller.bin b/artifacts/test_program_methods/validity_window_chain_caller.bin index 1fdf286e..875131f1 100644 Binary files a/artifacts/test_program_methods/validity_window_chain_caller.bin and b/artifacts/test_program_methods/validity_window_chain_caller.bin differ diff --git a/nssa/src/program.rs b/nssa/src/program.rs index a7e376ee..ed0e90ad 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -319,6 +319,18 @@ mod tests { use test_program_methods::VALIDITY_WINDOW_CHAIN_CALLER_ELF; Self::new(VALIDITY_WINDOW_CHAIN_CALLER_ELF.to_vec()).unwrap() } + + #[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] diff --git a/nssa/src/state.rs b/nssa/src/state.rs index c21793e8..3024fe60 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -400,6 +400,8 @@ pub mod tests { self.insert_program(Program::claimer()); self.insert_program(Program::changer_claimer()); self.insert_program(Program::validity_window()); + self.insert_program(Program::time_locked_transfer()); + self.insert_program(Program::pinata_cooldown()); self } @@ -3644,6 +3646,227 @@ 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 { + 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]); diff --git a/test_program_methods/guest/Cargo.toml b/test_program_methods/guest/Cargo.toml index 1ca958b3..9764bd24 100644 --- a/test_program_methods/guest/Cargo.toml +++ b/test_program_methods/guest/Cargo.toml @@ -9,5 +9,6 @@ workspace = true [dependencies] nssa_core.workspace = true +clock_core.workspace = true risc0-zkvm.workspace = true diff --git a/test_program_methods/guest/src/bin/pinata_cooldown.rs b/test_program_methods/guest/src/bin/pinata_cooldown.rs new file mode 100644 index 00000000..460fb34b --- /dev/null +++ b/test_program_methods/guest/src/bin/pinata_cooldown.rs @@ -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 (read-only, e.g. `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 { + 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::(); + + 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(); +} diff --git a/test_program_methods/guest/src/bin/time_locked_transfer.rs b/test_program_methods/guest/src/bin/time_locked_transfer.rs new file mode 100644 index 00000000..681d7fcd --- /dev/null +++ b/test_program_methods/guest/src/bin/time_locked_transfer.rs @@ -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::(); + + 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(); +}