diff --git a/STABILITY.md b/STABILITY.md new file mode 100644 index 0000000..2cd659e --- /dev/null +++ b/STABILITY.md @@ -0,0 +1,35 @@ +# Stability Guarantees + +## ContractError Discriminant Values + +The `ContractError` enum in `contracts/anonvote/src/errors.rs` defines error codes +returned by the AnonVote Soroban contract. Each variant has a fixed `#[repr(u32)]` +discriminant. + +**These discriminant values must never change after the contract is deployed.** +Consumers that parse error codes from Stellar transaction results depend on the +numeric value, not the variant name. Changing a value is a breaking change. + +| Variant | Value | Description | +|------------------------|-------|------------------------------------------| +| AlreadyInitialized | 1 | initialize called after admin is set | +| Unauthorized | 2 | caller is not the admin | +| BallotAlreadyExists | 3 | record_ballot called with existing hash | +| BallotNotFound | 4 | write op with unregistered ballot | +| BallotAlreadyFinalised | 5 | record_result after result is set | +| InvalidBallotIdHash | 6 | ballot_id_hash not valid 64-char hex | +| InvalidResultHash | 7 | result_hash not valid 64-char hex | +| InvalidAdminAddress | 8 | new admin address is zero or same | +| CounterOverflow | 9 | token/vote counter exceeds u32::MAX | +| BallotExpired | 10 | operation after ballot ledger expiry | + +## Storage Key Stability + +Storage keys used for instance and persistent storage are derived from the +`DataKey` enum variants. Variant names must not be changed or reordered after +deployment. + +## Event Topics + +Events published by the contract use fixed topic symbols. Changing topic +symbols is a breaking change for off-chain event indexers. diff --git a/contracts/anonvote/src/errors.rs b/contracts/anonvote/src/errors.rs new file mode 100644 index 0000000..3aa33de --- /dev/null +++ b/contracts/anonvote/src/errors.rs @@ -0,0 +1,17 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + AlreadyInitialized = 1, + Unauthorized = 2, + BallotAlreadyExists = 3, + BallotNotFound = 4, + BallotAlreadyFinalised = 5, + InvalidBallotIdHash = 6, + InvalidResultHash = 7, + InvalidAdminAddress = 8, + CounterOverflow = 9, + BallotExpired = 10, +} diff --git a/contracts/anonvote/src/lib.rs b/contracts/anonvote/src/lib.rs index dde822c..cac338f 100644 --- a/contracts/anonvote/src/lib.rs +++ b/contracts/anonvote/src/lib.rs @@ -196,7 +196,7 @@ impl AnonVoteContract { /// Initializes the contract. Governance starts as 1-of-1 with the admin as /// the sole approver, so deployments can explicitly configure M-of-N next. pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { - if env.storage().instance().has(&DataKey::Admin) { + if env.storage().instance().has(&DataKey::Initialized) { return Err(ContractError::AlreadyInitialized); } @@ -567,6 +567,7 @@ impl AnonVoteContract { caller: Address, ballot_id_hash: String, ) -> Result<(), ContractError> { + validate_hex_hash(&env, &ballot_id_hash, ContractError::InvalidBallotIdHash)?; caller.require_auth(); Self::require_not_paused(&env)?; Self::require_admin(&env, &caller)?; @@ -595,6 +596,7 @@ impl AnonVoteContract { caller: Address, ballot_id_hash: String, ) -> Result<(), ContractError> { + validate_hex_hash(&env, &ballot_id_hash, ContractError::InvalidBallotIdHash)?; caller.require_auth(); Self::require_not_paused(&env)?; Self::require_admin(&env, &caller)?; @@ -623,6 +625,7 @@ impl AnonVoteContract { caller: Address, ballot_id_hash: String, ) -> Result<(), ContractError> { + validate_hex_hash(&env, &result_hash, ContractError::InvalidResultHash)?; caller.require_auth(); Self::require_admin(&env, &caller)?; Self::require_ballot_metadata(&env, &ballot_id_hash)?; @@ -1104,9 +1107,9 @@ impl AnonVoteContract { .storage() .instance() .get(&DataKey::Admin) - .ok_or(ContractError::NotInitialized)?; + .ok_or(ContractError::Unauthorized)?; if *caller != admin { - return Err(ContractError::AdminUnauthorized); + return Err(ContractError::Unauthorized); } Ok(()) } @@ -1950,3 +1953,6 @@ mod tests { assert!(!client.is_consistent(&phantom)); } } + +#[cfg(test)] +mod test; diff --git a/contracts/anonvote/src/test.rs b/contracts/anonvote/src/test.rs new file mode 100644 index 0000000..fd79527 --- /dev/null +++ b/contracts/anonvote/src/test.rs @@ -0,0 +1,424 @@ +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; + +fn valid_ballot_hash(env: &Env) -> String { + String::from_str( + env, + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + ) +} + +fn valid_result_hash(env: &Env) -> String { + String::from_str( + env, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ) +} + +fn setup() -> (Env, AnonVoteContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnonVoteContract); + let client = AnonVoteContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin).unwrap(); + (env, client, admin) +} + +fn setup_with_id() -> (Env, Address, AnonVoteContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnonVoteContract); + let client = AnonVoteContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin).unwrap(); + (env, contract_id, client, admin) +} + +fn setup_uninitialized() -> (Env, AnonVoteContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnonVoteContract); + let client = AnonVoteContractClient::new(&env, &contract_id); + (env, client) +} + +#[test] +fn test_initialize_sets_admin() { + let (env, client, admin) = setup(); + let stored = client.get_admin().unwrap(); + assert_eq!(stored, admin); + assert!(client.get_initialized_at().unwrap() > 0); +} + +#[test] +fn test_initialize_twice_returns_already_initialized() { + let (_, client, admin) = setup(); + let err = client.try_initialize(&admin).unwrap_err().unwrap(); + assert_eq!(err, ContractError::AlreadyInitialized); +} + +#[test] +fn test_initialize_zero_address_returns_invalid_admin() { + let (env, client) = setup_uninitialized(); + let zero_key = String::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + let zero = Address::from_string(&zero_key); + let err = client.try_initialize(&zero).unwrap_err().unwrap(); + assert_eq!(err, ContractError::InvalidAdminAddress); +} + +#[test] +fn test_record_ballot_success() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + assert!(client.ballot_exists(&hash)); + assert_eq!(client.get_tokens_issued(&hash), Some(0)); + assert_eq!(client.get_votes_cast(&hash), Some(0)); +} + +#[test] +fn test_record_ballot_invalid_hash_wrong_length() { + let (env, client, admin) = setup(); + let short = String::from_str(&env, "abc"); + let err = client + .try_record_ballot(&admin, &short) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::InvalidBallotIdHash); +} + +#[test] +fn test_record_ballot_invalid_hash_uppercase() { + let (env, client, admin) = setup(); + let upper = String::from_str( + &env, + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", + ); + let err = client + .try_record_ballot(&admin, &upper) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::InvalidBallotIdHash); +} + +#[test] +fn test_record_ballot_invalid_hash_non_hex() { + let (env, client, admin) = setup(); + let non_hex = String::from_str( + &env, + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ); + let err = client + .try_record_ballot(&admin, &non_hex) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::InvalidBallotIdHash); +} + +#[test] +fn test_record_ballot_duplicate_returns_already_exists() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + let err = client + .try_record_ballot(&admin, &hash) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::BallotAlreadyExists); +} + +#[test] +fn test_record_token_success() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + client.record_token(&admin, &hash).unwrap(); + assert_eq!(client.get_tokens_issued(&hash), Some(1)); + client.record_token(&admin, &hash).unwrap(); + assert_eq!(client.get_tokens_issued(&hash), Some(2)); +} + +#[test] +fn test_record_token_ballot_not_found() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + let err = client + .try_record_token(&admin, &hash) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::BallotNotFound); +} + +#[test] +fn test_record_token_after_finalised_returns_error() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + let result = valid_result_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + client.record_result(&admin, &hash, &result).unwrap(); + let err = client + .try_record_token(&admin, &hash) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::BallotAlreadyFinalised); +} + +#[test] +fn test_record_vote_success() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + client.record_vote(&admin, &hash).unwrap(); + assert_eq!(client.get_votes_cast(&hash), Some(1)); + client.record_vote(&admin, &hash).unwrap(); + assert_eq!(client.get_votes_cast(&hash), Some(2)); +} + +#[test] +fn test_record_vote_ballot_not_found() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + let err = client + .try_record_vote(&admin, &hash) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::BallotNotFound); +} + +#[test] +fn test_record_vote_after_finalised_returns_error() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + let result = valid_result_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + client.record_result(&admin, &hash, &result).unwrap(); + let err = client + .try_record_vote(&admin, &hash) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::BallotAlreadyFinalised); +} + +#[test] +fn test_counter_overflow_at_max() { + let (env, contract_id, client, admin) = setup_with_id(); + let hash = valid_ballot_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + + let bh = hash.clone(); + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&DataKey::TokensIssued(bh), &u32::MAX); + }); + + let err = client + .try_record_token(&admin, &hash) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::CounterOverflow); +} + +#[test] +fn test_counter_overflow_does_not_panic() { + let (env, contract_id, client, admin) = setup_with_id(); + let hash1 = valid_ballot_hash(&env); + let hash2 = String::from_str( + &env, + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + ); + client.record_ballot(&admin, &hash1).unwrap(); + client.record_ballot(&admin, &hash2).unwrap(); + + let bh = hash1.clone(); + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&DataKey::TokensIssued(bh), &u32::MAX); + }); + + client.record_token(&admin, &hash1).unwrap_err().unwrap(); + client.record_token(&admin, &hash2).unwrap(); + assert_eq!(client.get_tokens_issued(&hash2), Some(1)); +} + +#[test] +fn test_counter_overflow_emits_event() { + let (env, contract_id, client, admin) = setup_with_id(); + let hash = valid_ballot_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + + let bh = hash.clone(); + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&DataKey::TokensIssued(bh), &u32::MAX); + }); + + client.try_record_token(&admin, &hash).unwrap_err().unwrap(); + let events = env.events().all(); + assert!(events.len() >= 2); +} + +#[test] +fn test_record_result_success() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + let result = valid_result_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + client + .record_result(&admin, &hash, &result) + .unwrap(); + assert_eq!(client.get_result_hash(&hash), Some(result)); + assert!(client.result_exists(&hash)); +} + +#[test] +fn test_record_result_invalid_hash() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + let bad_result = String::from_str(&env, "nothex"); + client.record_ballot(&admin, &hash).unwrap(); + let err = client + .try_record_result(&admin, &hash, &bad_result) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::InvalidResultHash); +} + +#[test] +fn test_record_result_twice_returns_already_finalised() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + let result = valid_result_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + client + .record_result(&admin, &hash, &result) + .unwrap(); + let result2 = String::from_str( + &env, + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ); + let err = client + .try_record_result(&admin, &hash, &result2) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::BallotAlreadyFinalised); +} + +#[test] +fn test_record_result_ballot_not_found() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + let result = valid_result_hash(&env); + let err = client + .try_record_result(&admin, &hash, &result) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::BallotNotFound); +} + +#[test] +fn test_rotate_admin_success() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + client.rotate_admin(&admin, &new_admin).unwrap(); + let stored = client.get_admin().unwrap(); + assert_eq!(stored, new_admin); + let err = client + .try_record_ballot(&admin, &valid_ballot_hash(&env)) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized); + client + .record_ballot(&new_admin, &valid_ballot_hash(&env)) + .unwrap(); +} + +#[test] +fn test_rotate_admin_same_address_returns_invalid() { + let (env, client, admin) = setup(); + let err = client + .try_rotate_admin(&admin, &admin) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::InvalidAdminAddress); +} + +#[test] +fn test_rotate_admin_zero_address_returns_invalid() { + let (env, client, admin) = setup(); + let zero_key = String::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + let zero = Address::from_string(&zero_key); + let err = client + .try_rotate_admin(&admin, &zero) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::InvalidAdminAddress); +} + +#[test] +fn test_rotate_admin_unauthorized() { + let (env, client, _admin) = setup(); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let err = client + .try_rotate_admin(&attacker, &new_admin) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized); +} + +#[test] +fn test_rotate_admin_history_grows() { + let (env, client, admin) = setup(); + let new_admin1 = Address::generate(&env); + let new_admin2 = Address::generate(&env); + client.rotate_admin(&admin, &new_admin1).unwrap(); + client.rotate_admin(&new_admin1, &new_admin2).unwrap(); + let history = client.get_admin_history(); + assert_eq!(history.len(), 2); + let first = history.get(0).unwrap(); + assert_eq!(first.old_admin, admin); + assert_eq!(first.new_admin, new_admin1); + let second = history.get(1).unwrap(); + assert_eq!(second.old_admin, new_admin1); + assert_eq!(second.new_admin, new_admin2); +} + +#[test] +fn test_is_consistent_true() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + assert!(client.is_consistent(&hash)); + client.record_token(&admin, &hash).unwrap(); + assert!(!client.is_consistent(&hash)); + client.record_vote(&admin, &hash).unwrap(); + assert!(client.is_consistent(&hash)); +} + +#[test] +fn test_is_consistent_false() { + let (env, client, admin) = setup(); + let hash = valid_ballot_hash(&env); + client.record_ballot(&admin, &hash).unwrap(); + client.record_token(&admin, &hash).unwrap(); + client.record_token(&admin, &hash).unwrap(); + client.record_vote(&admin, &hash).unwrap(); + assert!(!client.is_consistent(&hash)); +} + +#[test] +fn test_is_consistent_nonexistent_ballot() { + let (env, client, _admin) = setup(); + let hash = valid_ballot_hash(&env); + assert!(!client.is_consistent(&hash)); +} diff --git a/contracts/anonvote/src/validation.rs b/contracts/anonvote/src/validation.rs new file mode 100644 index 0000000..b2da328 --- /dev/null +++ b/contracts/anonvote/src/validation.rs @@ -0,0 +1,21 @@ +use soroban_sdk::{Env, String}; +use crate::errors::ContractError; + +pub fn validate_hex_hash( + _env: &Env, + hash: &String, + error: ContractError, +) -> Result<(), ContractError> { + if hash.len() != 64 { + return Err(error); + } + let mut buf = [0u8; 64]; + hash.copy_into_slice(&mut buf); + for ch in buf.iter() { + match ch { + b'0'..=b'9' | b'a'..=b'f' => {} + _ => return Err(error), + } + } + Ok(()) +}