Skip to content

Contract validation and error handling #90

Description

@Just-Bamford

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 validationballot_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

  • ContractError enum has 10 variants with stable discriminant values documented in STABILITY.md
  • initialize cannot be called twice — second call returns AlreadyInitialized
  • validate_hex_hash rejects non-64-char strings, uppercase hex, and non-hex characters
  • record_ballot rejects duplicate ballot_id_hash values
  • record_token and record_vote return BallotNotFound for unregistered ballots
  • record_token and record_vote return BallotAlreadyFinalised after record_result is called
  • Counter overflow at u32::MAX returns CounterOverflow and emits an event without panicking
  • record_result is callable exactly once per ballot
  • rotate_admin implemented with auth guard, validation, event, and history
  • All 25 test cases pass with cargo test
  • cargo build --target wasm32-unknown-unknown --release produces a clean WASM binary with no warnings

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.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26enhancementNew feature or requestspikeissue requiring deep engineering work across multiple layers.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions