add clock usage example programs

This commit is contained in:
Sergio Chouhy 2026-04-03 20:54:19 -03:00
parent 34de497d4d
commit 015999b3a5
25 changed files with 421 additions and 0 deletions

1
Cargo.lock generated
View File

@ -7843,6 +7843,7 @@ dependencies = [
name = "test_programs"
version = "0.1.0"
dependencies = [
"clock_core",
"nssa_core",
"risc0-zkvm",
]

Binary file not shown.

View File

@ -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]

View File

@ -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<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]);

View File

@ -9,5 +9,6 @@ workspace = true
[dependencies]
nssa_core.workspace = true
clock_core.workspace = true
risc0-zkvm.workspace = true

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