feat(redemption-controller): implement initialize and update functions for redemption rate feedback controller

- Added `initialize_redemption_controller` and `update_redemption_controller` functions to manage redemption rate feedback.
- Introduced `RedemptionController` struct to maintain state for redemption rates.
- Updated `Cargo.toml` and `Cargo.lock` to include `twap_oracle_core` dependency.
- Modified integration tests to cover new redemption controller functionality.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-07-03 09:42:45 -03:00
parent 4a6192d84f
commit d2f3e1d8fb
9 changed files with 1032 additions and 4 deletions
+148
View File
@@ -14,6 +14,12 @@ use spel_framework_macros::account_type;
// compatibility.
const POSITION_PDA_DOMAIN: &[u8] = b"POSITION";
const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT";
const REDEMPTION_CONTROLLER_PDA_DOMAIN: &[u8] = b"REDEMPTION_CONTROLLER";
/// Fixed-point denominator for controller gain parameters.
///
/// A gain of [`CONTROLLER_GAIN_SCALE`] means `1.0`.
pub const CONTROLLER_GAIN_SCALE: u128 = 1_000_000_000;
/// Stablecoin Program Instruction.
#[derive(Debug, Serialize, Deserialize)]
@@ -80,6 +86,47 @@ pub enum Instruction {
/// Amount of stablecoin debt to repay (also the amount burned from the user's holding).
amount: u128,
},
/// Initialize the global redemption-rate feedback controller for one stablecoin/feed pair.
///
/// Required accounts (3):
/// - 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)
///
/// `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,
/// 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`].
proportional_gain: u128,
/// Integral controller gain, scaled by [`CONTROLLER_GAIN_SCALE`].
integral_gain: u128,
/// Maximum absolute accumulated error before the integral term is clamped.
max_integral_error: u128,
/// Maximum absolute redemption rate, in price units per timestamp unit.
max_redemption_rate: u128,
/// Maximum allowed oracle price age. Uses the same timestamp unit as the oracle feed.
max_price_feed_age: u64,
/// Timestamp used to initialize the controller state.
current_timestamp: u64,
},
/// Permissionlessly update redemption price and rate from the configured price feed.
///
/// Required accounts (2):
/// - Redemption controller account (initialized, owned by `self_program_id`)
/// - Configured oracle price feed account
///
/// If the configured feed is stale or unavailable, the controller account is emitted
/// unchanged so redemption updates are paused.
UpdateRedemptionController {
/// Current block timestamp. The guest constrains output validity to this exact timestamp.
current_timestamp: u64,
},
}
/// Persistent state held by a Stablecoin [`Position`] account.
@@ -99,6 +146,42 @@ pub struct Position {
pub debt_amount: u128,
}
/// Global 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
/// before gain scaling and is clamped by `max_integral_error`.
#[account_type]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
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,
/// 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.
pub redemption_rate: i128,
/// Integral controller state, clamped to `max_integral_error`.
pub accumulated_error: i128,
/// Proportional controller gain, scaled by [`CONTROLLER_GAIN_SCALE`].
pub proportional_gain: u128,
/// Integral controller gain, scaled by [`CONTROLLER_GAIN_SCALE`].
pub integral_gain: u128,
/// Maximum absolute accumulated error.
pub max_integral_error: u128,
/// Maximum absolute redemption rate.
pub max_redemption_rate: u128,
/// Maximum allowed oracle price age.
pub max_price_feed_age: u64,
/// Last timestamp at which the controller accepted a live oracle reading.
pub last_update_timestamp: u64,
}
impl TryFrom<&Data> for Position {
type Error = std::io::Error;
@@ -116,6 +199,23 @@ impl From<&Position> for Data {
}
}
impl TryFrom<&Data> for RedemptionController {
type Error = std::io::Error;
fn try_from(data: &Data) -> Result<Self, Self::Error> {
Self::try_from_slice(data.as_ref())
}
}
impl From<&RedemptionController> for Data {
fn from(controller: &RedemptionController) -> Self {
let mut data = Vec::with_capacity(std::mem::size_of_val(controller));
BorshSerialize::serialize(controller, &mut data)
.expect("Serialization to Vec should not fail");
Self::try_from(data).expect("Redemption controller encoded data should fit into Data")
}
}
/// PDA seed for the [`Position`] account owned by `owner_id` for `collateral_definition_id`.
///
/// Derived from the owner and collateral definition addresses with a domain-separation tag
@@ -175,6 +275,54 @@ pub fn compute_position_vault_pda(
)
}
/// PDA seed for the [`RedemptionController`] bound to a stablecoin and price feed.
pub fn compute_redemption_controller_pda_seed(
stablecoin_definition_id: AccountId,
price_feed_id: AccountId,
) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::new();
bytes.extend_from_slice(&stablecoin_definition_id.to_bytes());
bytes.extend_from_slice(&price_feed_id.to_bytes());
bytes.extend_from_slice(REDEMPTION_CONTROLLER_PDA_DOMAIN);
let mut out = [0u8; 32];
out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes());
PdaSeed::new(out)
}
/// Account id of the [`RedemptionController`] PDA for a stablecoin/feed pair.
pub fn compute_redemption_controller_pda(
stablecoin_program_id: ProgramId,
stablecoin_definition_id: AccountId,
price_feed_id: AccountId,
) -> AccountId {
AccountId::for_public_pda(
&stablecoin_program_id,
&compute_redemption_controller_pda_seed(stablecoin_definition_id, price_feed_id),
)
}
/// Verify a redemption controller account address and return its PDA seed.
///
/// # Panics
/// If `controller.account_id` does not match the configured PDA derivation.
pub fn verify_redemption_controller_and_get_seed(
controller: &AccountWithMetadata,
stablecoin_definition_id: AccountId,
price_feed_id: AccountId,
stablecoin_program_id: ProgramId,
) -> PdaSeed {
let seed = compute_redemption_controller_pda_seed(stablecoin_definition_id, price_feed_id);
let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed);
assert_eq!(
controller.account_id, expected_id,
"Redemption controller account ID does not match expected derivation"
);
seed
}
/// Verify the position account's address matches
/// `(stablecoin_program_id, owner, collateral_definition_id)` and return the [`PdaSeed`] for
/// use in post-state claims.