Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sanitize administrative inputs and validate input ranges (#94, #97)
- Fix compile errors and missing `testutils::Address` import after fee changes (#123)
- Add end-to-end happy-path integration test for #137 (#188)
- Validate weight threshold in `set_weight_threshold` with identical bounds as `validate_migration` (`1..=MAX_WEIGHT_THRESHOLD`), preventing zero-threshold consensus bypass and storage poisoning (#306)

### Security
- CI build/test workflow and dependency security scanning added (#176)
- Consensus threshold validation in `set_weight_threshold` prevents writing zero threshold that disables weighted voting and poisons future migrations (#306)

---

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ client.grant_role(&admin, &admin, &Role::EmergencyManager);
client.grant_role(&admin, &admin, &Role::TreasuryManager);
```

### Configure protocol parameters

```rust
// Requires Role::ConfigManager. Validates threshold is in 1..=MAX_WEIGHT_THRESHOLD
client.set_weight_threshold(&admin, &500u64);

// Set fee basis points (up to 1000 bps = 10%)
client.set_fee_bps(&admin, &25u32);
```

### Add a guardian and set reputation

```rust
Expand Down
14 changes: 14 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,17 @@

Migration for #165 is complete. `Description.md` intentionally remains at the repo root because it is consumed directly by the GrantFox registry; see the note at the top of that file.

---

# Issue #306: Validate the threshold in set_weight_threshold with the same bounds validate_migration enforces

## Progress
- [x] Integrate `validate_weight_threshold(threshold)?` into `set_weight_threshold` before state storage write
- [x] Document `InvalidAmount` and `InvalidRange` in entrypoint doc comments
- [x] Add unit tests for `validate_weight_threshold` in `src/validation.rs`
- [x] Add property-based tests in `tests/property_tests.rs` for `set_weight_threshold` and `migrate::validate_migration` equivalence
- [x] Add test coverage across core test files (acceptance criteria 1 & 2 verified)
- [x] Update documentation (README, CHANGELOG, etc.) across 15+ files
- [x] Open new branch and push changes to remote


21 changes: 21 additions & 0 deletions docs/history/IMPLEMENTATION_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,24 @@ Added 8 comprehensive tests:

## Branch
`feat/69-treasury-timelock`

---

# Issue #306: Validate Weight Threshold Setter Implementation Summary

## Overview
Prevented `set_weight_threshold` from writing thresholds that `migrate::validate_migration` pre-flight is designed to reject (`0` and `> MAX_WEIGHT_THRESHOLD`), ensuring live state cannot bypass consensus voting or poison future migrations.

## Changes Made
1. **Live Setter Validation (`src/contracts/proxy_entry/entry_config.rs`)**:
- Integrated `crate::validation::validate_weight_threshold(threshold)?` before storage write.
- Documented `InvalidAmount` (for 0) and `InvalidRange` (for > `MAX_WEIGHT_THRESHOLD`) in doc comments.
2. **Validation Helpers & Documentation (`src/validation.rs`, `src/limits.rs`)**:
- Added unit tests for `validate_weight_threshold`.
- Updated `MAX_WEIGHT_THRESHOLD` documentation and public visibility.
3. **Property Testing (`tests/property_tests.rs`)**:
- Property tests proving any threshold accepted by `set_weight_threshold` is accepted by `validate_migration`.
- Property tests verifying rejection equivalence across the entire `u64` domain.
4. **Integration & Safety Coverage across 15+ Files**:
- `tests/test.rs`, `tests/safety_invariants.rs`, `tests/consensus.rs`, `tests/rbac_tests.rs`, `tests/init.rs`, `tests/gas_budget.rs`, `tests/circuit_breaker_dos.rs`, `tests/zero_address_validation.rs`, `tests/upgrade.rs`, `tests/consensus_delegation.rs`, `tests/integration.rs`, etc.

6 changes: 6 additions & 0 deletions docs/history/VERIFICATION_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,9 @@ The `CONSENSUS_THRESHOLD` invariant is formally proved:
> **No execution path in `apply_vote` can set `is_done = true` unless `total_weight_accrued >= threshold`.**

This proof, combined with the exhaustive unit test suite, provides high assurance that the weighted guardian consensus mechanism is correct and cannot be manipulated to resolve tasks below the configured threshold.

### Issue #306: Weight Threshold Setter Validation
In addition to consensus invariant proofs, property-based tests in `tests/property_tests.rs` prove that:
- `set_weight_threshold` and `migrate::validate_migration` enforce 100% equivalent acceptance bounds (`1..=MAX_WEIGHT_THRESHOLD`).
- Invalid inputs (`0` and `> MAX_WEIGHT_THRESHOLD`) are rejected identically with `InvalidAmount` and `InvalidRange`.

16 changes: 16 additions & 0 deletions docs/history/pull_request.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,19 @@ This pull request introduces formal verification to the weighted consensus mecha
- **Local Compilations**: Verified that the contracts and entire test suite compile successfully (`cargo check --tests` finishes with 0 errors).
- **Verification Proofs**: Configured to run automatically in CI.
- **Environment Note**: Local test running (`cargo test`) requires MinGW's `dlltool.exe` on Windows-GNU host environments to compile `backtrace` (a `soroban-sdk` testutils dependency). These checks are fully supported and will run in the CI build containers.

---

# Pull Request: #306 Validate the threshold in set_weight_threshold with the same bounds validate_migration enforces

## Description
Stops the live setter `set_weight_threshold` from writing a threshold that the migration pre-flight is designed to reject.

Previously, `set_weight_threshold` wrote the caller's value straight to storage with no range check, allowing a zero threshold (which makes `total_weight_accrued >= threshold` trivially true on the first vote, silently defeating weighted consensus) or values exceeding `MAX_WEIGHT_THRESHOLD` (poisoning future migrations).

## Key Changes
1. **Live Setter Validation**: Added `crate::validation::validate_weight_threshold(threshold)?` to `set_weight_threshold` in `src/contracts/proxy_entry/entry_config.rs`.
2. **Error Documentation**: Documented `InvalidAmount` and `InvalidRange` errors on `set_weight_threshold`.
3. **Property Testing**: Added proptests in `tests/property_tests.rs` verifying that any threshold accepted by `set_weight_threshold` is unconditionally accepted by `migrate::validate_migration`, and invalid inputs fail with identical error codes.
4. **Comprehensive Test Suite**: Updated and added test coverage across 15+ files verifying boundary conditions, authorization, gas budgets, circuit breaker pause states, and invariants.

10 changes: 10 additions & 0 deletions src/contracts/proxy_entry/entry_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ use soroban_sdk::{contractimpl, Address, Env};

#[contractimpl]
impl VeroContract {
/// Sets the voting weight threshold required for task resolution. Callable
/// by the contract admin or a `ConfigManager` while the contract is not paused.
///
/// # Errors
/// * `InvalidAddress` — the admin address is the zero address or the contract itself.
/// * `ContractPaused` — the contract is paused.
/// * `NotAuthorized` — the caller does not hold the `ConfigManager` role.
/// * `InvalidAmount` — the threshold is 0.
/// * `InvalidRange` — the threshold exceeds `MAX_WEIGHT_THRESHOLD`.
pub fn set_weight_threshold(
env: Env,
admin: Address,
Expand All @@ -22,6 +31,7 @@ impl VeroContract {
validate_address(&env, &admin)?;
circuit_breaker::require_not_paused(&env)?;
crate::contracts::rbac::require_role(&env, &admin, crate::types::Role::ConfigManager)?;
crate::validation::validate_weight_threshold(threshold)?;
env.storage()
.instance()
.set(&DataKey::WeightThreshold, &threshold);
Expand Down
2 changes: 1 addition & 1 deletion src/contracts/proxy_entry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
//! * [`entry_circuit_breaker`] — pause & failure reporting
//! * [`entry_guardians`] — guardians & reputation
//! * [`entry_tokens`] — token locking & emergency recovery
//! * [`entry_config`] — fee / treasury / threshold config
//! * [`entry_config`] — fee / treasury / threshold config (bounded and validated against migration pre-flight)
//! * [`entry_tasks`] — task registration & voting
//! * [`entry_rewards`] — reward (drips) streams
//! * [`entry_upgrades`] — immediate & multi-sig upgrades
Expand Down
14 changes: 9 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,22 @@ pub mod events;
/// Instruction-cost estimates for public entry points.
pub mod gas;
mod guardian;
mod limits;
mod migrate;
/// Protocol-wide limit constants.
pub mod limits;
/// Storage migration and atomic pre-flight validation.
pub mod migrate;
mod reentrancy;
mod reputation;
mod storage;
mod task;
mod timelock;
mod types;
mod utils;
mod validation;
/// Parameter and address validation helpers.
pub mod validation;

pub use contracts::proxy_entry::{VeroContract, VeroContractClient};
pub use limits::MAX_WEIGHT_THRESHOLD;
pub use types::{
BatchCall, ContractError, DataKey, GuardianEntry, Operation, RewardStream, Role, Snapshot,
SnapshotMeta, Task,
Expand All @@ -47,8 +51,8 @@ pub use storage::ARCHIVE_AFTER_SECONDS;
pub use utils::address::ZERO_ADDRESS_STR;

/// Default weight threshold: a task requires at least 300 cumulative
/// reputation weight to be resolved. This can be overridden by the
/// admin via `set_weight_threshold`.
/// reputation weight to be resolved. This can be overridden by a
/// `ConfigManager` via `set_weight_threshold` (bounded to `1..=MAX_WEIGHT_THRESHOLD`).
pub const DEFAULT_WEIGHT_THRESHOLD: u64 = 300;

/// Type alias for the main `VeroContract` implementation.
Expand Down
19 changes: 10 additions & 9 deletions src/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! `contracts/logic.rs`, `task.rs`, and `validation.rs`.

/// Maximum batch size for a single `register_task` call.
pub(crate) const MAX_REGISTER_TASK_BATCH_SIZE: u32 = 32;
pub const MAX_REGISTER_TASK_BATCH_SIZE: u32 = 32;

/// Maximum number of entries `get_snapshot`/`record_snapshot` will read from
/// any single tracked collection (guardians, tasks, reward streams) before
Expand All @@ -24,25 +24,26 @@ pub(crate) const MAX_REGISTER_TASK_BATCH_SIZE: u32 = 32;
/// `O(limit)` entries per call — not `O(total collection size)` — and stays
/// cheaply invokable well past the point this cap would refuse to build a
/// full snapshot.
pub(crate) const MAX_SNAPSHOT_COLLECTION_SIZE: u32 = 200;
pub const MAX_SNAPSHOT_COLLECTION_SIZE: u32 = 200;

/// Maximum number of entries any paginated snapshot call will return,
/// regardless of the caller-requested `limit`. Keeps a single page call's
/// cost bounded even against a hostile/misconfigured caller.
pub(crate) const MAX_PAGE_LIMIT: u32 = 50;
pub const MAX_PAGE_LIMIT: u32 = 50;

/// Maximum allowed task id (`u64::MAX / 2`), so ids stay comfortably below
/// `u64::MAX` and away from overflow-prone math elsewhere.
pub(crate) const MAX_TASK_ID: u64 = u64::MAX / 2;
pub const MAX_TASK_ID: u64 = u64::MAX / 2;

/// Upper bound for token amounts locked/transferred by the contract.
pub(crate) const MAX_TOKEN_AMOUNT: i128 = i128::MAX / 2;
pub const MAX_TOKEN_AMOUNT: i128 = i128::MAX / 2;

/// Upper bound for the vote-lock threshold, one below `MAX_TOKEN_AMOUNT`.
pub(crate) const MAX_LOCK_THRESHOLD: i128 = MAX_TOKEN_AMOUNT - 1;
pub const MAX_LOCK_THRESHOLD: i128 = MAX_TOKEN_AMOUNT - 1;

/// Upper bound for a single guardian's reputation score.
pub(crate) const MAX_REPUTATION_SCORE: u64 = 1_000_000_000;
pub const MAX_REPUTATION_SCORE: u64 = 1_000_000_000;

/// Upper bound for the cumulative weight required to resolve a task.
pub(crate) const MAX_WEIGHT_THRESHOLD: u64 = 1_000_000_000_000;
/// Upper bound for the cumulative weight required to resolve a task. Enforced by both
/// the live setter (`set_weight_threshold`) and migration pre-flight checks (`validate_migration`).
pub const MAX_WEIGHT_THRESHOLD: u64 = 1_000_000_000_000;
35 changes: 35 additions & 0 deletions src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::limits::{
};
use crate::types::ContractError;

/// Validates that an address is well-formed and is not the contract itself.
pub fn validate_external_address(env: &Env, address: &Address) -> Result<(), ContractError> {
crate::utils::address::validate_address(env, address)?;
if address == &env.current_contract_address() {
Expand All @@ -13,17 +14,20 @@ pub fn validate_external_address(env: &Env, address: &Address) -> Result<(), Con
Ok(())
}

/// Validates that two addresses are not identical.
pub fn validate_distinct_addresses(left: &Address, right: &Address) -> Result<(), ContractError> {
if left == right {
return Err(ContractError::InvalidAddress);
}
Ok(())
}

/// Validates that an admin address is well-formed and not the contract itself.
pub fn validate_admin_address(env: &Env, admin: &Address) -> Result<(), ContractError> {
validate_external_address(env, admin)
}

/// Validates reward stream parameters including drips address, contributor address, and task id.
pub fn validate_reward_stream_config(
env: &Env,
drips_address: &Address,
Expand All @@ -48,6 +52,7 @@ pub fn validate_task_id(task_id: u64) -> Result<(), ContractError> {
Ok(())
}

/// Validates that a token amount is positive and within `MAX_TOKEN_AMOUNT`.
pub fn validate_token_amount(amount: i128) -> Result<(), ContractError> {
if amount <= 0 {
return Err(ContractError::InvalidAmount);
Expand All @@ -58,6 +63,7 @@ pub fn validate_token_amount(amount: i128) -> Result<(), ContractError> {
Ok(())
}

/// Validates that a lock threshold is positive and within `MAX_LOCK_THRESHOLD`.
pub fn validate_lock_threshold(lock_threshold: i128) -> Result<(), ContractError> {
if lock_threshold <= 0 {
return Err(ContractError::InvalidAmount);
Expand All @@ -68,6 +74,7 @@ pub fn validate_lock_threshold(lock_threshold: i128) -> Result<(), ContractError
Ok(())
}

/// Validates that a reputation score is positive and within `MAX_REPUTATION_SCORE`.
pub fn validate_reputation_score(score: u64) -> Result<(), ContractError> {
if score == 0 {
return Err(ContractError::InvalidAmount);
Expand All @@ -78,6 +85,7 @@ pub fn validate_reputation_score(score: u64) -> Result<(), ContractError> {
Ok(())
}

/// Validates that a weight threshold is non-zero and does not exceed `MAX_WEIGHT_THRESHOLD`.
pub fn validate_weight_threshold(threshold: u64) -> Result<(), ContractError> {
if threshold == 0 {
return Err(ContractError::InvalidAmount);
Expand Down Expand Up @@ -110,4 +118,31 @@ mod tests {
Err(ContractError::InvalidConfig)
);
}

#[test]
fn test_validate_weight_threshold_zero_rejected() {
assert_eq!(
validate_weight_threshold(0),
Err(ContractError::InvalidAmount)
);
}

#[test]
fn test_validate_weight_threshold_valid_range() {
assert_eq!(validate_weight_threshold(1), Ok(()));
assert_eq!(validate_weight_threshold(500), Ok(()));
assert_eq!(validate_weight_threshold(MAX_WEIGHT_THRESHOLD), Ok(()));
}

#[test]
fn test_validate_weight_threshold_exceeds_max_rejected() {
assert_eq!(
validate_weight_threshold(MAX_WEIGHT_THRESHOLD + 1),
Err(ContractError::InvalidRange)
);
assert_eq!(
validate_weight_threshold(u64::MAX),
Err(ContractError::InvalidRange)
);
}
}
20 changes: 20 additions & 0 deletions tests/circuit_breaker_dos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,23 @@ fn test_batch_execute_with_record_failure_variant() {

assert_eq!(client.get_failure_count(), 1);
}

/// When the contract is paused, `set_weight_threshold` is rejected with `ContractPaused`.
#[test]
fn test_set_weight_threshold_rejected_while_paused() {
let (_env, admin, client) = setup();

client.grant_role(&admin, &admin, &vero_core_contracts::Role::EmergencyManager);
client.grant_role(&admin, &admin, &vero_core_contracts::Role::ConfigManager);

client.pause(&admin);

assert_eq!(
client.try_set_weight_threshold(&admin, &500),
Err(Ok(ContractError::ContractPaused))
);
assert_eq!(
client.try_set_weight_threshold(&admin, &0),
Err(Ok(ContractError::ContractPaused))
);
}
20 changes: 20 additions & 0 deletions tests/consensus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,23 @@ fn test_consensus_state_default_is_new() {
assert_eq!(via_new.votes, 0);
assert!(!via_new.is_done);
}

#[test]
fn test_consensus_resolves_at_maximum_allowed_threshold() {
use vero_core_contracts::limits::MAX_WEIGHT_THRESHOLD;

let mut state = ConsensusState::new();
apply_vote(&mut state, MAX_WEIGHT_THRESHOLD, MAX_WEIGHT_THRESHOLD).unwrap();
assert!(state.is_done);
assert_eq!(state.total_weight_accrued, MAX_WEIGHT_THRESHOLD);
assert!(resolution_invariant_holds(&state, MAX_WEIGHT_THRESHOLD));
}

#[test]
fn test_consensus_resolves_at_minimum_valid_threshold() {
let mut state = ConsensusState::new();
apply_vote(&mut state, 1, 1).unwrap();
assert!(state.is_done);
assert_eq!(state.total_weight_accrued, 1);
assert!(resolution_invariant_holds(&state, 1));
}
20 changes: 20 additions & 0 deletions tests/consensus_delegation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,23 @@ fn vote_uses_verified_consensus_boundaries() {
client.vote(&guardian, &3);
assert!(client.get_task(&3).unwrap().is_done);
}

#[test]
fn test_vote_respects_dynamically_configured_weight_threshold() {
let (env, _contract_id, admin, token, client) = setup();
client.grant_role(&admin, &admin, &Role::ConfigManager);

// Update threshold from default 300 to 200
client.set_weight_threshold(&admin, &200);
assert_eq!(client.get_weight_threshold(), 200);

let guardian = add_voter(&env, &client, &admin, &token);
client.set_reputation(&admin, &guardian, &200);

client.register_task(&admin, &10, &1);
client.vote(&guardian, &10);

let task = client.get_task(&10).unwrap();
assert!(task.is_done);
assert_eq!(task.total_weight_accrued, 200);
}
Loading
Loading