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
175 changes: 175 additions & 0 deletions contracts/price-oracle/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use soroban_sdk::{contracttype, Address, Env, Vec};
use crate::types::{RelayerState, DataKey};

// ─────────────────────────────────────────────────────────────────────────────
// Storage Key
Expand Down Expand Up @@ -230,6 +231,80 @@ pub fn _require_not_frozen(env: &Env) {
}

// ─────────────────────────────────────────────────────────────────────────────
// Relayer Penalty System
// ─────────────────────────────────────────────────────────────────────────────

/// Get the relayer state for a given provider address.
pub fn _get_relayer_state(env: &Env, provider: &Address) -> RelayerState {
env.storage()
.instance()
.get(&crate::types::DataKey::RelayerState(provider.clone()))
.unwrap_or_else(|| RelayerState {
consecutive_errors: 0,
penalty_multiplier: 100, // No penalty by default
last_success_timestamp: 0,
last_error_timestamp: 0,
})
}

/// Set the relayer state for a given provider address.
pub fn _set_relayer_state(env: &Env, provider: &Address, state: &RelayerState) {
env.storage().instance().set(&crate::types::DataKey::RelayerState(provider.clone()), state);
}

/// Calculate penalty multiplier based on consecutive errors.
///
/// Penalty progression:
/// - 1-2 errors: 110 (10% penalty)
/// - 3-4 errors: 125 (25% penalty)
/// - 5-7 errors: 150 (50% penalty)
/// - 8+ errors: 200 (100% penalty - effectively suspended)
pub fn _calculate_penalty_multiplier(consecutive_errors: u32) -> u32 {
match consecutive_errors {
0 => 100, // No penalty
1 | 2 => 110, // 10% penalty
3 | 4 => 125, // 25% penalty
5 | 6 | 7 => 150, // 50% penalty
_ => 200, // 100% penalty (suspended)
}
}

/// Update relayer state after a successful submission.
/// Resets consecutive errors and updates penalty multiplier.
pub fn _record_relayer_success(env: &Env, provider: &Address) {
let mut state = _get_relayer_state(env, provider);
state.consecutive_errors = 0;
state.penalty_multiplier = 100; // Reset penalty
state.last_success_timestamp = env.ledger().timestamp();
_set_relayer_state(env, provider, &state);
}

/// Update relayer state after a failed submission or missed heartbeat.
/// Increments consecutive errors and updates penalty multiplier.
pub fn _record_relayer_error(env: &Env, provider: &Address) {
let mut state = _get_relayer_state(env, provider);
state.consecutive_errors += 1;
state.penalty_multiplier = _calculate_penalty_multiplier(state.consecutive_errors);
state.last_error_timestamp = env.ledger().timestamp();
_set_relayer_state(env, provider, &state);
}

/// Get the current penalty multiplier for a relayer.
pub fn _get_relayer_penalty_multiplier(env: &Env, provider: &Address) -> u32 {
let state = _get_relayer_state(env, provider);
state.penalty_multiplier
}

/// Check if a relayer is effectively suspended (100% penalty).
pub fn _is_relayer_suspended(env: &Env, provider: &Address) -> bool {
let state = _get_relayer_state(env, provider);
state.penalty_multiplier >= 200
}

/// Get the number of consecutive errors for a relayer.
pub fn _get_consecutive_errors(env: &Env, provider: &Address) -> u32 {
let state = _get_relayer_state(env, provider);
state.consecutive_errors
// Multi-Sig Action Proposal Helpers
// ─────────────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -602,6 +677,106 @@ mod auth_tests {
});
}

// ── Relayer Penalty System tests ───────────────────────────────────────────

#[test]
fn test_relayer_state_initialization() {
let (env, contract_id, _) = setup();
let provider = <soroban_sdk::Address as soroban_sdk::testutils::Address>::generate(&env);
env.as_contract(&contract_id, || {
let state = _get_relayer_state(&env, &provider);
assert_eq!(state.consecutive_errors, 0);
assert_eq!(state.penalty_multiplier, 100);
assert_eq!(state.last_success_timestamp, 0);
assert_eq!(state.last_error_timestamp, 0);
});
}

#[test]
fn test_calculate_penalty_multiplier() {
assert_eq!(_calculate_penalty_multiplier(0), 100); // No penalty
assert_eq!(_calculate_penalty_multiplier(1), 110); // 10% penalty
assert_eq!(_calculate_penalty_multiplier(2), 110); // 10% penalty
assert_eq!(_calculate_penalty_multiplier(3), 125); // 25% penalty
assert_eq!(_calculate_penalty_multiplier(4), 125); // 25% penalty
assert_eq!(_calculate_penalty_multiplier(5), 150); // 50% penalty
assert_eq!(_calculate_penalty_multiplier(6), 150); // 50% penalty
assert_eq!(_calculate_penalty_multiplier(7), 150); // 50% penalty
assert_eq!(_calculate_penalty_multiplier(8), 200); // 100% penalty (suspended)
assert_eq!(_calculate_penalty_multiplier(10), 200); // 100% penalty (suspended)
}

