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
7 changes: 1 addition & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ clean:

fmt:
cargo fmt --all -- --check
.PHONY: fmt test check clippy all

fmt:
cargo fmt --all --check

test:
cargo test --workspace
Expand All @@ -47,5 +43,4 @@ wasm_size: optimize
check_codeowners:
bash scripts/check_codeowners.sh

all: fmt check clippy test test_scripts wasm_size check_codeowners
all: fmt check clippy test
all: fmt check clippy test test_scripts check_codeowners
5 changes: 4 additions & 1 deletion bettapay_common/src/error_codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ pub const OPERATION_ALREADY_SCHEDULED: u32 = 12;
pub const INVALID_WASM_INTERFACE: u32 = 13;
/// The provided multisig threshold is invalid.
pub const INVALID_THRESHOLD: u32 = 14;
/// A recovery operation is already pending; a second one cannot be initiated.
pub const RECOVERY_ALREADY_PENDING: u32 = 15;
/// `pause` was called while the contract was already paused.
pub const ALREADY_PAUSED: u32 = 15;
pub const ALREADY_PAUSED: u32 = 17;
/// `unpause` was called while the contract was already unpaused.
pub const ALREADY_UNPAUSED: u32 = 16;

Expand All @@ -75,6 +77,7 @@ pub const SHARED_CODES: &[(&str, u32)] = &[
("OperationAlreadyScheduled", OPERATION_ALREADY_SCHEDULED),
("InvalidWasmInterface", INVALID_WASM_INTERFACE),
("InvalidThreshold", INVALID_THRESHOLD),
("RecoveryAlreadyPending", RECOVERY_ALREADY_PENDING),
("AlreadyPaused", ALREADY_PAUSED),
("AlreadyUnpaused", ALREADY_UNPAUSED),
];
Expand Down
28 changes: 22 additions & 6 deletions bettapay_common/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,20 @@ mod compatibility_tests {
fn common_data_key_encoding_matches_legacy() {
let env = Env::default();
let mut map: soroban_sdk::Map<Val, u32> = soroban_sdk::Map::new(&env);

map.set(LegacyDataKey::RecoveryAddress.into_val(&env), 1u32);
assert_eq!(map.get(CommonDataKey::RecoveryAddress.into_val(&env)), Some(1u32), "RecoveryAddress encoding mismatch");
assert_eq!(
map.get(CommonDataKey::RecoveryAddress.into_val(&env)),
Some(1u32),
"RecoveryAddress encoding mismatch"
);

map.set(LegacyDataKey::PendingRecovery.into_val(&env), 2u32);
assert_eq!(map.get(CommonDataKey::PendingRecovery.into_val(&env)), Some(2u32), "PendingRecovery encoding mismatch");
assert_eq!(
map.get(CommonDataKey::PendingRecovery.into_val(&env)),
Some(2u32),
"PendingRecovery encoding mismatch"
);

// Note: Paused was also a unit variant in the legacy DataKey.
// We'll just define another legacy enum for it or reuse the same.
Expand All @@ -223,11 +231,19 @@ mod compatibility_tests {
Paused,
SystemParam(soroban_sdk::Symbol),
}

map.set(LegacyDataKey2::Paused.into_val(&env), 3u32);
assert_eq!(map.get(CommonDataKey::Paused.into_val(&env)), Some(3u32), "Paused encoding mismatch");
assert_eq!(
map.get(CommonDataKey::Paused.into_val(&env)),
Some(3u32),
"Paused encoding mismatch"
);

map.set(LegacyDataKey::Threshold.into_val(&env), 4u32);
assert_eq!(map.get(CommonDataKey::Threshold.into_val(&env)), Some(4u32), "Threshold encoding mismatch");
assert_eq!(
map.get(CommonDataKey::Threshold.into_val(&env)),
Some(4u32),
"Threshold encoding mismatch"
);
}
}
5 changes: 3 additions & 2 deletions governance_contract/src/anchor_no_event_error_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ fn change_threshold_emits_no_event_when_insufficient_signatures() {
let recovery = Address::generate(&env);
let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
client.init(&admins, &2, &recovery);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &2, &recovery);

