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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
93 changes: 93 additions & 0 deletions contracts/vault/src/emergency.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//! Dual-approval flow for critical emergency contract actions.
//!
//! High-impact operations require authorization from **two distinct approvers**
//! configured via `set_emergency_approvers`. The primary initiates a proposal;
//! the secondary confirms and triggers execution.

use soroban_sdk::{contracttype, Address, BytesN, Env};

#[contracttype]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u32)]
pub enum EmergencyActionKind {
Pause = 1,
Unpause = 2,
EmergencyDivest = 3,
ForceUpgrade = 4,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EmergencyProposal {
pub kind: EmergencyActionKind,
/// Pause reason code (`0` = not applicable). See [`PauseReason`].
pub pause_reason_code: u32,
pub divest_amount: Option<i128>,
pub wasm_hash: Option<BytesN<32>>,
pub initiator: Address,
pub confirmed: bool,
pub executed: bool,
}

pub fn read_proposal(env: &Env, id: u32) -> Option<EmergencyProposal> {
env.storage()
.instance()
.get(&crate::DataKey::EmergencyProposal(id))
}

pub fn write_proposal(env: &Env, id: u32, proposal: &EmergencyProposal) {
env.storage()
.instance()
.set(&crate::DataKey::EmergencyProposal(id), proposal);
}

pub fn next_proposal_id(env: &Env) -> u32 {
let nonce: u32 = env
.storage()
.instance()
.get(&crate::DataKey::EmergencyProposalNonce)
.unwrap_or(0);
let next = nonce.checked_add(1).expect("proposal nonce overflow");
env.storage()
.instance()
.set(&crate::DataKey::EmergencyProposalNonce, &next);
next
}

pub fn primary_approver(env: &Env) -> Option<Address> {
env.storage()
.instance()
.get(&crate::DataKey::EmergencyApproverPrimary)
}

pub fn secondary_approver(env: &Env) -> Option<Address> {
env.storage()
.instance()
.get(&crate::DataKey::EmergencyApproverSecondary)
}

pub fn require_distinct_approvers(primary: &Address, secondary: &Address) {
assert!(primary != secondary, "approvers must be distinct");
}

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::testutils::Address as _;

#[test]
fn test_distinct_approvers_required() {
let env = Env::default();
let a = Address::generate(&env);
let b = Address::generate(&env);
require_distinct_approvers(&a, &b);
}

#[test]
#[should_panic(expected = "approvers must be distinct")]
fn test_same_approver_rejected() {
let env = Env::default();
let a = Address::generate(&env);
require_distinct_approvers(&a, &a);
}
}
4 changes: 3 additions & 1 deletion contracts/vault/src/event_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ fn test_pause_unpause_works() {
let vault = YieldVaultClient::new(&env, &vault_id);
vault.initialize(&admin, &usdc.address);

vault.pause();
vault.pause(&PauseReason::Maintenance);
assert!(vault.is_paused());
assert_eq!(vault.pause_reason(), Some(PauseReason::Maintenance));
vault.unpause();
assert!(!vault.is_paused());
assert_eq!(vault.pause_reason(), None);
}

#[test]
Expand Down
127 changes: 127 additions & 0 deletions contracts/vault/src/feature_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#![cfg(test)]

use super::*;
use crate::emergency::EmergencyActionKind;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{token, Address, Env};

fn setup_vault(
e: &Env,
) -> (
YieldVaultClient<'_>,
token::Client<'_>,
token::StellarAssetClient<'_>,
Address,
) {
let admin = Address::generate(e);
let token_admin = Address::generate(e);
let usdc = e
.register_stellar_asset_contract_v2(token_admin.clone())
.address();
let usdc_client = token::Client::new(e, &usdc);
let usdc_sa = token::StellarAssetClient::new(e, &usdc);

let vault_id = e.register(YieldVault, ());
let vault = YieldVaultClient::new(e, &vault_id);
vault.initialize(&admin, &usdc);

(vault, usdc_client, usdc_sa, admin)
}

#[test]
fn test_pause_reason_stored_and_cleared() {
let env = Env::default();
env.mock_all_auths();

let (vault, _, _, _) = setup_vault(&env);
assert_eq!(vault.pause_reason(), None);

vault.pause(&PauseReason::OracleFailure);
assert!(vault.is_paused());
assert_eq!(vault.pause_reason(), Some(PauseReason::OracleFailure));

vault.unpause();
assert_eq!(vault.pause_reason(), None);
}

#[test]
fn test_dual_approval_emergency_pause() {
let env = Env::default();
env.mock_all_auths();

let (vault, _, _, _admin) = setup_vault(&env);
let primary = Address::generate(&env);
let secondary = Address::generate(&env);

vault.set_emergency_approvers(&primary, &secondary);

let proposal_id = vault.propose_emergency_action(
&primary,
&EmergencyActionKind::Pause,
&(PauseReason::SecurityIncident as u32),
&None,
&None,
);

let proposal = vault.emergency_proposal(&proposal_id).unwrap();
assert!(!proposal.confirmed);
assert!(!proposal.executed);

vault.confirm_emergency_action(&secondary, &proposal_id);

assert!(vault.is_paused());
assert_eq!(vault.pause_reason(), Some(PauseReason::SecurityIncident));

let executed = vault.emergency_proposal(&proposal_id).unwrap();
assert!(executed.confirmed);
assert!(executed.executed);
}

#[test]
#[should_panic(expected = "only primary approver can initiate")]
fn test_emergency_proposal_rejects_non_primary() {
let env = Env::default();
env.mock_all_auths();

let (vault, _, _, _admin) = setup_vault(&env);
let primary = Address::generate(&env);
let secondary = Address::generate(&env);
let outsider = Address::generate(&env);

vault.set_emergency_approvers(&primary, &secondary);

vault.propose_emergency_action(
&outsider,
&EmergencyActionKind::Pause,
&(PauseReason::Governance as u32),
&None,
&None,
);
}