#[test]
fn test_record_relayer_success() {
let (env, contract_id, _) = setup();
let provider = <soroban_sdk::Address as soroban_sdk::testutils::Address>::generate(&env);
env.as_contract(&contract_id, || {
// Start with some errors
_record_relayer_error(&env, &provider);
_record_relayer_error(&env, &provider);
assert_eq!(_get_consecutive_errors(&env, &provider), 2);
assert_eq!(_get_relayer_penalty_multiplier(&env, &provider), 110);

// Record success - should reset
_record_relayer_success(&env, &provider);
assert_eq!(_get_consecutive_errors(&env, &provider), 0);
assert_eq!(_get_relayer_penalty_multiplier(&env, &provider), 100);
assert!(_get_relayer_state(&env, &provider).last_success_timestamp > 0);
});
}

#[test]
fn test_record_relayer_error_progression() {
let (env, contract_id, _) = setup();
let provider = <soroban_sdk::Address as soroban_sdk::testutils::Address>::generate(&env);
env.as_contract(&contract_id, || {
// First error
_record_relayer_error(&env, &provider);
assert_eq!(_get_consecutive_errors(&env, &provider), 1);
assert_eq!(_get_relayer_penalty_multiplier(&env, &provider), 110);
assert!(!_is_relayer_suspended(&env, &provider));

// Second error
_record_relayer_error(&env, &provider);
assert_eq!(_get_consecutive_errors(&env, &provider), 2);
assert_eq!(_get_relayer_penalty_multiplier(&env, &provider), 110);
assert!(!_is_relayer_suspended(&env, &provider));

// Third error
_record_relayer_error(&env, &provider);
assert_eq!(_get_consecutive_errors(&env, &provider), 3);
assert_eq!(_get_relayer_penalty_multiplier(&env, &provider), 125);
assert!(!_is_relayer_suspended(&env, &provider));

// Eighth error - should be suspended
_record_relayer_error(&env, &provider);
_record_relayer_error(&env, &provider);
_record_relayer_error(&env, &provider);
_record_relayer_error(&env, &provider);
_record_relayer_error(&env, &provider);
assert_eq!(_get_consecutive_errors(&env, &provider), 8);
assert_eq!(_get_relayer_penalty_multiplier(&env, &provider), 200);
assert!(_is_relayer_suspended(&env, &provider));
});
}

#[test]
fn test_relayer_state_persistence() {
let (env, contract_id, _) = setup();
let provider = <soroban_sdk::Address as soroban_sdk::testutils::Address>::generate(&env);
env.as_contract(&contract_id, || {
// Record some errors
_record_relayer_error(&env, &provider);
_record_relayer_error(&env, &provider);

// Verify state is saved
let state = _get_relayer_state(&env, &provider);
assert_eq!(state.consecutive_errors, 2);
assert_eq!(state.penalty_multiplier, 110);
assert!(state.last_error_timestamp > 0);
});
}

// ── Renounce ownership tests ──────────────────────────────────────────────

#[test]
Expand Down
72 changes: 72 additions & 0 deletions contracts/price-oracle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,21 @@ pub trait StellarFlowTrait {
///
/// Returns true if the contract is frozen, false otherwise.
fn is_frozen(env: Env) -> bool;

/// Get the current penalty multiplier for a relayer.
///
/// Returns the penalty multiplier (100 = no penalty, 150 = 50% penalty, etc.).
fn get_relayer_penalty_multiplier(env: Env, relayer: Address) -> u32;

/// Get the number of consecutive errors for a relayer.
///
/// Returns the count of consecutive missed heartbeats or errors.
fn get_relayer_consecutive_errors(env: Env, relayer: Address) -> u32;

/// Check if a relayer is suspended due to penalties.
///
/// Returns true if the relayer has a 100% penalty (effectively suspended).
fn is_relayer_suspended(env: Env, relayer: Address) -> bool;
}

#[contractclient(name = "TokenContractClient")]
Expand Down Expand Up @@ -1212,6 +1227,34 @@ impl PriceOracle {
decimals: u32,
confidence_score: u32,
ttl: u64,
) -> Result<(), Error> {
let result = Self::update_price_internal(
env.clone(),
source.clone(),
asset.clone(),
price,
decimals,
confidence_score,
ttl,
);

// Record error if submission failed
if let Err(_) = result {
crate::auth::_record_relayer_error(&env, &source);
}

result
}

