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(()) +}