let single_signer = vec![&env, a1.clone()];
let prev = env.events().all().len();
Expand All @@ -241,7 +242,7 @@ fn change_threshold_emits_no_event_when_insufficient_signatures() {
// ---------------------------------------------------------------------------

#[test]
#[should_panic(expected = "Error(Contract, #15)")]
#[should_panic(expected = "Error(Contract, #17)")]
fn pause_emits_no_event_when_already_paused() {
let (env, client, admins) = setup();
client.pause(&admins);
Expand Down
100 changes: 73 additions & 27 deletions governance_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,11 +277,13 @@ pub enum GovernanceError {
InvalidWasmInterface = 13,
/// The provided multisig threshold is invalid.
InvalidThreshold = 14,
/// A recovery is already pending; initiate_recovery cannot overwrite it.
RecoveryAlreadyPending = 15,
/// The anchor for the specified asset was not found.
AnchorMissing = 200,
InvalidParamValue = 201,
/// `pause` was called while the contract was already paused.
AlreadyPaused = 15,
AlreadyPaused = 17,
/// `unpause` was called while the contract was already unpaused.
AlreadyUnpaused = 16,
/// The new admin set and threshold are identical to the current ones.
Expand All @@ -302,6 +304,9 @@ const _: () = {
assert!(GovernanceError::RecoveryDelayActive as u32 == error_codes::RECOVERY_DELAY_ACTIVE);
assert!(GovernanceError::InvalidWasmInterface as u32 == error_codes::INVALID_WASM_INTERFACE);
assert!(GovernanceError::InvalidThreshold as u32 == error_codes::INVALID_THRESHOLD);
assert!(
GovernanceError::RecoveryAlreadyPending as u32 == error_codes::RECOVERY_ALREADY_PENDING
);
assert!(GovernanceError::AnchorMissing as u32 >= error_codes::GOVERNANCE_RANGE_START);
assert!(GovernanceError::InvalidParamValue as u32 >= error_codes::GOVERNANCE_RANGE_START);
assert!(GovernanceError::AlreadyPaused as u32 == error_codes::ALREADY_PAUSED);
Expand Down Expand Up @@ -339,7 +344,13 @@ impl GovernanceContract {
/// # Errors
///
/// Panics with `GovernanceError::AlreadyInitialized` if already initialised.
pub fn init(env: Env, deployer: Address, admins: Vec<Address>, threshold: u32, recovery_address: Address) {
pub fn init(
env: Env,
deployer: Address,
admins: Vec<Address>,
threshold: u32,
recovery_address: Address,
) {
if env.storage().instance().has(&DataKey::Admin) {
panic_with_error!(&env, GovernanceError::AlreadyInitialized);
}
Expand Down Expand Up @@ -392,11 +403,7 @@ impl GovernanceContract {
pub fn update_recovery_address(env: Env, signers: Vec<Address>, new_recovery: Address) {
verify_admin_auth(&env, &signers, read_threshold(&env));
let admin = signers.get(0).unwrap();
assert_not_zero(
&env,
&new_recovery,
GovernanceError::InvalidRecoveryAddress,
);
assert_not_zero(&env, &new_recovery, GovernanceError::InvalidRecoveryAddress);
env.storage()
.instance()
.set(&CommonDataKey::RecoveryAddress, &new_recovery);
Expand Down Expand Up @@ -471,6 +478,14 @@ impl GovernanceContract {
recovery_address.require_auth();
assert_not_zero(&env, &new_admin, GovernanceError::InvalidAdmin);

if env
.storage()
.instance()
.has(&CommonDataKey::PendingRecovery)
{
panic_with_error!(&env, GovernanceError::RecoveryAlreadyPending);
}

let pending = PendingRecovery {
new_admin: new_admin.clone(),
execute_after: env.ledger().timestamp() + RECOVERY_DELAY_SECONDS,
Expand Down Expand Up @@ -512,7 +527,9 @@ impl GovernanceContract {

let new_admins = soroban_sdk::vec![&env, pending.new_admin.clone()];
env.storage().instance().set(&DataKey::Admin, &new_admins);
env.storage().instance().set(&CommonDataKey::Threshold, &1u32);
env.storage()
.instance()
.set(&CommonDataKey::Threshold, &1u32);
env.storage()
.instance()
.remove(&CommonDataKey::PendingRecovery);
Expand Down Expand Up @@ -706,7 +723,9 @@ impl GovernanceContract {
let key = DataKey::Anchor(asset.clone());
let old_anchor: Option<Address> = env.storage().persistent().get(&key);
env.storage().persistent().set(&key, &anchor.clone());
env.storage().persistent().extend_ttl(&key, ANCHOR_TTL_THRESHOLD, ANCHOR_TTL_BUMP);
env.storage()
.persistent()
.extend_ttl(&key, ANCHOR_TTL_THRESHOLD, ANCHOR_TTL_BUMP);
env.events().publish(
(Symbol::new(&env, events::ANCHOR_UPSERTED_EVENT), asset),
(old_anchor, anchor),
Expand Down Expand Up @@ -908,8 +927,8 @@ mod real_auth_tests;
mod tests {
use super::*;
use proptest::prelude::*;
use soroban_sdk::testutils::{Address as _, Events};
use soroban_sdk::testutils::storage::Persistent;
use soroban_sdk::testutils::{Address as _, Events};
use soroban_sdk::{vec, Bytes, FromVal, String};

fn setup() -> (
Expand All @@ -927,7 +946,7 @@ mod tests {
let recovery_address = Address::generate(&env);
let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
let deployer = Address::generate(&env);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &2, &recovery_address);
(env, client, admins, recovery_address)
}
Expand Down Expand Up @@ -1015,7 +1034,10 @@ mod tests {
let bad_hash = upload_test_wasm(&env); // empty wasm — no supports_interface

let result = client.try_upgrade(&admins, &bad_hash);
assert!(result.is_err(), "upgrade with non-conforming wasm must be rejected");
assert!(
result.is_err(),
"upgrade with non-conforming wasm must be rejected"
);

// Contract is intact after the failed upgrade.
let live_client = GovernanceContractClient::new(&env, &client.address);
Expand All @@ -1025,8 +1047,8 @@ mod tests {
#[test]
#[should_panic(expected = "Error(Contract, #1)")]
fn governance_rejects_double_initialization() {
let (_env, client, admins, recovery) = setup();
let deployer = Address::generate(&env);
let (env, client, admins, recovery) = setup();
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &2, &recovery);
}

Expand All @@ -1039,8 +1061,8 @@ mod tests {
let recovery = Address::generate(&env);
let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
let deployer = Address::generate(&env);
client.init(&deployer, &vec![env, admin], &0, &recovery);
let deployer = Address::generate(&env);
client.init(&deployer, &vec![&env, admin], &0, &recovery);
}

#[test]
Expand Down Expand Up @@ -1175,7 +1197,7 @@ mod tests {
#[should_panic(expected = "Error(Contract, #4)")]
fn set_fee_config_rejects_fees_exceeding_ceiling() {
let (_env, client, admins, _recovery) = setup();

// Sum exceeds BPS_DENOMINATOR
let cfg = FeeConfig {
platform_fee_bps: 5_000,
Expand All @@ -1189,7 +1211,7 @@ mod tests {
#[should_panic(expected = "Error(Contract, #4)")]
fn set_fee_config_rejects_individual_fee_exceeding_max() {
let (_env, client, admins, _recovery) = setup();

// Individual fee exceeds MAX_FEE_BPS (governance trust root)
let cfg = FeeConfig {
platform_fee_bps: 5_001,
Expand Down Expand Up @@ -1436,7 +1458,8 @@ mod tests {
let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);

let result = client.try_init(&admins, &threshold, &recovery);
let deployer = Address::generate(&env);
let result = client.try_init(&deployer, &admins, &threshold, &recovery);
if threshold == 0 || threshold > admin_count {
prop_assert!(result.is_err());
} else {
Expand Down Expand Up @@ -1464,8 +1487,8 @@ mod tests {
let client = GovernanceContractClient::new(&env, &contract_id);

assert!(!client.is_initialized());
let deployer = Address::generate(&env);
client.init(&deployer, &vec![env, admin.clone()], &1, &recovery_address);
let deployer = Address::generate(&env);
client.init(&deployer, &vec![&env, admin.clone()], &1, &recovery_address);
assert!(client.is_initialized());
}

Expand All @@ -1480,7 +1503,7 @@ mod tests {
let client = GovernanceContractClient::new(&env, &contract_id);

let admins = vec![&env, admin.clone()];
let deployer = Address::generate(&env);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &1, &recovery_address);
assert!(client.is_initialized());
assert_eq!(client.get_admin(), admins);
Expand Down Expand Up @@ -1545,7 +1568,7 @@ mod tests {

let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
let deployer = Address::generate(&env);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &1, &recovery);

assert_eq!(client.get_threshold(), 1);
Expand All @@ -1569,7 +1592,7 @@ mod tests {

let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
let deployer = Address::generate(&env);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &1, &recovery);

// Current threshold is 1, needs 2 signatures for change_threshold, but only 1 provided.
Expand All @@ -1592,7 +1615,7 @@ mod tests {

let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
let deployer = Address::generate(&env);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &1, &recovery);

// Threshold 3 > admins.len() 2 — must fail with InvalidThreshold, not auth.
Expand All @@ -1613,7 +1636,7 @@ mod tests {

let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
let deployer = Address::generate(&env);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &2, &recovery);

client.change_threshold(&admins, &0);
Expand Down Expand Up @@ -1668,6 +1691,29 @@ mod tests {
);
}

#[test]
#[should_panic(expected = "Error(Contract, #15)")]
fn initiate_recovery_rejects_overwrite_while_pending() {
let env = Env::default();
env.mock_all_auths();
let admin1 = Address::generate(&env);
let admin2 = Address::generate(&env);
let admins = vec![&env, admin1, admin2];
let recovery_address = Address::generate(&env);
let first_target = Address::generate(&env);
let second_target = Address::generate(&env);

let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &2, &recovery_address);

client.initiate_recovery(&first_target);

// Second initiation must be rejected — a recovery is already pending.
client.initiate_recovery(&second_target);
}

// -----------------------------------------------------------------------
// InvalidWasmInterface: upgrade flow enforces supports_interface(1)
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -1825,7 +1871,7 @@ mod tests {
let recovery_address = Address::generate(&env);
let contract_id = env.register_contract(None, GovernanceContract);
let client = GovernanceContractClient::new(&env, &contract_id);
let deployer = Address::generate(&env);
let deployer = Address::generate(&env);
client.init(&deployer, &admins, &1, &recovery_address);

client.pause(&admins);
Expand Down
Loading
Loading