fix(stablecoin): tighten fee bounds and validation

This commit is contained in:
Ricardo Guilherme Schmidt 2026-06-25 12:08:30 -03:00
parent d5c0f5c8a2
commit bc61f4cee9
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
5 changed files with 49 additions and 4 deletions

View File

@ -594,7 +594,7 @@ These are properties the protocol maintains across every state-changing instruct
| Constant / parameter | Bound | Rationale |
|---|---|---|
| `FIXED_POINT_ONE` | `10^27` | RAY precision; standard. |
| `stability_fee_per_millisecond` | `FIXED_POINT_ONE ≤ x ≤ FIXED_POINT_ONE * 2` | Lower bound = no decay (RFP "fees accrue continuously" implies positive rate). Upper bound is an anti-typo sanity cap (≈100%/ms) — it does **not** by itself prevent `compound_rate` overflow; that is handled by clamping the elapsed window (`MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS`, below). Real values are `1 + ε` where `ε ≈ 1.5×10^15` for ~5% annual. |
| `stability_fee_per_millisecond` | `FIXED_POINT_ONE ≤ x ≤ FIXED_POINT_ONE + FIXED_POINT_ONE / 100_000_000_000` | Lower bound = no decay (RFP "fees accrue continuously" implies positive rate). Upper bound caps the per-ms increment at `1e-11`, so `compound_rate(x, MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS)` stays inside `u128` under the one-day clamp. Real values are `1 + ε` where `ε ≈ 1.5×10^15` for ~5% annual. |
| `minimum_collateralization_ratio` | `FIXED_POINT_ONE * 1.1 ≤ x ≤ FIXED_POINT_ONE * 10` | Lower bound = 110% (any less is liquidation-immediate); upper bound = 1000% (sanity cap). Real values are 130200%. |
| `controller_proportional_gain` magnitude | `|x| ≤ FIXED_POINT_ONE * 10^3` | Practical upper bound for rate-explosion guard (RFP R2). Real values are tiny (≈10^610^12 raw) because they scale price-error × per-ms rate-output. Rescaled `÷10^3` from the per-second formulation. |
| `controller_integral_gain` magnitude | `|x| ≤ FIXED_POINT_ONE` | As proportional, but rescaled `÷10^6` (it also multiplies `Δt`, now in ms): real values ≈10^310^9 raw. |

View File

@ -24,6 +24,7 @@ use twap_oracle_core::OraclePriceAccount;
const POSITION_NONCE: u64 = 7;
const CLOCK_START: u64 = 1_000;
const Q64_ONE: u128 = 18_446_744_073_709_551_616;
const TEST_STABILITY_FEE_RATE: u128 = FIXED_POINT_ONE + 1_000_000_000_000_000;
struct Keys;
struct Ids;
@ -498,7 +499,7 @@ fn fungible_supply(state: &V03State, account_id: AccountId) -> u128 {
#[test]
fn stablecoin_initialize_then_accrue_fee() {
let mut state = state_for_stablecoin_tests();
let rate = FIXED_POINT_ONE + FIXED_POINT_ONE / 10;
let rate = TEST_STABILITY_FEE_RATE;
initialize_protocol(&mut state, rate);
let params = protocol_parameters(&state);
@ -538,7 +539,7 @@ fn stablecoin_initialize_then_accrue_fee() {
#[test]
fn stablecoin_set_fee_rate_anchors_old_rate_before_switching() {
let mut state = state_for_stablecoin_tests();
let old_rate = FIXED_POINT_ONE + FIXED_POINT_ONE / 10;
let old_rate = TEST_STABILITY_FEE_RATE;
initialize_protocol(&mut state, old_rate);
advance_clock(&mut state, CLOCK_START + 2);

View File

@ -115,6 +115,10 @@ mod stablecoin {
}
/// Open a new collateral-only position for the calling owner.
#[expect(
clippy::too_many_arguments,
reason = "instruction interface passes explicit position, vault, token, and protocol accounts"
)]
#[instruction]
pub fn open_position(
ctx: ProgramContext,
@ -230,6 +234,10 @@ mod stablecoin {
}
/// Repay stablecoin debt against an existing position.
#[expect(
clippy::too_many_arguments,
reason = "instruction interface passes explicit position, token, fee, and protocol accounts"
)]
#[instruction]
pub fn repay_debt(
ctx: ProgramContext,

View File

@ -103,6 +103,11 @@ pub fn generate_debt(
market_price_oracle.account_id, params.market_price_oracle_id,
"Market price oracle does not match protocol parameters"
);
assert_ne!(
market_price_oracle.account,
Account::default(),
"Market price oracle account must be initialized"
);
let oracle = OraclePriceAccount::try_from(&market_price_oracle.account.data)
.expect("Market price oracle account must hold a valid OraclePriceAccount");
assert_eq!(

View File

@ -18,7 +18,7 @@ use stablecoin_core::{
compute_stablecoin_definition_pda, compute_stablecoin_definition_pda_seed,
compute_stablecoin_master_holding_pda, compute_stablecoin_master_holding_pda_seed, Position,
ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator, FIXED_POINT_ONE,
MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS,
MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS, MAX_STABILITY_FEE_PER_MILLISECOND,
};
use token_core::{TokenDefinition, TokenHolding};
use twap_oracle_core::OraclePriceAccount;
@ -788,6 +788,19 @@ fn set_stability_fee_rejects_rate_below_one() {
);
}
#[test]
#[should_panic(expected = "Stability fee per millisecond is out of bounds")]
fn set_stability_fee_rejects_rate_above_safe_maximum() {
crate::set_stability_fee_per_millisecond::set_stability_fee_per_millisecond(
admin_account(),
protocol_parameters_account(false),
stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000),
clock_account(1_000),
STABLECOIN_PROGRAM_ID,
MAX_STABILITY_FEE_PER_MILLISECOND + 1,
);
}
#[test]
fn open_position_stores_normalized_position_and_emits_token_calls() {
let (post_states, chained_calls) = crate::open_position::open_position(
@ -961,6 +974,24 @@ fn generate_debt_rejects_wrong_stablecoin_definition() {
);
}
#[test]
#[should_panic(expected = "Market price oracle account must be initialized")]
fn generate_debt_rejects_uninitialized_market_price_oracle() {
crate::generate_debt::generate_debt(
owner_account(),
position_account(1_000, 0),
stablecoin_definition_account(0),
user_stablecoin_holding(0),
stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000),
redemption_price_state_account(FIXED_POINT_ONE, 1_000),
uninit(oracle_id()),
protocol_parameters_account(false),
clock_account(1_000),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
fn repay_debt_uses_floor_rounding_against_current_accumulator() {
let accumulator = FIXED_POINT_ONE + FIXED_POINT_ONE / 10;