diff --git a/Cargo.lock b/Cargo.lock index d6a7b766..ea0dca84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4601,6 +4601,7 @@ name = "program_deployment" version = "0.1.0" dependencies = [ "clap", + "hex", "nssa", "nssa_core", "tokio", diff --git a/examples/program_deployment/Cargo.toml b/examples/program_deployment/Cargo.toml index 2199fe21..81497226 100644 --- a/examples/program_deployment/Cargo.toml +++ b/examples/program_deployment/Cargo.toml @@ -11,3 +11,4 @@ wallet.workspace = true tokio = { workspace = true, features = ["macros"] } clap.workspace = true +hex.workspace = true diff --git a/examples/program_deployment/methods/guest/src/bin/attestation.rs b/examples/program_deployment/methods/guest/src/bin/attestation.rs new file mode 100644 index 00000000..2dd77d57 --- /dev/null +++ b/examples/program_deployment/methods/guest/src/bin/attestation.rs @@ -0,0 +1,509 @@ +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data}, + program::{ + AccountPostState, ProgramInput, read_nssa_inputs, write_nssa_outputs, + }, +}; + +// Attestation registry program. +// +// Any account holder can create attestations about any subject, identified by +// a 32-byte key, with an arbitrary-length value. Only the original creator can +// update or revoke their attestation. +// +// Data layout (stored in the attestation account's `data` field): +// Bytes 0..32 : creator (AccountId, 32 bytes) +// Bytes 32..64 : subject (AccountId, 32 bytes) +// Bytes 64..96 : key ([u8; 32], 32 bytes) +// Byte 96 : revoked (bool, 0 = active, 1 = revoked) +// Bytes 97.. : value (variable length) +// +// Instructions: +// 0x00 Attest — payload: subject (32) || key (32) || value (var) +// accounts: [creator (authorized), attestation_account] +// 0x01 Revoke — payload: (none) +// accounts: [creator (authorized), attestation_account] + +const ATTESTATION_HEADER_SIZE: usize = 97; // 32 + 32 + 32 + 1 + +struct Attestation { + creator: AccountId, + subject: AccountId, + key: [u8; 32], + revoked: bool, + value: Vec, +} + +impl Attestation { + fn into_data(self) -> Data { + let mut bytes = Vec::::new(); + bytes.extend_from_slice(&self.creator.to_bytes()); + bytes.extend_from_slice(&self.subject.to_bytes()); + bytes.extend_from_slice(&self.key); + bytes.push(self.revoked as u8); + bytes.extend_from_slice(&self.value); + + Data::try_from(bytes).expect("Attestation data must fit within the allowed limits") + } + + fn parse(data: &Data) -> Option { + let data = Vec::::from(data.clone()); + + if data.len() < ATTESTATION_HEADER_SIZE { + return None; + } + + let creator = AccountId::new( + data[0..32] + .try_into() + .expect("Creator must be 32 bytes"), + ); + let subject = AccountId::new( + data[32..64] + .try_into() + .expect("Subject must be 32 bytes"), + ); + let key: [u8; 32] = data[64..96] + .try_into() + .expect("Key must be 32 bytes"); + let revoked = data[96] != 0; + let value = data[97..].to_vec(); + + Some(Self { + creator, + subject, + key, + revoked, + value, + }) + } +} + +fn attest(pre_states: &[AccountWithMetadata], payload: &[u8]) -> Vec { + if pre_states.len() != 2 { + panic!("Attest requires exactly 2 accounts"); + } + + let creator_account = &pre_states[0]; + let attestation_account = &pre_states[1]; + + if !creator_account.is_authorized { + panic!("Missing required authorization for creator"); + } + + if payload.len() < 64 { + panic!("Attest payload must contain at least subject (32) and key (32)"); + } + + let subject = AccountId::new( + payload[0..32] + .try_into() + .expect("Subject must be 32 bytes"), + ); + let key: [u8; 32] = payload[32..64] + .try_into() + .expect("Key must be 32 bytes"); + let value = payload[64..].to_vec(); + + let attestation_post = if attestation_account.account == Account::default() { + // Creating a new attestation + let attestation = Attestation { + creator: creator_account.account_id, + subject, + key, + revoked: false, + value, + }; + + let mut account = attestation_account.account.clone(); + account.data = attestation.into_data(); + AccountPostState::new_claimed(account) + } else { + // Updating an existing attestation + let mut existing = Attestation::parse(&attestation_account.account.data) + .expect("Invalid existing attestation data"); + + assert_eq!( + existing.creator, creator_account.account_id, + "Only the original creator can update an attestation" + ); + assert!( + !existing.revoked, + "Cannot update a revoked attestation" + ); + + existing.value = value; + + let mut account = attestation_account.account.clone(); + account.data = existing.into_data(); + AccountPostState::new(account) + }; + + let creator_post = AccountPostState::new(creator_account.account.clone()); + + vec![creator_post, attestation_post] +} + +fn revoke(pre_states: &[AccountWithMetadata]) -> Vec { + if pre_states.len() != 2 { + panic!("Revoke requires exactly 2 accounts"); + } + + let creator_account = &pre_states[0]; + let attestation_account = &pre_states[1]; + + if !creator_account.is_authorized { + panic!("Missing required authorization for creator"); + } + + if attestation_account.account == Account::default() { + panic!("Cannot revoke a non-existent attestation"); + } + + let mut existing = Attestation::parse(&attestation_account.account.data) + .expect("Invalid existing attestation data"); + + assert_eq!( + existing.creator, creator_account.account_id, + "Only the original creator can revoke an attestation" + ); + assert!( + !existing.revoked, + "Attestation is already revoked" + ); + + existing.revoked = true; + + let mut account = attestation_account.account.clone(); + account.data = existing.into_data(); + + let creator_post = AccountPostState::new(creator_account.account.clone()); + let attestation_post = AccountPostState::new(account); + + vec![creator_post, attestation_post] +} + +type Instruction = Vec; + +fn main() { + let ( + ProgramInput { + pre_states, + instruction, + }, + instruction_data, + ) = read_nssa_inputs::(); + + let post_states = match instruction[0] { + 0 => attest(&pre_states, &instruction[1..]), + 1 => revoke(&pre_states), + _ => panic!("Invalid instruction opcode"), + }; + + write_nssa_outputs(instruction_data, pre_states, post_states); +} + +#[cfg(test)] +mod tests { + use nssa_core::account::{Account, AccountId, AccountWithMetadata, Data}; + + use crate::{ATTESTATION_HEADER_SIZE, Attestation, attest, revoke}; + + fn creator_id() -> AccountId { + AccountId::new([1; 32]) + } + + fn subject_id() -> AccountId { + AccountId::new([2; 32]) + } + + fn attestation_key() -> [u8; 32] { + [3; 32] + } + + fn creator_account(is_authorized: bool) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: [5u32; 8], + balance: 0, + data: Data::default(), + nonce: 0, + }, + is_authorized, + account_id: creator_id(), + } + } + + fn uninit_attestation_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: AccountId::new([10; 32]), + } + } + + fn existing_attestation_account(revoked: bool, value: &[u8]) -> AccountWithMetadata { + let attestation = Attestation { + creator: creator_id(), + subject: subject_id(), + key: attestation_key(), + revoked, + value: value.to_vec(), + }; + AccountWithMetadata { + account: Account { + program_owner: [5u32; 8], + balance: 0, + data: attestation.into_data(), + nonce: 0, + }, + is_authorized: false, + account_id: AccountId::new([10; 32]), + } + } + + fn build_attest_payload(subject: &AccountId, key: &[u8; 32], value: &[u8]) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(subject.value()); + payload.extend_from_slice(key); + payload.extend_from_slice(value); + payload + } + + #[test] + fn test_attestation_serialize_deserialize() { + let original = Attestation { + creator: creator_id(), + subject: subject_id(), + key: attestation_key(), + revoked: false, + value: b"hello world".to_vec(), + }; + + let data = original.into_data(); + let parsed = Attestation::parse(&data).expect("Should parse successfully"); + + assert_eq!(parsed.creator, creator_id()); + assert_eq!(parsed.subject, subject_id()); + assert_eq!(parsed.key, attestation_key()); + assert!(!parsed.revoked); + assert_eq!(parsed.value, b"hello world"); + } + + #[test] + fn test_attestation_parse_invalid_length() { + let short_data = Data::try_from(vec![0u8; ATTESTATION_HEADER_SIZE - 1]).unwrap(); + assert!(Attestation::parse(&short_data).is_none()); + } + + #[test] + fn test_attestation_parse_empty() { + let empty_data = Data::default(); + assert!(Attestation::parse(&empty_data).is_none()); + } + + #[test] + fn test_attest_create_new() { + let pre_states = vec![creator_account(true), uninit_attestation_account()]; + let payload = build_attest_payload(&subject_id(), &attestation_key(), b"test value"); + + let post_states = attest(&pre_states, &payload); + assert_eq!(post_states.len(), 2); + + // Creator account should be unchanged + assert_eq!(*post_states[0].account(), pre_states[0].account); + assert!(!post_states[0].requires_claim()); + + // Attestation account should be claimed with new data + assert!(post_states[1].requires_claim()); + let parsed = Attestation::parse(&post_states[1].account().data) + .expect("Should parse attestation"); + assert_eq!(parsed.creator, creator_id()); + assert_eq!(parsed.subject, subject_id()); + assert_eq!(parsed.key, attestation_key()); + assert!(!parsed.revoked); + assert_eq!(parsed.value, b"test value"); + } + + #[test] + fn test_attest_update_existing() { + let pre_states = vec![ + creator_account(true), + existing_attestation_account(false, b"old value"), + ]; + let payload = build_attest_payload(&subject_id(), &attestation_key(), b"new value"); + + let post_states = attest(&pre_states, &payload); + assert_eq!(post_states.len(), 2); + + assert!(!post_states[1].requires_claim()); + let parsed = Attestation::parse(&post_states[1].account().data) + .expect("Should parse attestation"); + assert_eq!(parsed.value, b"new value"); + assert!(!parsed.revoked); + } + + #[test] + #[should_panic(expected = "Missing required authorization for creator")] + fn test_attest_missing_authorization() { + let pre_states = vec![creator_account(false), uninit_attestation_account()]; + let payload = build_attest_payload(&subject_id(), &attestation_key(), b"test"); + + attest(&pre_states, &payload); + } + + #[test] + #[should_panic(expected = "Only the original creator can update an attestation")] + fn test_attest_update_wrong_creator() { + let different_creator = AccountWithMetadata { + account: Account { + program_owner: [5u32; 8], + balance: 0, + data: Data::default(), + nonce: 0, + }, + is_authorized: true, + account_id: AccountId::new([99; 32]), + }; + let pre_states = vec![ + different_creator, + existing_attestation_account(false, b"old"), + ]; + let payload = build_attest_payload(&subject_id(), &attestation_key(), b"new"); + + attest(&pre_states, &payload); + } + + #[test] + #[should_panic(expected = "Cannot update a revoked attestation")] + fn test_attest_update_revoked() { + let pre_states = vec![ + creator_account(true), + existing_attestation_account(true, b"old"), + ]; + let payload = build_attest_payload(&subject_id(), &attestation_key(), b"new"); + + attest(&pre_states, &payload); + } + + #[test] + #[should_panic(expected = "Attest requires exactly 2 accounts")] + fn test_attest_wrong_account_count() { + let pre_states = vec![creator_account(true)]; + let payload = build_attest_payload(&subject_id(), &attestation_key(), b"test"); + + attest(&pre_states, &payload); + } + + #[test] + #[should_panic(expected = "Attest payload must contain at least subject (32) and key (32)")] + fn test_attest_payload_too_short() { + let pre_states = vec![creator_account(true), uninit_attestation_account()]; + + attest(&pre_states, &[0u8; 63]); + } + + #[test] + fn test_revoke_success() { + let pre_states = vec![ + creator_account(true), + existing_attestation_account(false, b"some value"), + ]; + + let post_states = revoke(&pre_states); + assert_eq!(post_states.len(), 2); + + assert_eq!(*post_states[0].account(), pre_states[0].account); + assert!(!post_states[0].requires_claim()); + + assert!(!post_states[1].requires_claim()); + let parsed = Attestation::parse(&post_states[1].account().data) + .expect("Should parse attestation"); + assert!(parsed.revoked); + assert_eq!(parsed.value, b"some value"); + } + + #[test] + #[should_panic(expected = "Missing required authorization for creator")] + fn test_revoke_missing_authorization() { + let pre_states = vec![ + creator_account(false), + existing_attestation_account(false, b"val"), + ]; + + revoke(&pre_states); + } + + #[test] + #[should_panic(expected = "Cannot revoke a non-existent attestation")] + fn test_revoke_nonexistent() { + let pre_states = vec![creator_account(true), uninit_attestation_account()]; + + revoke(&pre_states); + } + + #[test] + #[should_panic(expected = "Only the original creator can revoke an attestation")] + fn test_revoke_wrong_creator() { + let different_creator = AccountWithMetadata { + account: Account { + program_owner: [5u32; 8], + balance: 0, + data: Data::default(), + nonce: 0, + }, + is_authorized: true, + account_id: AccountId::new([99; 32]), + }; + let pre_states = vec![ + different_creator, + existing_attestation_account(false, b"val"), + ]; + + revoke(&pre_states); + } + + #[test] + #[should_panic(expected = "Attestation is already revoked")] + fn test_revoke_already_revoked() { + let pre_states = vec![ + creator_account(true), + existing_attestation_account(true, b"val"), + ]; + + revoke(&pre_states); + } + + #[test] + #[should_panic(expected = "Revoke requires exactly 2 accounts")] + fn test_revoke_wrong_account_count() { + let pre_states = vec![creator_account(true)]; + + revoke(&pre_states); + } + + #[test] + fn test_attest_empty_value() { + let pre_states = vec![creator_account(true), uninit_attestation_account()]; + let payload = build_attest_payload(&subject_id(), &attestation_key(), b""); + + let post_states = attest(&pre_states, &payload); + + let parsed = Attestation::parse(&post_states[1].account().data) + .expect("Should parse attestation"); + assert_eq!(parsed.value, b""); + } + + #[test] + fn test_attestation_header_size_matches_parse() { + let attestation = Attestation { + creator: creator_id(), + subject: subject_id(), + key: attestation_key(), + revoked: false, + value: vec![], + }; + let data = attestation.into_data(); + assert_eq!(data.len(), ATTESTATION_HEADER_SIZE); + } +} diff --git a/examples/program_deployment/src/bin/run_attestation.rs b/examples/program_deployment/src/bin/run_attestation.rs new file mode 100644 index 00000000..bd2578a9 --- /dev/null +++ b/examples/program_deployment/src/bin/run_attestation.rs @@ -0,0 +1,161 @@ +use clap::{Parser, Subcommand}; +use nssa::{ + AccountId, PublicTransaction, + program::Program, + public_transaction::{Message, WitnessSet}, +}; +use wallet::WalletCore; + +// Before running this example, compile the `attestation.rs` guest program with: +// +// cargo risczero build --manifest-path examples/program_deployment/methods/guest/Cargo.toml +// +// Note: you must run the above command from the root of the repository. +// Note: The compiled binary file is stored in +// methods/guest/target/riscv32im-risc0-zkvm-elf/docker/attestation.bin +// +// Usage: +// cargo run --bin run_attestation -- +// +// Examples: +// cargo run --bin run_attestation -- \ +// methods/guest/target/riscv32im-risc0-zkvm-elf/docker/attestation.bin \ +// attest +// +// cargo run --bin run_attestation -- \ +// methods/guest/target/riscv32im-risc0-zkvm-elf/docker/attestation.bin \ +// revoke + +#[derive(Parser, Debug)] +struct Cli { + /// Path to the attestation program binary + program_path: String, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Create or update an attestation + Attest { + /// Account ID of the creator (must be a self-owned public account) + creator_id: String, + /// Account ID for the attestation record + attestation_id: String, + /// Account ID of the subject being attested about + subject_id: String, + /// 32-byte attestation key as a hex string (64 hex chars) + key_hex: String, + /// Attestation value as a UTF-8 string + value_string: String, + }, + /// Revoke an existing attestation + Revoke { + /// Account ID of the creator (must be a self-owned public account) + creator_id: String, + /// Account ID of the attestation record to revoke + attestation_id: String, + }, +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + // Load the program + let bytecode: Vec = std::fs::read(cli.program_path).unwrap(); + let program = Program::new(bytecode).unwrap(); + + // Initialize wallet + let wallet_core = WalletCore::from_env().unwrap(); + + match cli.command { + Command::Attest { + creator_id, + attestation_id, + subject_id, + key_hex, + value_string, + } => { + let creator_id: AccountId = creator_id.parse().unwrap(); + let attestation_id: AccountId = attestation_id.parse().unwrap(); + let subject_id: AccountId = subject_id.parse().unwrap(); + let key_bytes: [u8; 32] = hex::decode(&key_hex) + .expect("key_hex must be valid hex") + .try_into() + .expect("key_hex must decode to exactly 32 bytes"); + + let signing_key = wallet_core + .storage() + .user_data + .get_pub_account_signing_key(&creator_id) + .expect("Creator account should be a self-owned public account"); + + let nonces = wallet_core + .get_accounts_nonces(vec![creator_id]) + .await + .expect("Node should be reachable to query account data"); + + // Build instruction: [0x00 || subject (32) || key (32) || value (var)] + let mut instruction: Vec = Vec::new(); + instruction.push(0x00); + instruction.extend_from_slice(subject_id.value()); + instruction.extend_from_slice(&key_bytes); + instruction.extend_from_slice(value_string.as_bytes()); + + let message = Message::try_new( + program.id(), + vec![creator_id, attestation_id], + nonces, + instruction, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, &[signing_key]); + let tx = PublicTransaction::new(message, witness_set); + + let _response = wallet_core + .sequencer_client + .send_tx_public(tx) + .await + .unwrap(); + } + Command::Revoke { + creator_id, + attestation_id, + } => { + let creator_id: AccountId = creator_id.parse().unwrap(); + let attestation_id: AccountId = attestation_id.parse().unwrap(); + + let signing_key = wallet_core + .storage() + .user_data + .get_pub_account_signing_key(&creator_id) + .expect("Creator account should be a self-owned public account"); + + let nonces = wallet_core + .get_accounts_nonces(vec![creator_id]) + .await + .expect("Node should be reachable to query account data"); + + // Build instruction: [0x01] + let instruction: Vec = vec![0x01]; + + let message = Message::try_new( + program.id(), + vec![creator_id, attestation_id], + nonces, + instruction, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, &[signing_key]); + let tx = PublicTransaction::new(message, witness_set); + + let _response = wallet_core + .sequencer_client + .send_tx_public(tx) + .await + .unwrap(); + } + } +}