chore: simple attestation program

This commit is contained in:
r4bbit 2026-02-05 16:03:50 +01:00
parent 01f7d254c9
commit 75c0c31472
No known key found for this signature in database
GPG Key ID: E95F1E9447DC91A9
4 changed files with 672 additions and 0 deletions

1
Cargo.lock generated
View File

@ -4601,6 +4601,7 @@ name = "program_deployment"
version = "0.1.0"
dependencies = [
"clap",
"hex",
"nssa",
"nssa_core",
"tokio",

View File

@ -11,3 +11,4 @@ wallet.workspace = true
tokio = { workspace = true, features = ["macros"] }
clap.workspace = true
hex.workspace = true

View File

@ -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<u8>,
}
impl Attestation {
fn into_data(self) -> Data {
let mut bytes = Vec::<u8>::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<Self> {
let data = Vec::<u8>::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<AccountPostState> {
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<AccountPostState> {
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<u8>;
fn main() {
let (
ProgramInput {
pre_states,
instruction,
},
instruction_data,
) = read_nssa_inputs::<Instruction>();
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<u8> {
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);
}
}

View File

@ -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 -- <program_binary_path> <subcommand>
//
// Examples:
// cargo run --bin run_attestation -- \
// methods/guest/target/riscv32im-risc0-zkvm-elf/docker/attestation.bin \
// attest <creator_id> <attestation_id> <subject_id> <key_hex> <value_string>
//
// cargo run --bin run_attestation -- \
// methods/guest/target/riscv32im-risc0-zkvm-elf/docker/attestation.bin \
// revoke <creator_id> <attestation_id>
#[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<u8> = 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<u8> = 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<u8> = 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();
}
}
}