Summary
The AnonVote Soroban contract in contracts/anonvote/src/lib.rs has 10 functions — 4 write functions, 5 view functions, and 1 initialiser. None of
the write functions validate their inputs. record_ballot accepts any string as ballot_id_hash with no check that it is a valid SHA-256 hex digest. record_result accepts any string as result_hash with no validation. record_token and record_vote increment counters for ballot IDs that may
never have been registered via record_ballot. initialize can be called multiple times — there is no guard preventing an attacker from replacing the admin address after the contract is deployed. Counter values stored as u32 can overflow at 4 billion entries with no detection.
These are not edge cases. They are structural gaps in a contract that is meant to be the immutable, publicly verifiable record of every AnonVote election. An invalid result_hash written on-chain cannot be corrected. A phantom ballot created by calling record_token with a fabricated ballot_id_hash is indistinguishable from a real ballot to any external verifier. A reinitialized admin address means the contract's access control has been silently replaced.
This issue is a complete hardening of the contract layer — input validation, state guards, error taxonomy, overflow detection, and a test suite that proves every failure mode behaves correctly before a single byte is deployed to Stellar.
What Needs to Be Built
ContractError Enum — Full Error Taxonomy
The contract currently has a minimal or absent ContractError enum. It must be expanded into a complete, documented error taxonomy before any validation logic is written. Every error variant must have a u32 discriminant value that is stable across contract upgrades — changing a discriminant value is a breaking change for any consumer parsing error codes from Stellar transaction results.
Define in contracts/anonvote/src/errors.rs (new file):
use soroban_sdk::contracterror;
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum ContractError {
// Initialisation errors
AlreadyInitialized = 1, // initialize called after admin is set
Unauthorized = 2, // caller is not the admin
// Ballot errors
BallotAlreadyExists = 3, // record_ballot called with existing ballot_id_hash
BallotNotFound = 4, // write op called with unregistered ballot_id_hash
BallotAlreadyFinalised = 5, // record_result called after result is set
// Input validation errors
InvalidBallotIdHash = 6, // ballot_id_hash is not a valid 64-char hex string
InvalidResultHash = 7, // result_hash is not a valid 64-char hex string
InvalidAdminAddress = 8, // new admin address is zero or same as current
// Counter errors
CounterOverflow = 9, // token or vote counter would exceed u32::MAX
// Ledger errors
BallotExpired = 10, // operation attempted after ballot ledger expiry
}
Every error variant must have a JSDoc-style comment explaining the exact condition that triggers it. The discriminant values must never be changed once deployed — document this constraint in a STABILITY.md file at the repo root.
initialize — One-Time Guard
The current initialize function sets the admin address but has no guard preventing it from being called a second time. After the first call, any subsequent call must return Err(ContractError::AlreadyInitialized) without modifying any state.
The implementation must use a dedicated storage key to track whether initialization has occurred — do not rely on the presence of the admin address as the guard, because the admin address could theoretically be the zero address on the first call. Use an explicit INITIALIZED: bool flag in instance storage.
The admin address validation must also be hardened — reject a zero address (Address::from_str("G" + "A" * 55) is not the correct check, use
env.authenticator().is_zero() equivalent or Soroban's address validation utilities). Return InvalidAdminAddress for any address that fails validation.
record_ballot — Input Validation and Duplicate Guard
Two validations must run before any storage write:
Hash format validation — ballot_id_hash must be exactly 64 characters and contain only lowercase hex characters [0-9a-f]. Validate in a shared helper function validate_hex_hash(hash: &str) -> Result<(), ContractError> that is called from every write function that accepts a hash parameter. Return InvalidBallotIdHash if validation fails. Uppercase hex must be rejected — hashIdentifier from @anonvote/crypto always outputs lowercase and consistency is required for cross-verification.
Duplicate guard — check ballot_exists(ballot_id_hash) before writing. Return BallotAlreadyExists if the ballot is already registered. Do not
silently overwrite an existing ballot record.
After validation and duplicate check, write the ballot record to instance storage and emit a ("ballot", "registered") event with the ballot_id_hash and env.ledger().timestamp() as the event data.
record_token and record_vote — Existence Check and Overflow Guard
Both functions must run two checks before incrementing:
Existence check — call ballot_exists(ballot_id_hash) before any read or write. Return BallotNotFound if the ballot is not registered. This prevents phantom counter entries for fabricated ballot IDs.
Finalisation check — check whether get_result_hash(ballot_id_hash) returns a value. If a result hash exists, the ballot has been finalised and
no further token or vote increments should be accepted. Return BallotAlreadyFinalised.
Overflow guard — before incrementing, read the current counter value. If current_value == u32::MAX, return CounterOverflow without incrementing. Emit a ("counter", "overflow_prevented") event with the ballot_id_hash and current count for audit purposes. Log the event but do not panic — the contract must remain callable for other ballots even if one ballot's counter is saturated.
Both functions must also validate ballot_id_hash format using the shared validate_hex_hash helper before any storage access.
record_result — Immutability Guard and Hash Validation
record_result writes the result hash and must be callable exactly once per ballot. Two guards:
Finalisation guard — check whether a result hash already exists for this ballot. If it does, return BallotAlreadyFinalised. Do not overwrite an existing result hash under any circumstances — on-chain result records are the immutable audit trail.
Hash format validation — validate result_hash using validate_hex_hash. Return InvalidResultHash if validation fails. Do not write anything to storage if the hash is malformed.
After both checks pass, write the result hash to instance storage and emit a ("result", "published") event with ballot_id_hash, result_hash, and env.ledger().timestamp().
rotate_admin — New Function
The current contract has no way to rotate a compromised admin key. Add a rotate_admin(new_admin: Address) function:
- Require the current admin to authenticate the call using
env.current_contract_address() auth — only the current admin can rotate
- Validate
new_admin is not the zero address and is not the same as the current admin — return InvalidAdminAddress for either case
- Update the admin address in instance storage atomically
- Emit an
("admin", "rotated") event with the old admin address, new admin address, and env.ledger().timestamp()
- Store the rotation in a history list — a
Vec<(Address, Address, u64)> of (old_admin, new_admin, timestamp) triples — so the full admin ownership chain is reconstructable from contract state without relying on events alone
The rotation is immediate with no time-lock. Document this explicitly in the function's doc comment.
validate_hex_hash — Shared Validation Helper
Create contracts/anonvote/src/validation.rs (new file) with:
use soroban_sdk::{Env, String as SorobanString};
use crate::errors::ContractError;
pub fn validate_hex_hash(
env: &Env,
hash: &SorobanString,
error: ContractError,
) -> Result<(), ContractError> {
// Must be exactly 64 characters
if hash.len() != 64 {
return Err(error);
}
// Must contain only [0-9a-f]
// Soroban Strings iterate as u32 codepoints
for ch in hash.iter() {
match ch {
48..=57 | 97..=102 => {} // '0'-'9' | 'a'-'f'
_ => return Err(error),
}
}
Ok(())
}
This function is called from record_ballot, record_token, record_vote, and record_result. The error parameter allows the caller to specify which error variant to return — InvalidBallotIdHash or InvalidResultHash — so the error is semantically correct at the call site.
Test Suite — contracts/anonvote/src/test.rs
The current test suite is minimal. It must be expanded to cover every validation path, every error variant, and every state transition. Tests run
in the Soroban native test environment — no network required.
Initialisation tests
test_initialize_sets_admin — admin address is stored after first call
test_initialize_twice_returns_already_initialized — second call returns error
test_initialize_zero_address_returns_invalid_admin — zero address rejected
Ballot registration tests
test_record_ballot_success — ballot registered, event emitted
test_record_ballot_invalid_hash_wrong_length — 63-char string returns error
test_record_ballot_invalid_hash_uppercase — uppercase hex returns error
test_record_ballot_invalid_hash_non_hex — non-hex character returns error
test_record_ballot_duplicate_returns_already_exists — second call same hash returns error
Token and vote recording tests
test_record_token_success — counter incremented after valid ballot registered
test_record_token_ballot_not_found — unregistered ballot returns error
test_record_token_after_finalised_returns_error — increment after result set returns error
test_record_vote_success — counter incremented
test_record_vote_ballot_not_found — unregistered ballot returns error
test_record_vote_after_finalised_returns_error — increment after result set returns error
Counter overflow tests
test_counter_overflow_at_max — counter at u32::MAX returns CounterOverflow
test_counter_overflow_does_not_panic — other ballots still callable after one overflows
test_counter_overflow_emits_event — overflow event emitted with correct data
Result recording tests
test_record_result_success — result hash stored, event emitted
test_record_result_invalid_hash — non-hex result hash returns InvalidResultHash
test_record_result_twice_returns_already_finalised — second call returns error
test_record_result_ballot_not_found — unregistered ballot returns error
Admin rotation tests
test_rotate_admin_success — new admin stored, event emitted, history updated
test_rotate_admin_same_address_returns_invalid — same address returns error
test_rotate_admin_zero_address_returns_invalid — zero address returns error
test_rotate_admin_unauthorized — non-admin caller returns Unauthorized
test_rotate_admin_history_grows — two rotations produce two history entries
Consistency tests
test_is_consistent_true — equal token and vote counts returns true
test_is_consistent_false — unequal counts returns false
test_is_consistent_nonexistent_ballot — returns false, not error
Relevant Files
New files to create:
contracts/anonvote/src/errors.rs
contracts/anonvote/src/validation.rs
STABILITY.md
Existing files to modify:
contracts/anonvote/src/lib.rs
contracts/anonvote/src/test.rs
contracts/anonvote/Cargo.toml — ensure contracterror feature is enabled
Acceptance Criteria
Note for Contributors
The discriminant values in ContractError must never change after this issue is merged. A consumer parsing error codes from Stellar transaction results depends on the numeric value, not the variant name. Write STABILITY.md before writing a single error variant — it forces you to think about the stability contract before committing to a numbering scheme.
The validate_hex_hash helper must reject uppercase hex. This is not a style preference — hashIdentifier from @anonvote/crypto always outputs lowercase and a ballot registered with ballot_id_hash in uppercase would never match a query using a correctly-produced hash. An uppercase hash accepted by the contract creates an unreachable ballot record that silently breaks all subsequent operations for that ballot.
Summary
The AnonVote Soroban contract in
contracts/anonvote/src/lib.rshas 10 functions — 4 write functions, 5 view functions, and 1 initialiser. None ofthe write functions validate their inputs.
record_ballotaccepts any string asballot_id_hashwith no check that it is a valid SHA-256 hex digest.record_resultaccepts any string asresult_hashwith no validation.record_tokenandrecord_voteincrement counters for ballot IDs that maynever have been registered via
record_ballot.initializecan be called multiple times — there is no guard preventing an attacker from replacing the admin address after the contract is deployed. Counter values stored asu32can overflow at 4 billion entries with no detection.These are not edge cases. They are structural gaps in a contract that is meant to be the immutable, publicly verifiable record of every AnonVote election. An invalid
result_hashwritten on-chain cannot be corrected. A phantom ballot created by callingrecord_tokenwith a fabricatedballot_id_hashis indistinguishable from a real ballot to any external verifier. A reinitialized admin address means the contract's access control has been silently replaced.This issue is a complete hardening of the contract layer — input validation, state guards, error taxonomy, overflow detection, and a test suite that proves every failure mode behaves correctly before a single byte is deployed to Stellar.
What Needs to Be Built
ContractErrorEnum — Full Error TaxonomyThe contract currently has a minimal or absent
ContractErrorenum. It must be expanded into a complete, documented error taxonomy before any validation logic is written. Every error variant must have au32discriminant value that is stable across contract upgrades — changing a discriminant value is a breaking change for any consumer parsing error codes from Stellar transaction results.Define in
contracts/anonvote/src/errors.rs(new file):Every error variant must have a JSDoc-style comment explaining the exact condition that triggers it. The discriminant values must never be changed once deployed — document this constraint in a
STABILITY.mdfile at the repo root.initialize— One-Time GuardThe current
initializefunction sets the admin address but has no guard preventing it from being called a second time. After the first call, any subsequent call must returnErr(ContractError::AlreadyInitialized)without modifying any state.The implementation must use a dedicated storage key to track whether initialization has occurred — do not rely on the presence of the admin address as the guard, because the admin address could theoretically be the zero address on the first call. Use an explicit
INITIALIZED: boolflag in instance storage.The admin address validation must also be hardened — reject a zero address (
Address::from_str("G" + "A" * 55)is not the correct check, useenv.authenticator().is_zero()equivalent or Soroban's address validation utilities). ReturnInvalidAdminAddressfor any address that fails validation.record_ballot— Input Validation and Duplicate GuardTwo validations must run before any storage write:
Hash format validation —
ballot_id_hashmust be exactly 64 characters and contain only lowercase hex characters[0-9a-f]. Validate in a shared helper functionvalidate_hex_hash(hash: &str) -> Result<(), ContractError>that is called from every write function that accepts a hash parameter. ReturnInvalidBallotIdHashif validation fails. Uppercase hex must be rejected —hashIdentifierfrom@anonvote/cryptoalways outputs lowercase and consistency is required for cross-verification.Duplicate guard — check
ballot_exists(ballot_id_hash)before writing. ReturnBallotAlreadyExistsif the ballot is already registered. Do notsilently overwrite an existing ballot record.
After validation and duplicate check, write the ballot record to instance storage and emit a
("ballot", "registered")event with theballot_id_hashandenv.ledger().timestamp()as the event data.record_tokenandrecord_vote— Existence Check and Overflow GuardBoth functions must run two checks before incrementing:
Existence check — call
ballot_exists(ballot_id_hash)before any read or write. ReturnBallotNotFoundif the ballot is not registered. This prevents phantom counter entries for fabricated ballot IDs.Finalisation check — check whether
get_result_hash(ballot_id_hash)returns a value. If a result hash exists, the ballot has been finalised andno further token or vote increments should be accepted. Return
BallotAlreadyFinalised.Overflow guard — before incrementing, read the current counter value. If
current_value == u32::MAX, returnCounterOverflowwithout incrementing. Emit a("counter", "overflow_prevented")event with theballot_id_hashand current count for audit purposes. Log the event but do not panic — the contract must remain callable for other ballots even if one ballot's counter is saturated.Both functions must also validate
ballot_id_hashformat using the sharedvalidate_hex_hashhelper before any storage access.record_result— Immutability Guard and Hash Validationrecord_resultwrites the result hash and must be callable exactly once per ballot. Two guards:Finalisation guard — check whether a result hash already exists for this ballot. If it does, return
BallotAlreadyFinalised. Do not overwrite an existing result hash under any circumstances — on-chain result records are the immutable audit trail.Hash format validation — validate
result_hashusingvalidate_hex_hash. ReturnInvalidResultHashif validation fails. Do not write anything to storage if the hash is malformed.After both checks pass, write the result hash to instance storage and emit a
("result", "published")event withballot_id_hash,result_hash, andenv.ledger().timestamp().rotate_admin— New FunctionThe current contract has no way to rotate a compromised admin key. Add a
rotate_admin(new_admin: Address)function:env.current_contract_address()auth — only the current admin can rotatenew_adminis not the zero address and is not the same as the current admin — returnInvalidAdminAddressfor either case("admin", "rotated")event with the old admin address, new admin address, andenv.ledger().timestamp()Vec<(Address, Address, u64)>of(old_admin, new_admin, timestamp)triples — so the full admin ownership chain is reconstructable from contract state without relying on events aloneThe rotation is immediate with no time-lock. Document this explicitly in the function's doc comment.
validate_hex_hash— Shared Validation HelperCreate
contracts/anonvote/src/validation.rs(new file) with:This function is called from
record_ballot,record_token,record_vote, andrecord_result. Theerrorparameter allows the caller to specify which error variant to return —InvalidBallotIdHashorInvalidResultHash— so the error is semantically correct at the call site.Test Suite —
contracts/anonvote/src/test.rsThe current test suite is minimal. It must be expanded to cover every validation path, every error variant, and every state transition. Tests run
in the Soroban native test environment — no network required.
Initialisation tests
test_initialize_sets_admin— admin address is stored after first calltest_initialize_twice_returns_already_initialized— second call returns errortest_initialize_zero_address_returns_invalid_admin— zero address rejectedBallot registration tests
test_record_ballot_success— ballot registered, event emittedtest_record_ballot_invalid_hash_wrong_length— 63-char string returns errortest_record_ballot_invalid_hash_uppercase— uppercase hex returns errortest_record_ballot_invalid_hash_non_hex— non-hex character returns errortest_record_ballot_duplicate_returns_already_exists— second call same hash returns errorToken and vote recording tests
test_record_token_success— counter incremented after valid ballot registeredtest_record_token_ballot_not_found— unregistered ballot returns errortest_record_token_after_finalised_returns_error— increment after result set returns errortest_record_vote_success— counter incrementedtest_record_vote_ballot_not_found— unregistered ballot returns errortest_record_vote_after_finalised_returns_error— increment after result set returns errorCounter overflow tests
test_counter_overflow_at_max— counter at u32::MAX returns CounterOverflowtest_counter_overflow_does_not_panic— other ballots still callable after one overflowstest_counter_overflow_emits_event— overflow event emitted with correct dataResult recording tests
test_record_result_success— result hash stored, event emittedtest_record_result_invalid_hash— non-hex result hash returns InvalidResultHashtest_record_result_twice_returns_already_finalised— second call returns errortest_record_result_ballot_not_found— unregistered ballot returns errorAdmin rotation tests
test_rotate_admin_success— new admin stored, event emitted, history updatedtest_rotate_admin_same_address_returns_invalid— same address returns errortest_rotate_admin_zero_address_returns_invalid— zero address returns errortest_rotate_admin_unauthorized— non-admin caller returns Unauthorizedtest_rotate_admin_history_grows— two rotations produce two history entriesConsistency tests
test_is_consistent_true— equal token and vote counts returns truetest_is_consistent_false— unequal counts returns falsetest_is_consistent_nonexistent_ballot— returns false, not errorRelevant Files
New files to create:
contracts/anonvote/src/errors.rscontracts/anonvote/src/validation.rsSTABILITY.mdExisting files to modify:
contracts/anonvote/src/lib.rscontracts/anonvote/src/test.rscontracts/anonvote/Cargo.toml— ensurecontracterrorfeature is enabledAcceptance Criteria
ContractErrorenum has 10 variants with stable discriminant values documented inSTABILITY.mdinitializecannot be called twice — second call returnsAlreadyInitializedvalidate_hex_hashrejects non-64-char strings, uppercase hex, and non-hex charactersrecord_ballotrejects duplicateballot_id_hashvaluesrecord_tokenandrecord_votereturnBallotNotFoundfor unregistered ballotsrecord_tokenandrecord_votereturnBallotAlreadyFinalisedafterrecord_resultis calledu32::MAXreturnsCounterOverflowand emits an event without panickingrecord_resultis callable exactly once per ballotrotate_adminimplemented with auth guard, validation, event, and historycargo testcargo build --target wasm32-unknown-unknown --releaseproduces a clean WASM binary with no warningsNote for Contributors
The discriminant values in
ContractErrormust never change after this issue is merged. A consumer parsing error codes from Stellar transaction results depends on the numeric value, not the variant name. WriteSTABILITY.mdbefore writing a single error variant — it forces you to think about the stability contract before committing to a numbering scheme.The
validate_hex_hashhelper must reject uppercase hex. This is not a style preference —hashIdentifierfrom@anonvote/cryptoalways outputs lowercase and a ballot registered withballot_id_hashin uppercase would never match a query using a correctly-produced hash. An uppercase hash accepted by the contract creates an unreachable ballot record that silently breaks all subsequent operations for that ballot.