From 65de004238bc00b569e183c873ec0c43e7383e00 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Mon, 15 Jun 2026 11:08:09 -0300 Subject: [PATCH] fix(stablecoin): align redemption controller feedback --- artifacts/stablecoin-idl.json | 8 +- .../integration_tests/tests/stablecoin.rs | 11 +- programs/stablecoin/core/src/lib.rs | 17 ++- .../methods/guest/src/bin/stablecoin.rs | 4 +- .../stablecoin/src/redemption_controller.rs | 114 +++++++++++++----- 5 files changed, 101 insertions(+), 53 deletions(-) diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 7ff2215..d07fbee 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -137,7 +137,7 @@ ], "args": [ { - "name": "reference_asset_id", + "name": "collateral_definition_id", "type": "account_id" }, { @@ -229,17 +229,13 @@ "type": "account_id" }, { - "name": "reference_asset_id", + "name": "collateral_definition_id", "type": "account_id" }, { "name": "price_feed_id", "type": "account_id" }, - { - "name": "oracle_program_id", - "type": "program_id" - }, { "name": "redemption_price", "type": "u128" diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index a9ac752..450cb96 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -54,10 +54,6 @@ impl Ids { AccountId::new([6; 32]) } - fn reference_asset() -> AccountId { - AccountId::new([7; 32]) - } - fn price_feed() -> AccountId { AccountId::new([8; 32]) } @@ -190,7 +186,7 @@ impl Accounts { balance: 0_u128, data: Data::from(&OraclePriceAccount { base_asset: Ids::stablecoin_definition(), - quote_asset: Ids::reference_asset(), + quote_asset: Ids::collateral_definition(), price, timestamp, source_id: String::from("twap"), @@ -470,7 +466,7 @@ fn stablecoin_redemption_controller_initializes_and_updates_from_price_feed() { ); let initialize = stablecoin_core::Instruction::InitializeRedemptionController { - reference_asset_id: Ids::reference_asset(), + collateral_definition_id: Ids::collateral_definition(), initial_redemption_price: Balances::redemption_price(), proportional_gain: CONTROLLER_GAIN_SCALE, integral_gain: 0, @@ -501,7 +497,6 @@ fn stablecoin_redemption_controller_initializes_and_updates_from_price_feed() { .expect("valid RedemptionController"); assert_eq!(controller.redemption_price, Balances::redemption_price()); assert_eq!(controller.redemption_rate, 0); - assert_eq!(controller.oracle_program_id, Ids::oracle_program()); let update = stablecoin_core::Instruction::UpdateRedemptionController { current_timestamp }; let message = public_transaction::Message::try_new( @@ -521,7 +516,7 @@ fn stablecoin_redemption_controller_initializes_and_updates_from_price_feed() { RedemptionController::try_from(&state.get_account_by_id(Ids::redemption_controller()).data) .expect("valid RedemptionController"); assert_eq!(controller.redemption_price, Balances::redemption_price()); - assert_eq!(controller.redemption_rate, 100); + assert_eq!(controller.redemption_rate, -100); assert_eq!(controller.accumulated_error, 0); assert_eq!(controller.last_update_timestamp, current_timestamp); } diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index e541ffc..cac1a62 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -92,14 +92,15 @@ pub enum Instruction { /// - Redemption controller account (uninitialized, address must match /// `compute_redemption_controller_pda(self_program_id, stablecoin_definition, price_feed)`) /// - Stablecoin token definition account (initialized fungible token) - /// - Oracle price feed account (initialized; its `program_owner` becomes the configured oracle - /// program) + /// - Oracle price feed account (initialized; must decode as the configured + /// stablecoin/collateral market price) /// /// `proportional_gain` and `integral_gain` use [`CONTROLLER_GAIN_SCALE`] fixed-point /// precision. For example, `CONTROLLER_GAIN_SCALE / 10` represents `0.1`. InitializeRedemptionController { - /// Asset that denominates both the oracle market price and redemption price. - reference_asset_id: AccountId, + /// Collateral token definition that denominates the oracle market price and redemption + /// price. + collateral_definition_id: AccountId, /// Initial redemption price, in the same units and precision as the oracle price. initial_redemption_price: u128, /// Proportional controller gain, scaled by [`CONTROLLER_GAIN_SCALE`]. @@ -146,7 +147,7 @@ pub struct Position { pub debt_amount: u128, } -/// Global redemption feedback controller state for a stablecoin/feed pair. +/// Redemption feedback controller state for a stablecoin/feed pair. /// /// `redemption_rate` is signed price drift per timestamp unit. Positive rates raise the /// redemption price; negative rates lower it. `accumulated_error` stores the integral term @@ -156,12 +157,10 @@ pub struct Position { pub struct RedemptionController { /// Stablecoin token definition priced by the oracle feed. pub stablecoin_definition_id: AccountId, - /// Asset that denominates both oracle prices and redemption price. - pub reference_asset_id: AccountId, + /// Collateral token definition that denominates oracle prices and redemption price. + pub collateral_definition_id: AccountId, /// Configured oracle price feed account. pub price_feed_id: AccountId, - /// Program expected to own `price_feed_id`. - pub oracle_program_id: ProgramId, /// Current redemption price in oracle price units. pub redemption_price: u128, /// Current redemption rate in price units per timestamp unit. diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index 236a94e..1fcd50d 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -131,7 +131,7 @@ mod stablecoin { controller: AccountWithMetadata, stablecoin_definition: AccountWithMetadata, price_feed: AccountWithMetadata, - reference_asset_id: AccountId, + collateral_definition_id: AccountId, initial_redemption_price: u128, proportional_gain: u128, integral_gain: u128, @@ -146,7 +146,7 @@ mod stablecoin { stablecoin_definition, price_feed, ctx.self_program_id, - reference_asset_id, + collateral_definition_id, initial_redemption_price, proportional_gain, integral_gain, diff --git a/programs/stablecoin/src/redemption_controller.rs b/programs/stablecoin/src/redemption_controller.rs index beee4e3..9f45433 100644 --- a/programs/stablecoin/src/redemption_controller.rs +++ b/programs/stablecoin/src/redemption_controller.rs @@ -14,7 +14,7 @@ const CONTROLLER_GAIN_SCALE_I128: i128 = 1_000_000_000; /// - `controller` is already initialized. /// - `controller.account_id` does not match the stablecoin/feed PDA. /// - `stablecoin_definition` is uninitialized or not a fungible token definition. -/// - `price_feed` is uninitialized. +/// - `price_feed` is uninitialized, malformed, stale, or not the stablecoin/collateral feed. /// - Initial price, gains, or clamp limits do not fit controller math bounds. #[expect( clippy::too_many_arguments, @@ -25,7 +25,7 @@ pub fn initialize_redemption_controller( stablecoin_definition: AccountWithMetadata, price_feed: AccountWithMetadata, stablecoin_program_id: ProgramId, - reference_asset_id: AccountId, + collateral_definition_id: AccountId, initial_redemption_price: u128, proportional_gain: u128, integral_gain: u128, @@ -69,6 +69,13 @@ pub fn initialize_redemption_controller( matches!(token_definition, TokenDefinition::Fungible { .. }), "Stablecoin definition must be fungible" ); + assert_live_price_feed( + &price_feed, + stablecoin_definition.account_id, + collateral_definition_id, + current_timestamp, + max_price_feed_age, + ); let controller_seed = verify_redemption_controller_and_get_seed( &controller, @@ -78,9 +85,8 @@ pub fn initialize_redemption_controller( ); let controller_state = RedemptionController { stablecoin_definition_id: stablecoin_definition.account_id, - reference_asset_id, + collateral_definition_id, price_feed_id: price_feed.account_id, - oracle_program_id: price_feed.account.program_owner, redemption_price: initial_redemption_price, redemption_rate: 0, accumulated_error: 0, @@ -165,16 +171,14 @@ fn live_market_price( price_feed: &AccountWithMetadata, current_timestamp: u64, ) -> Option { - if price_feed.account_id != controller.price_feed_id - || price_feed.account == Account::default() - || price_feed.account.program_owner != controller.oracle_program_id + if price_feed.account_id != controller.price_feed_id || price_feed.account == Account::default() { return None; } let price_account = OraclePriceAccount::try_from(&price_feed.account.data).ok()?; if price_account.base_asset != controller.stablecoin_definition_id - || price_account.quote_asset != controller.reference_asset_id + || price_account.quote_asset != controller.collateral_definition_id || price_account.price == 0 || price_account.price > i128_max_as_u128() || price_account.timestamp > current_timestamp @@ -190,6 +194,38 @@ fn live_market_price( Some(price_account.price) } +fn assert_live_price_feed( + price_feed: &AccountWithMetadata, + stablecoin_definition_id: AccountId, + collateral_definition_id: AccountId, + current_timestamp: u64, + max_price_feed_age: u64, +) { + let price_account = OraclePriceAccount::try_from(&price_feed.account.data) + .expect("Price feed account must hold a valid OraclePriceAccount"); + assert_eq!( + price_account.base_asset, stablecoin_definition_id, + "Price feed base asset must be the stablecoin definition" + ); + assert_eq!( + price_account.quote_asset, collateral_definition_id, + "Price feed quote asset must be the collateral definition" + ); + assert_ne!(price_account.price, 0, "Price feed price must be nonzero"); + assert_price_value_fits_i128(price_account.price, "Price feed price"); + assert!( + price_account.timestamp <= current_timestamp, + "Price feed timestamp cannot be in the future" + ); + let age = current_timestamp + .checked_sub(price_account.timestamp) + .expect("Price feed timestamp was checked to be current or older"); + assert!( + age <= max_price_feed_age, + "Price feed age exceeds maximum allowed age" + ); +} + fn compute_next_controller_state( controller: &RedemptionController, market_price: u128, @@ -212,16 +248,15 @@ fn compute_next_controller_state( ); let proportional_term = scaled_term(error, controller.proportional_gain); let integral_term = scaled_term(accumulated_error, controller.integral_gain); - let redemption_rate = clamp_signed( - proportional_term.saturating_add(integral_term), - controller.max_redemption_rate, - ); + let rate_adjustment = proportional_term + .saturating_add(integral_term) + .saturating_neg(); + let redemption_rate = clamp_signed(rate_adjustment, controller.max_redemption_rate); RedemptionController { stablecoin_definition_id: controller.stablecoin_definition_id, - reference_asset_id: controller.reference_asset_id, + collateral_definition_id: controller.collateral_definition_id, price_feed_id: controller.price_feed_id, - oracle_program_id: controller.oracle_program_id, redemption_price, redemption_rate, accumulated_error, @@ -302,7 +337,7 @@ mod tests { AccountId::new([1; 32]) } - fn reference_asset_id() -> AccountId { + fn collateral_definition_id() -> AccountId { AccountId::new([2; 32]) } @@ -341,13 +376,21 @@ mod tests { } fn price_feed_account(price: u128, timestamp: u64) -> AccountWithMetadata { + price_feed_account_with_owner(price, timestamp, ORACLE_PROGRAM_ID) + } + + fn price_feed_account_with_owner( + price: u128, + timestamp: u64, + program_owner: ProgramId, + ) -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ORACLE_PROGRAM_ID, + program_owner, balance: 0, data: Data::from(&OraclePriceAccount { base_asset: stablecoin_definition_id(), - quote_asset: reference_asset_id(), + quote_asset: collateral_definition_id(), price, timestamp, source_id: "twap".to_owned(), @@ -384,9 +427,8 @@ mod tests { fn controller_state() -> RedemptionController { RedemptionController { stablecoin_definition_id: stablecoin_definition_id(), - reference_asset_id: reference_asset_id(), + collateral_definition_id: collateral_definition_id(), price_feed_id: price_feed_id(), - oracle_program_id: ORACLE_PROGRAM_ID, redemption_price: 1_000, redemption_rate: 0, accumulated_error: 0, @@ -406,7 +448,7 @@ mod tests { stablecoin_definition_account(), price_feed_account(1_000, 100), STABLECOIN_PROGRAM_ID, - reference_asset_id(), + collateral_definition_id(), 1_000, CONTROLLER_GAIN_SCALE, 0, @@ -427,9 +469,11 @@ mod tests { controller.stablecoin_definition_id, stablecoin_definition_id() ); - assert_eq!(controller.reference_asset_id, reference_asset_id()); + assert_eq!( + controller.collateral_definition_id, + collateral_definition_id() + ); assert_eq!(controller.price_feed_id, price_feed_id()); - assert_eq!(controller.oracle_program_id, ORACLE_PROGRAM_ID); assert_eq!(controller.redemption_price, 1_000); assert_eq!(controller.redemption_rate, 0); assert_eq!(controller.last_update_timestamp, 100); @@ -447,10 +491,24 @@ mod tests { assert_eq!(post_states.len(), 2); let controller = RedemptionController::try_from(&post_states[0].account().data).expect("valid state"); - assert_eq!(controller.redemption_rate, 100); + assert_eq!(controller.redemption_rate, -100); assert_eq!(controller.last_update_timestamp, 100); } + #[test] + fn update_redemption_controller_accepts_matching_feed_from_any_program_owner() { + let post_states = update_redemption_controller( + controller_account(&controller_state()), + price_feed_account_with_owner(900, 100, [7u32; 8]), + STABLECOIN_PROGRAM_ID, + 100, + ); + + let controller = + RedemptionController::try_from(&post_states[0].account().data).expect("valid state"); + assert_eq!(controller.redemption_rate, -100); + } + #[test] fn update_redemption_controller_pauses_when_price_feed_is_stale() { let controller = controller_state(); @@ -487,19 +545,19 @@ mod tests { } #[test] - fn controller_sets_positive_rate_when_market_price_is_below_redemption_price() { + fn controller_sets_negative_rate_when_market_price_is_below_redemption_price() { let updated = compute_next_controller_state(&controller_state(), 900, 100); - assert_eq!(updated.redemption_rate, 100); + assert_eq!(updated.redemption_rate, -100); assert_eq!(updated.accumulated_error, 0); assert_eq!(updated.redemption_price, 1_000); } #[test] - fn controller_sets_negative_rate_when_market_price_is_above_redemption_price() { + fn controller_sets_positive_rate_when_market_price_is_above_redemption_price() { let updated = compute_next_controller_state(&controller_state(), 1_100, 100); - assert_eq!(updated.redemption_rate, -100); + assert_eq!(updated.redemption_rate, 100); } #[test] @@ -527,6 +585,6 @@ mod tests { let updated = compute_next_controller_state(&controller, 900, 101); assert_eq!(updated.accumulated_error, 100); - assert_eq!(updated.redemption_rate, 80); + assert_eq!(updated.redemption_rate, -80); } }