/// Internal price update function with penalty tracking.
fn update_price_internal(
env: Env,
source: Address,
asset: Symbol,
price: i128,
decimals: u32,
confidence_score: u32,
ttl: u64,
) -> Result<(), Error> {
_require_not_destroyed(&env);
crate::auth::_require_not_frozen(&env);
Expand All @@ -1229,6 +1272,11 @@ impl PriceOracle {
return Err(Error::NotAuthorized);
}

// Check if relayer is suspended due to penalties
if crate::auth::_is_relayer_suspended(&env, &source) {
return Err(Error::NotAuthorized);
}

// Normalize the raw price to 9 fixed-point decimals on entry.
let normalized = Self::normalize_price(&env, &asset, price);

Expand Down Expand Up @@ -1332,6 +1380,9 @@ impl PriceOracle {
};
callbacks::notify_subscribers(&env, &payload);

// Record successful submission for penalty tracking
crate::auth::_record_relayer_success(&env, &source);

Ok(())
}

Expand Down Expand Up @@ -2082,6 +2133,27 @@ impl PriceOracle {
crate::auth::_is_frozen(&env)
}

/// Get the current penalty multiplier for a relayer.
///
/// Returns the penalty multiplier (100 = no penalty, 150 = 50% penalty, etc.).
pub fn get_relayer_penalty_multiplier(env: Env, relayer: Address) -> u32 {
crate::auth::_get_relayer_penalty_multiplier(&env, &relayer)
}

/// Get the number of consecutive errors for a relayer.
///
/// Returns the count of consecutive missed heartbeats or errors.
pub fn get_relayer_consecutive_errors(env: Env, relayer: Address) -> u32 {
crate::auth::_get_consecutive_errors(&env, &relayer)
}

/// Check if a relayer is suspended due to penalties.
///
/// Returns true if the relayer has a 100% penalty (effectively suspended).
pub fn is_relayer_suspended(env: Env, relayer: Address) -> bool {
crate::auth::_is_relayer_suspended(&env, &relayer)
}

/// Get the price buffer for a specific asset.
///
/// Returns all relayer submissions for the current ledger,
Expand Down
47 changes: 47 additions & 0 deletions contracts/price-oracle/src/penalty_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Simple test to verify penalty system implementation
use soroban_sdk::{Address, Env};
use crate::types::RelayerState;
use crate::auth::{
_get_relayer_state, _set_relayer_state, _calculate_penalty_multiplier,
_record_relayer_success, _record_relayer_error, _get_consecutive_errors,
_get_relayer_penalty_multiplier, _is_relayer_suspended
};

#[test]
fn test_penalty_system_basic() {
let env = Env::default();
let contract_id = env.register_contract("test", crate::PriceOracle);
let provider = Address::generate(&env);

env.as_contract(&contract_id, || {
// Test initial state
let state = _get_relayer_state(&env, &provider);
assert_eq!(state.consecutive_errors, 0);
assert_eq!(state.penalty_multiplier, 100);
assert!(!_is_relayer_suspended(&env, &provider));

// Test penalty calculation
assert_eq!(_calculate_penalty_multiplier(0), 100);
assert_eq!(_calculate_penalty_multiplier(1), 110);
assert_eq!(_calculate_penalty_multiplier(3), 125);
assert_eq!(_calculate_penalty_multiplier(5), 150);
assert_eq!(_calculate_penalty_multiplier(8), 200);

// Test error recording
_record_relayer_error(&env, &provider);
assert_eq!(_get_consecutive_errors(&env, &provider), 1);
assert_eq!(_get_relayer_penalty_multiplier(&env, &provider), 110);

// Test success resets errors
_record_relayer_success(&env, &provider);
assert_eq!(_get_consecutive_errors(&env, &provider), 0);
assert_eq!(_get_relayer_penalty_multiplier(&env, &provider), 100);

// Test suspension after 8 errors
for i in 0..8 {
_record_relayer_error(&env, &provider);
}
assert_eq!(_get_consecutive_errors(&env, &provider), 8);
assert!(_is_relayer_suspended(&env, &provider));
});
}
14 changes: 14 additions & 0 deletions contracts/price-oracle/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub enum DataKey {
CommunityCouncil,
/// Emergency freeze state flag.
EmergencyFrozen,
/// Relayer state tracking consecutive errors and penalties.
RelayerState(Address),
/// Proposed action for multi-signature voting (action_id -> ProposedAction).
ProposedAction(u64),
/// Votes for a proposed action (action_id -> Vec<Address>).
Expand Down Expand Up @@ -227,6 +229,18 @@ pub struct AdminLogEntry {
pub timestamp: u64,
}

/// Relayer state tracking consecutive errors and penalties.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RelayerState {
/// Number of consecutive missed heartbeats or errors.
pub consecutive_errors: u32,
/// Current penalty multiplier (100 = no penalty, 150 = 50% penalty, etc.).
pub penalty_multiplier: u32,
/// Timestamp of last successful submission.
pub last_success_timestamp: u64,
/// Timestamp of last error/miss.
pub last_error_timestamp: u64,
/// Proposed action waiting for multi-signature approval.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down