#[test]
fn test_storage_key_registry_valid() {
let env = Env::default();
env.mock_all_auths();

let (vault, _, _, _) = setup_vault(&env);
let registry = vault.storage_key_registry();
assert!(registry.valid);
assert!(registry.keys.len() >= 20);
}

#[test]
fn test_accrue_yield_fee_rounding_deterministic() {
let env = Env::default();
env.mock_all_auths();

let (vault, _, usdc_sa, admin) = setup_vault(&env);
vault.set_fee_bps(&100); // 1%

usdc_sa.mint(&admin, &333);
vault.accrue_yield(&333);

// floor(333 * 100 / 10000) = floor(3.33) = 3
assert_eq!(vault.treasury_balance(), 3);
assert_eq!(vault.total_assets(), 330);
}
133 changes: 133 additions & 0 deletions contracts/vault/src/fee_math.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! Deterministic protocol-fee rounding policy.
//!
//! All fee calculations use **floor division** (truncate toward zero):
//! `fee = amount * fee_bps / 10_000` with integer division rounding down.
//! The vault never over-charges; any sub-stroop remainder stays with depositors.

pub const BPS_DENOMINATOR: i128 = 10_000;

/// Compute protocol fee and net amount using floor rounding.
///
/// Returns `(fee_amount, net_amount)` where `fee_amount + net_amount == amount`.
pub fn calculate_protocol_fee(amount: i128, fee_bps: i128) -> (i128, i128) {
assert!(amount >= 0, "amount must be non-negative");
assert!(fee_bps >= 0 && fee_bps <= BPS_DENOMINATOR, "fee_bps out of range");

if amount == 0 || fee_bps == 0 {
return (0, amount);
}

let fee_amount = amount
.checked_mul(fee_bps)
.expect("fee overflow")
/ BPS_DENOMINATOR;
let net_amount = amount - fee_amount;
(fee_amount, net_amount)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_zero_amount_zero_fee() {
assert_eq!(calculate_protocol_fee(0, 500), (0, 0));
}

#[test]
fn test_zero_bps_no_fee() {
assert_eq!(calculate_protocol_fee(1_000_000, 0), (0, 1_000_000));
}

#[test]
fn test_max_bps_full_fee() {
assert_eq!(calculate_protocol_fee(1_000, 10_000), (1_000, 0));
}

#[test]
fn test_one_bps_exact() {
assert_eq!(calculate_protocol_fee(10_000, 1), (1, 9_999));
}

#[test]
fn test_one_bps_truncates_down() {
// 9_999 * 1 / 10_000 = 0 (floor)
assert_eq!(calculate_protocol_fee(9_999, 1), (0, 9_999));
}

#[test]
fn test_sub_stroop_remainder_stays_with_depositor() {
// amount=1, fee_bps=3333 → fee = 0, net = 1
assert_eq!(calculate_protocol_fee(1, 3_333), (0, 1));
// amount=2, fee_bps=3333 → fee = 0, net = 2
assert_eq!(calculate_protocol_fee(2, 3_333), (0, 2));
// amount=3, fee_bps=3333 → fee = 0, net = 3
assert_eq!(calculate_protocol_fee(3, 3_333), (0, 3));
// amount=4, fee_bps=3333 → fee = 1, net = 3
assert_eq!(calculate_protocol_fee(4, 3_333), (1, 3));
}

#[test]
fn test_boundary_one_below_bps_denominator() {
assert_eq!(calculate_protocol_fee(9_999, 10_000), (9_999, 0));
}

#[test]
fn test_boundary_one_at_bps_denominator() {
assert_eq!(calculate_protocol_fee(10_000, 10_000), (10_000, 0));
}

#[test]
fn test_large_amount_no_overflow() {
let amount = i128::MAX / 10_000;
let (fee, net) = calculate_protocol_fee(amount, 100);
assert_eq!(fee + net, amount);
assert!(fee <= amount);
}

#[test]
fn test_common_fee_rates_deterministic() {
let cases: &[(i128, i128, i128)] = &[
(100, 250, 2), // 2.5% of 100
(100, 500, 5), // 5%
(100, 1000, 10), // 10%
(1, 5000, 0), // 50% of 1 truncates to 0
(3, 5000, 1), // 50% of 3 = 1
(10_000_000_000, 25, 25_000_000),
];
for &(amount, bps, expected_fee) in cases {
let (fee, net) = calculate_protocol_fee(amount, bps);
assert_eq!(fee, expected_fee, "amount={amount} bps={bps}");
assert_eq!(fee + net, amount);
}
}

#[test]
fn test_monotonic_fee_never_exceeds_amount() {
for amount in [1i128, 2, 3, 7, 99, 100, 101, 9999, 10_000, 10_001] {
for bps in [1, 50, 100, 333, 500, 999, 1000, 3333, 5000, 9999, 10_000] {
let (fee, net) = calculate_protocol_fee(amount, bps);
assert!(fee <= amount);
assert_eq!(fee + net, amount);
}
}
}

#[test]
#[should_panic(expected = "fee_bps out of range")]
fn test_rejects_negative_bps() {
calculate_protocol_fee(100, -1);
}

#[test]
#[should_panic(expected = "fee_bps out of range")]
fn test_rejects_bps_above_denominator() {
calculate_protocol_fee(100, 10_001);
}

#[test]
#[should_panic(expected = "amount must be non-negative")]
fn test_rejects_negative_amount() {
calculate_protocol_fee(-1, 100);
}
}
Loading
Loading