Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 0 additions & 82 deletions .github/workflows/ci.yml

This file was deleted.

89 changes: 89 additions & 0 deletions STABILITY.md
Original file line number Diff line number Diff line change
@@ -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<Address>` | 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<RotationRecord>` | 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 |
34 changes: 31 additions & 3 deletions contracts/anonvote/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +44,7 @@ pub enum ContractError {
SameAdmin = 22,
InvalidBallotIdHash = 23,
InvalidResultHash = 24,
InvalidAdminAddress = 25,
}

#[contracttype]
Expand Down Expand Up @@ -171,6 +175,7 @@ pub struct PendingOperation {
#[derive(Clone)]
pub enum DataKey {
Admin,
Initialized,
InitializedAt,
IsPaused,
Approvers,
Expand All @@ -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()
Expand Down Expand Up @@ -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)?;
Expand All @@ -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")),
Expand All @@ -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)?;
Expand All @@ -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")),
Expand Down
21 changes: 21 additions & 0 deletions contracts/anonvote/src/validation.rs
Original file line number Diff line number Diff line change
@@ -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(())
}