diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index b57917b..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,82 +0,0 @@
-name: CI
-
-on:
- pull_request:
- branches: [main]
-
-jobs:
- contract:
- name: Rust — build & test
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Install Rust stable
- uses: dtolnay/rust-toolchain@stable
- with:
- targets: wasm32-unknown-unknown
-
- - name: Cache Cargo registry & target
- uses: actions/cache@v4
- with:
- path: |
- ~/.cargo/registry
- ~/.cargo/git
- anonvote/target
- key: cargo-${{ runner.os }}-${{ hashFiles('anonvote/Cargo.lock') }}
- restore-keys: |
- cargo-${{ runner.os }}-
-
- - name: Build WASM (release)
- working-directory: anonvote
- run: cargo build --target wasm32-unknown-unknown --release
-
- - name: Verify WASM output exists and is non-empty
- working-directory: anonvote
- run: |
- WASM=target/wasm32-unknown-unknown/release/anonvote.wasm
- if [[ ! -f "$WASM" ]]; then
- echo "Error: $WASM not found after build"
- exit 1
- fi
- SIZE=$(wc -c < "$WASM")
- if [[ "$SIZE" -eq 0 ]]; then
- echo "Error: $WASM is empty"
- exit 1
- fi
- echo "WASM OK — ${SIZE} bytes at $WASM"
-
- - name: Run cargo tests
- working-directory: anonvote
- run: cargo test
-
- service:
- name: TypeScript — npm test
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set up Node.js 20
- uses: actions/setup-node@v4
- with:
- node-version: "20"
-
- - name: Cache npm modules
- uses: actions/cache@v4
- with:
- path: service/node_modules
- key: npm-${{ runner.os }}-${{ hashFiles('service/package-lock.json') }}
- restore-keys: |
- npm-${{ runner.os }}-
-
- - name: Install dependencies
- working-directory: service
- run: npm install
-
- - name: Run TypeScript tests
- working-directory: service
- run: npm test
diff --git a/STABILITY.md b/STABILITY.md
new file mode 100644
index 0000000..aa2486e
--- /dev/null
+++ b/STABILITY.md
@@ -0,0 +1,89 @@
+# Stability Guarantees
+
+## ContractError Discriminant Values
+
+The `ContractError` enum 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 |
+|--------------------------|-------|------------------------------------------------|
+| AdminUnauthorized | 1 | caller is not the admin |
+| AlreadyInitialized | 2 | initialize called after contract is set up |
+| NotInitialized | 3 | write operation before initialize |
+| BallotNotFound | 4 | write op on unregistered ballot |
+| BallotAlreadyExists | 5 | record_ballot with existing ballot hash |
+| ResultAlreadyPublished | 6 | different result hash already published |
+| CounterOverflow | 7 | token/vote counter exceeds u32::MAX |
+| InvalidBallotHash | 8 | ballot_id_hash is empty |
+| UpgradeAlreadyScheduled | 9 | upgrade already pending |
+| NoUpgradeScheduled | 10 | upgrade requested but none scheduled |
+| TimeLockNotExpired | 11 | upgrade time lock still active |
+| BallotExpired | 12 | ballot has been expired |
+| ContractPaused | 13 | write operation while contract is paused |
+| LimitExceeded | 14 | token/vote count hits ballot limit |
+| InvalidApprovalConfig | 15 | M-of-N config is invalid |
+| DuplicateApprover | 16 | duplicate address in approver list |
+| ApproverUnauthorized | 17 | address is not a configured approver |
+| OperationNotFound | 18 | operation_id does not exist |
+| OperationAlreadyApproved | 19 | approver already approved this operation |
+| OperationNotPending | 20 | operation is not in Pending status |
+| OperationExpired | 21 | approval window has passed |
+| SameAdmin | 22 | rotation target equals current admin |
+| InvalidBallotIdHash | 23 | ballot_id_hash is not valid 64-char hex |
+| InvalidResultHash | 24 | result_hash is not valid 64-char hex |
+| InvalidAdminAddress | 25 | initialize/rotate target is zero address |
+
+## Storage Key Stability
+
+Storage keys are derived from the `DataKey` enum variants. Variant names and
+their associated tuple types must not be changed or reordered after deployment.
+
+| Variant | Type | Location |
+|----------------------|-------------------|------------|
+| Admin | `Address` | instance |
+| InitializedAt | `u64` | instance |
+| IsPaused | `bool` | instance |
+| Approvers | `Vec
` | instance |
+| ApprovalThreshold | `u32` | instance |
+| OperationNonce | `u64` | instance |
+| Operation(id) | `PendingOperation`| persistent |
+| Approval(id, addr) | `bool` | persistent |
+| OperationApprover(id, addr) | `bool` | persistent |
+| TokensIssued(hash) | `u32` | persistent |
+| VotesCast(hash) | `u32` | persistent |
+| ResultHash(hash) | `String` | persistent |
+| BallotMetadata(hash) | `BallotMetadata` | persistent |
+| BallotExpired(hash) | `bool` | persistent |
+| PendingUpgrade | `PendingUpgrade` | instance |
+| RotationHistory | `Vec` | persistent |
+
+## Event Topics
+
+Events published by the contract use fixed two-symbol topics. Adding new topics
+is safe; changing or removing existing ones is breaking for off-chain indexers.
+
+| Topic | Emitted By |
+|-------------------------------|------------------------------|
+| `("govern", "cfg_appr")` | configure_approval_threshold |
+| `("govern", "op_create")` | create_operation |
+| `("govern", "approved")` | approve_operation |
+| `("govern", "op_exec")` | approve_operation (M-of-N reached) |
+| `("govern", "op_cancel")` | cancel_operation |
+| `("audit", "blt_crtd")` | record_ballot, record_ballots_batch |
+| `("audit", "tok_issd")` | record_token |
+| `("audit", "vote_cast")` | record_vote |
+| `("audit", "res_pub")` | execute_operation (result) |
+| `("audit", "exp_adm")` | expire_ballot |
+| `("audit", "paused")` | execute_operation (pause) |
+| `("audit", "upg_schd")` | execute_operation (upgrade) |
+| `("audit", "upg_cncl")` | cancel_upgrade |
+| `("audit", "upg_excd")` | execute_upgrade |
+| `("audit", "resumed")` | resume_contract |
+| `("admin", "rotated")` | execute_operation (rotation) |
+| `("ballot",)` (with `BallotEvent` payload) | record_ballot, record_token, record_vote, execute_operation |
+| `("init", "invalid")` | verify_initialized |
+| `("counter", "ovrflw")` | record_token / record_vote on u32::MAX overflow |
diff --git a/contracts/anonvote/src/lib.rs b/contracts/anonvote/src/lib.rs
index dde822c..1f5aeaf 100644
--- a/contracts/anonvote/src/lib.rs
+++ b/contracts/anonvote/src/lib.rs
@@ -5,10 +5,13 @@
#![no_std]
+mod validation;
+
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, symbol_short, Address, Bytes, BytesN, Env,
String, Symbol, Vec,
};
+use validation::validate_hex_hash;
const APPROVAL_EXPIRATION_SECONDS: u64 = 7 * 24 * 60 * 60;
const UPGRADE_TIME_LOCK_SECONDS: u64 = 48 * 60 * 60;
@@ -41,6 +44,7 @@ pub enum ContractError {
SameAdmin = 22,
InvalidBallotIdHash = 23,
InvalidResultHash = 24,
+ InvalidAdminAddress = 25,
}
#[contracttype]
@@ -171,6 +175,7 @@ pub struct PendingOperation {
#[derive(Clone)]
pub enum DataKey {
Admin,
+ Initialized,
InitializedAt,
IsPaused,
Approvers,
@@ -196,12 +201,21 @@ 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);
}
+ if admin
+ == Address::from_string(&String::from_str(
+ &env,
+ "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ ))
+ {
+ return Err(ContractError::InvalidAdminAddress);
+ }
let mut approvers = Vec::new(&env);
approvers.push_back(admin.clone());
+ env.storage().instance().set(&DataKey::Initialized, &true);
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage()
.instance()
@@ -567,6 +581,7 @@ impl AnonVoteContract {
caller: Address,
ballot_id_hash: String,
) -> Result<(), ContractError> {
+ validate_hex_hash(&ballot_id_hash, ContractError::InvalidBallotIdHash)?;
caller.require_auth();
Self::require_not_paused(&env)?;
Self::require_admin(&env, &caller)?;
@@ -577,7 +592,13 @@ impl AnonVoteContract {
if count >= metadata.limits.max_tokens {
return Err(ContractError::LimitExceeded);
}
- let new_count = count.checked_add(1).ok_or(ContractError::CounterOverflow)?;
+ let new_count = count.checked_add(1).ok_or_else(|| {
+ env.events().publish(
+ (symbol_short!("counter"), symbol_short!("ovrflw")),
+ (ballot_id_hash.clone(), count),
+ );
+ ContractError::CounterOverflow
+ })?;
env.storage().persistent().set(&key, &new_count);
env.events().publish(
(symbol_short!("audit"), symbol_short!("tok_issd")),
@@ -595,6 +616,7 @@ impl AnonVoteContract {
caller: Address,
ballot_id_hash: String,
) -> Result<(), ContractError> {
+ validate_hex_hash(&ballot_id_hash, ContractError::InvalidBallotIdHash)?;
caller.require_auth();
Self::require_not_paused(&env)?;
Self::require_admin(&env, &caller)?;
@@ -605,7 +627,13 @@ impl AnonVoteContract {
if count >= metadata.limits.max_votes {
return Err(ContractError::LimitExceeded);
}
- let new_count = count.checked_add(1).ok_or(ContractError::CounterOverflow)?;
+ let new_count = count.checked_add(1).ok_or_else(|| {
+ env.events().publish(
+ (symbol_short!("counter"), symbol_short!("ovrflw")),
+ (ballot_id_hash.clone(), count),
+ );
+ ContractError::CounterOverflow
+ })?;
env.storage().persistent().set(&key, &new_count);
env.events().publish(
(symbol_short!("audit"), symbol_short!("vote_cast")),
diff --git a/contracts/anonvote/src/validation.rs b/contracts/anonvote/src/validation.rs
new file mode 100644
index 0000000..61f054c
--- /dev/null
+++ b/contracts/anonvote/src/validation.rs
@@ -0,0 +1,21 @@
+use soroban_sdk::String;
+
+use crate::ContractError;
+
+pub fn validate_hex_hash(
+ 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(())
+}