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-06-08 11:24:27 -03:00
parent fe4c7a96da
commit f3a94e6bff
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
9 changed files with 1032 additions and 4 deletions

1
Cargo.lock generated
View File

@ -3791,6 +3791,7 @@ dependencies = [
"lee_core",
"stablecoin_core",
"token_core",
"twap_oracle_core",
]
[[package]]

View File

@ -112,6 +112,86 @@
"type": "u128"
}
]
},
{
"name": "initialize_redemption_controller",
"accounts": [
{
"name": "controller",
"writable": false,
"signer": false,
"init": false
},
{
"name": "stablecoin_definition",
"writable": false,
"signer": false,
"init": false
},
{
"name": "price_feed",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
{
"name": "reference_asset_id",
"type": "account_id"
},
{
"name": "initial_redemption_price",
"type": "u128"
},
{
"name": "proportional_gain",
"type": "u128"
},
{
"name": "integral_gain",
"type": "u128"
},
{
"name": "max_integral_error",
"type": "u128"
},
{
"name": "max_redemption_rate",
"type": "u128"
},
{
"name": "max_price_feed_age",
"type": "u64"
},
{
"name": "current_timestamp",
"type": "u64"
}
]
},
{
"name": "update_redemption_controller",
"accounts": [
{
"name": "controller",
"writable": false,
"signer": false,
"init": false
},
{
"name": "price_feed",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
{
"name": "current_timestamp",
"type": "u64"
}
]
}
],
"accounts": [
@ -139,6 +219,66 @@
]
}
},
{
"name": "RedemptionController",
"type": {
"kind": "struct",
"fields": [
{
"name": "stablecoin_definition_id",
"type": "account_id"
},
{
"name": "reference_asset_id",
"type": "account_id"
},
{
"name": "price_feed_id",
"type": "account_id"
},
{
"name": "oracle_program_id",
"type": "program_id"
},
{
"name": "redemption_price",
"type": "u128"
},
{
"name": "redemption_rate",
"type": "i128"
},
{
"name": "accumulated_error",
"type": "i128"
},
{
"name": "proportional_gain",
"type": "u128"
},
{
"name": "integral_gain",
"type": "u128"
},
{
"name": "max_integral_error",
"type": "u128"
},
{
"name": "max_redemption_rate",
"type": "u128"
},
{
"name": "max_price_feed_age",
"type": "u64"
},
{
"name": "last_update_timestamp",
"type": "u64"
}
]
}
},
{
"name": "TokenDefinition",
"type": {

View File

@ -3,8 +3,12 @@ use nssa::{
public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State,
};
use nssa_core::account::{Account, AccountId, Data, Nonce};
use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position};
use stablecoin_core::{
compute_position_pda, compute_position_vault_pda, compute_redemption_controller_pda, Position,
RedemptionController, CONTROLLER_GAIN_SCALE,
};
use token_core::{TokenDefinition, TokenHolding};
use twap_oracle_core::OraclePriceAccount;
struct Keys;
struct Ids;
@ -50,6 +54,26 @@ impl Ids {
AccountId::new([6; 32])
}
fn reference_asset() -> AccountId {
AccountId::new([7; 32])
}
fn price_feed() -> AccountId {
AccountId::new([8; 32])
}
fn redemption_controller() -> AccountId {
compute_redemption_controller_pda(
Self::stablecoin_program(),
Self::stablecoin_definition(),
Self::price_feed(),
)
}
fn oracle_program() -> nssa_core::program::ProgramId {
[9u32; 8]
}
fn user_stablecoin_holding() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(
&Keys::user_stablecoin_holding(),
@ -86,6 +110,14 @@ impl Balances {
1_000
}
fn redemption_price() -> u128 {
1_000
}
fn market_price_below_redemption() -> u128 {
900
}
fn user_stablecoin_holding_init() -> u128 {
1_000
}
@ -152,6 +184,22 @@ impl Accounts {
}
}
fn price_feed_init(price: u128, timestamp: u64) -> Account {
Account {
program_owner: Ids::oracle_program(),
balance: 0_u128,
data: Data::from(&OraclePriceAccount {
base_asset: Ids::stablecoin_definition(),
quote_asset: Ids::reference_asset(),
price,
timestamp,
source_id: String::from("twap"),
confidence_interval: 0,
}),
nonce: Nonce(0),
}
}
fn position_with_debt_init() -> Account {
Account {
program_owner: stablecoin_methods::STABLECOIN_ID,
@ -219,6 +267,20 @@ fn state_for_stablecoin_repay_tests() -> V03State {
state
}
fn state_for_stablecoin_redemption_controller_tests(price: u128, timestamp: u64) -> V03State {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::stablecoin_definition(),
Accounts::stablecoin_definition_init(),
);
state.force_insert_account(
Ids::price_feed(),
Accounts::price_feed_init(price, timestamp),
);
state
}
fn assert_position(state: &V03State, expected_collateral: u128) {
let position =
Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position");
@ -398,3 +460,68 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() {
}
}
}
#[test]
fn stablecoin_redemption_controller_initializes_and_updates_from_price_feed() {
let current_timestamp = 100_u64;
let mut state = state_for_stablecoin_redemption_controller_tests(
Balances::market_price_below_redemption(),
current_timestamp,
);
let initialize = stablecoin_core::Instruction::InitializeRedemptionController {
reference_asset_id: Ids::reference_asset(),
initial_redemption_price: Balances::redemption_price(),
proportional_gain: CONTROLLER_GAIN_SCALE,
integral_gain: 0,
max_integral_error: 1_000,
max_redemption_rate: 500,
max_price_feed_age: 10,
current_timestamp,
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::redemption_controller(),
Ids::stablecoin_definition(),
Ids::price_feed(),
],
vec![],
initialize,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, current_timestamp)
.expect("initialize_redemption_controller must succeed");
let controller =
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, 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(
Ids::stablecoin_program(),
vec![Ids::redemption_controller(), Ids::price_feed()],
vec![],
update,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, current_timestamp)
.expect("update_redemption_controller must succeed");
let controller =
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.accumulated_error, 0);
assert_eq!(controller.last_update_timestamp, current_timestamp);
}

View File

@ -7,3 +7,4 @@ edition = "2021"
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6", features = ["host"], package = "lee_core" }
stablecoin_core = { path = "core" }
token_core = { path = "../token/core" }
twap_oracle_core = { path = "../twap_oracle/core" }

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.

View File

@ -2712,6 +2712,7 @@ dependencies = [
"lee_core",
"stablecoin_core",
"token_core",
"twap_oracle_core",
]
[[package]]

View File

@ -1,8 +1,7 @@
#![cfg_attr(not(test), no_main)]
use nssa_core::account::AccountWithMetadata;
use spel_framework::context::ProgramContext;
use spel_framework::prelude::*;
use nssa_core::account::{AccountId, AccountWithMetadata};
use spel_framework::{context::ProgramContext, prelude::*};
#[cfg(not(test))]
risc0_zkvm::guest::entry!(main);
@ -114,4 +113,80 @@ mod stablecoin {
chained_calls,
))
}
/// Initialize redemption-rate feedback controller state for one stablecoin/feed pair.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition
/// fails (see
/// [`stablecoin_program::redemption_controller::initialize_redemption_controller`]
/// for the full list).
#[expect(
clippy::too_many_arguments,
reason = "instruction interface exposes controller configuration explicitly"
)]
#[instruction]
pub fn initialize_redemption_controller(
ctx: ProgramContext,
controller: AccountWithMetadata,
stablecoin_definition: AccountWithMetadata,
price_feed: AccountWithMetadata,
reference_asset_id: AccountId,
initial_redemption_price: u128,
proportional_gain: u128,
integral_gain: u128,
max_integral_error: u128,
max_redemption_rate: u128,
max_price_feed_age: u64,
current_timestamp: u64,
) -> SpelResult {
let post_states =
stablecoin_program::redemption_controller::initialize_redemption_controller(
controller,
stablecoin_definition,
price_feed,
ctx.self_program_id,
reference_asset_id,
initial_redemption_price,
proportional_gain,
integral_gain,
max_integral_error,
max_redemption_rate,
max_price_feed_age,
current_timestamp,
);
let validity_end = current_timestamp
.checked_add(1)
.expect("current_timestamp must allow an exact validity window");
Ok(spel_framework::SpelOutput::execute(post_states, vec![])
.try_with_timestamp_validity_window(current_timestamp..validity_end)
.expect("exact timestamp validity window must be non-empty"))
}
/// Update redemption price and redemption rate from the configured price feed.
///
/// # Errors
/// Returns the host program's panic-converted error if controller state
/// validation fails. Stale or unavailable price feeds pause updates by
/// emitting the controller state unchanged.
#[instruction]
pub fn update_redemption_controller(
ctx: ProgramContext,
controller: AccountWithMetadata,
price_feed: AccountWithMetadata,
current_timestamp: u64,
) -> SpelResult {
let post_states = stablecoin_program::redemption_controller::update_redemption_controller(
controller,
price_feed,
ctx.self_program_id,
current_timestamp,
);
let validity_end = current_timestamp
.checked_add(1)
.expect("current_timestamp must allow an exact validity window");
Ok(spel_framework::SpelOutput::execute(post_states, vec![])
.try_with_timestamp_validity_window(current_timestamp..validity_end)
.expect("exact timestamp validity window must be non-empty"))
}
}

View File

@ -5,6 +5,9 @@ pub use stablecoin_core as core;
/// Open a new collateral-only position for a calling owner.
pub mod open_position;
/// Initialize and update redemption-rate feedback controller state.
pub mod redemption_controller;
/// Repay outstanding stablecoin debt against an existing position.
pub mod repay_debt;

View File

@ -0,0 +1,532 @@
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data},
program::{AccountPostState, ProgramId},
};
use stablecoin_core::{verify_redemption_controller_and_get_seed, RedemptionController};
use token_core::TokenDefinition;
use twap_oracle_core::OraclePriceAccount;
const CONTROLLER_GAIN_SCALE_I128: i128 = 1_000_000_000;
/// Initialize the redemption-rate feedback controller for one stablecoin/feed pair.
///
/// # Panics
/// - `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.
/// - Initial price, gains, or clamp limits do not fit controller math bounds.
#[expect(
clippy::too_many_arguments,
reason = "public instruction configuration maps directly to controller parameters"
)]
pub fn initialize_redemption_controller(
controller: AccountWithMetadata,
stablecoin_definition: AccountWithMetadata,
price_feed: AccountWithMetadata,
stablecoin_program_id: ProgramId,
reference_asset_id: AccountId,
initial_redemption_price: u128,
proportional_gain: u128,
integral_gain: u128,
max_integral_error: u128,
max_redemption_rate: u128,
max_price_feed_age: u64,
current_timestamp: u64,
) -> Vec<AccountPostState> {
assert_eq!(
controller.account,
Account::default(),
"Redemption controller account must be uninitialized"
);
assert_ne!(
stablecoin_definition.account,
Account::default(),
"Stablecoin definition account must be initialized"
);
assert_ne!(
price_feed.account,
Account::default(),
"Price feed account must be initialized"
);
assert_price_value_fits_i128(initial_redemption_price, "Initial redemption price");
assert_price_value_fits_i128(max_integral_error, "Maximum integral error");
assert_price_value_fits_i128(max_redemption_rate, "Maximum redemption rate");
assert_price_value_fits_i128(proportional_gain, "Proportional gain");
assert_price_value_fits_i128(integral_gain, "Integral gain");
assert_ne!(
initial_redemption_price, 0,
"Initial redemption price must be nonzero"
);
assert_ne!(
max_redemption_rate, 0,
"Maximum redemption rate must be nonzero"
);
let token_definition = TokenDefinition::try_from(&stablecoin_definition.account.data)
.expect("Stablecoin definition account must hold a valid TokenDefinition");
assert!(
matches!(token_definition, TokenDefinition::Fungible { .. }),
"Stablecoin definition must be fungible"
);
let controller_seed = verify_redemption_controller_and_get_seed(
&controller,
stablecoin_definition.account_id,
price_feed.account_id,
stablecoin_program_id,
);
let controller_state = RedemptionController {
stablecoin_definition_id: stablecoin_definition.account_id,
reference_asset_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,
proportional_gain,
integral_gain,
max_integral_error,
max_redemption_rate,
max_price_feed_age,
last_update_timestamp: current_timestamp,
};
let mut controller_post = controller.account;
controller_post.data = Data::from(&controller_state);
vec![
AccountPostState::new_claimed(
controller_post,
nssa_core::program::Claim::Pda(controller_seed),
),
AccountPostState::new(stablecoin_definition.account),
AccountPostState::new(price_feed.account),
]
}
/// Update redemption price and redemption rate from the configured price feed.
///
/// If the configured feed is stale, unavailable, or malformed, the controller state is emitted
/// unchanged. That makes stale-oracle handling an explicit pause instead of a failed update.
///
/// # Panics
/// - `controller` is uninitialized, not owned by this program, malformed, or at the wrong PDA.
/// - `current_timestamp` is older than `RedemptionController.last_update_timestamp`.
pub fn update_redemption_controller(
controller: AccountWithMetadata,
price_feed: AccountWithMetadata,
stablecoin_program_id: ProgramId,
current_timestamp: u64,
) -> Vec<AccountPostState> {
assert_ne!(
controller.account,
Account::default(),
"Redemption controller account must be initialized"
);
assert_eq!(
controller.account.program_owner, stablecoin_program_id,
"Redemption controller is not owned by this stablecoin program"
);
let controller_data = RedemptionController::try_from(&controller.account.data)
.expect("Redemption controller account must hold valid controller state");
verify_redemption_controller_and_get_seed(
&controller,
controller_data.stablecoin_definition_id,
controller_data.price_feed_id,
stablecoin_program_id,
);
assert!(
current_timestamp >= controller_data.last_update_timestamp,
"Current timestamp is older than the last controller update"
);
let Some(market_price) = live_market_price(&controller_data, &price_feed, current_timestamp)
else {
return vec![
AccountPostState::new(controller.account),
AccountPostState::new(price_feed.account),
];
};
let updated_controller =
compute_next_controller_state(&controller_data, market_price, current_timestamp);
let mut controller_post = controller.account;
controller_post.data = Data::from(&updated_controller);
vec![
AccountPostState::new(controller_post),
AccountPostState::new(price_feed.account),
]
}
fn live_market_price(
controller: &RedemptionController,
price_feed: &AccountWithMetadata,
current_timestamp: u64,
) -> Option<u128> {
if price_feed.account_id != controller.price_feed_id
|| price_feed.account == Account::default()
|| price_feed.account.program_owner != controller.oracle_program_id
{
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.price == 0
|| price_account.price > i128_max_as_u128()
|| price_account.timestamp > current_timestamp
{
return None;
}
let age = current_timestamp.checked_sub(price_account.timestamp)?;
if age > controller.max_price_feed_age {
return None;
}
Some(price_account.price)
}
fn compute_next_controller_state(
controller: &RedemptionController,
market_price: u128,
current_timestamp: u64,
) -> RedemptionController {
let elapsed = current_timestamp
.checked_sub(controller.last_update_timestamp)
.expect("Current timestamp was checked to be monotonic");
let redemption_price = apply_redemption_rate(
controller.redemption_price,
controller.redemption_rate,
elapsed,
);
let error = price_error(redemption_price, market_price);
let accumulated_error = clamp_signed(
controller
.accumulated_error
.saturating_add(error.saturating_mul(i128::from(elapsed))),
controller.max_integral_error,
);
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,
);
RedemptionController {
stablecoin_definition_id: controller.stablecoin_definition_id,
reference_asset_id: controller.reference_asset_id,
price_feed_id: controller.price_feed_id,
oracle_program_id: controller.oracle_program_id,
redemption_price,
redemption_rate,
accumulated_error,
proportional_gain: controller.proportional_gain,
integral_gain: controller.integral_gain,
max_integral_error: controller.max_integral_error,
max_redemption_rate: controller.max_redemption_rate,
max_price_feed_age: controller.max_price_feed_age,
last_update_timestamp: current_timestamp,
}
}
fn apply_redemption_rate(redemption_price: u128, redemption_rate: i128, elapsed: u64) -> u128 {
let drift = redemption_rate.saturating_mul(i128::from(elapsed));
if drift >= 0 {
redemption_price.saturating_add(u128::try_from(drift).unwrap_or(u128::MAX))
} else {
let decrease = u128::try_from(drift.saturating_neg()).unwrap_or(u128::MAX);
redemption_price.saturating_sub(decrease).max(1)
}
}
fn price_error(redemption_price: u128, market_price: u128) -> i128 {
if redemption_price >= market_price {
let difference = redemption_price
.checked_sub(market_price)
.expect("checked by branch");
i128::try_from(difference).expect("Redemption price difference must fit i128")
} else {
let difference = market_price
.checked_sub(redemption_price)
.expect("checked by branch");
i128::try_from(difference)
.expect("Market price difference must fit i128")
.saturating_neg()
}
}
fn scaled_term(value: i128, gain: u128) -> i128 {
let gain = i128::try_from(gain).expect("Controller gain must fit i128");
value
.saturating_mul(gain)
.checked_div(CONTROLLER_GAIN_SCALE_I128)
.expect("Controller gain scale must be nonzero")
}
fn clamp_signed(value: i128, max_abs: u128) -> i128 {
let max_abs = i128::try_from(max_abs).expect("Controller clamp must fit i128");
value.clamp(max_abs.saturating_neg(), max_abs)
}
fn assert_price_value_fits_i128(value: u128, label: &str) {
assert!(value <= i128_max_as_u128(), "{label} must fit i128");
}
fn i128_max_as_u128() -> u128 {
u128::try_from(i128::MAX).expect("i128::MAX must fit u128")
}
#[cfg(test)]
mod tests {
use nssa_core::{
account::Nonce,
program::{Claim, PdaSeed},
};
use stablecoin_core::{
compute_redemption_controller_pda, compute_redemption_controller_pda_seed,
CONTROLLER_GAIN_SCALE,
};
use super::*;
const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8];
const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8];
const ORACLE_PROGRAM_ID: ProgramId = [4u32; 8];
fn stablecoin_definition_id() -> AccountId {
AccountId::new([1; 32])
}
fn reference_asset_id() -> AccountId {
AccountId::new([2; 32])
}
fn price_feed_id() -> AccountId {
AccountId::new([3; 32])
}
fn controller_id() -> AccountId {
compute_redemption_controller_pda(
STABLECOIN_PROGRAM_ID,
stablecoin_definition_id(),
price_feed_id(),
)
}
fn controller_seed() -> PdaSeed {
compute_redemption_controller_pda_seed(stablecoin_definition_id(), price_feed_id())
}
fn stablecoin_definition_account() -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: TOKEN_PROGRAM_ID,
balance: 0,
data: Data::from(&TokenDefinition::Fungible {
name: "DAI".to_owned(),
total_supply: 1_000_000,
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
},
is_authorized: false,
account_id: stablecoin_definition_id(),
}
}
fn price_feed_account(price: u128, timestamp: u64) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: ORACLE_PROGRAM_ID,
balance: 0,
data: Data::from(&OraclePriceAccount {
base_asset: stablecoin_definition_id(),
quote_asset: reference_asset_id(),
price,
timestamp,
source_id: "twap".to_owned(),
confidence_interval: 0,
}),
nonce: Nonce(0),
},
is_authorized: false,
account_id: price_feed_id(),
}
}
fn uninit_controller_account() -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: controller_id(),
}
}
fn controller_account(controller: &RedemptionController) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: STABLECOIN_PROGRAM_ID,
balance: 0,
data: Data::from(controller),
nonce: Nonce(0),
},
is_authorized: false,
account_id: controller_id(),
}
}
fn controller_state() -> RedemptionController {
RedemptionController {
stablecoin_definition_id: stablecoin_definition_id(),
reference_asset_id: reference_asset_id(),
price_feed_id: price_feed_id(),
oracle_program_id: ORACLE_PROGRAM_ID,
redemption_price: 1_000,
redemption_rate: 0,
accumulated_error: 0,
proportional_gain: CONTROLLER_GAIN_SCALE,
integral_gain: 0,
max_integral_error: 1_000,
max_redemption_rate: 500,
max_price_feed_age: 10,
last_update_timestamp: 100,
}
}
#[test]
fn initialize_redemption_controller_claims_pda_and_stores_config() {
let post_states = initialize_redemption_controller(
uninit_controller_account(),
stablecoin_definition_account(),
price_feed_account(1_000, 100),
STABLECOIN_PROGRAM_ID,
reference_asset_id(),
1_000,
CONTROLLER_GAIN_SCALE,
0,
1_000,
500,
10,
100,
);
assert_eq!(post_states.len(), 3);
assert_eq!(
post_states[0].required_claim(),
Some(Claim::Pda(controller_seed()))
);
let controller =
RedemptionController::try_from(&post_states[0].account().data).expect("valid state");
assert_eq!(
controller.stablecoin_definition_id,
stablecoin_definition_id()
);
assert_eq!(controller.reference_asset_id, reference_asset_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);
}
#[test]
fn update_redemption_controller_uses_live_price_feed() {
let post_states = update_redemption_controller(
controller_account(&controller_state()),
price_feed_account(900, 100),
STABLECOIN_PROGRAM_ID,
100,
);
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.last_update_timestamp, 100);
}
#[test]
fn update_redemption_controller_pauses_when_price_feed_is_stale() {
let controller = controller_state();
let post_states = update_redemption_controller(
controller_account(&controller),
price_feed_account(900, 100),
STABLECOIN_PROGRAM_ID,
111,
);
let updated =
RedemptionController::try_from(&post_states[0].account().data).expect("valid state");
assert_eq!(updated, controller);
}
#[test]
fn update_redemption_controller_pauses_when_price_feed_is_unavailable() {
let controller = controller_state();
let unavailable_feed = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: price_feed_id(),
};
let post_states = update_redemption_controller(
controller_account(&controller),
unavailable_feed,
STABLECOIN_PROGRAM_ID,
101,
);
let updated =
RedemptionController::try_from(&post_states[0].account().data).expect("valid state");
assert_eq!(updated, controller);
}
#[test]
fn controller_sets_positive_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.accumulated_error, 0);
assert_eq!(updated.redemption_price, 1_000);
}
#[test]
fn controller_sets_negative_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);
}
#[test]
fn controller_applies_existing_redemption_rate_over_elapsed_time() {
let mut controller = controller_state();
controller.redemption_rate = 2;
controller.proportional_gain = 0;
let updated = compute_next_controller_state(&controller, 1_010, 105);
assert_eq!(updated.redemption_price, 1_010);
assert_eq!(updated.redemption_rate, 0);
assert_eq!(updated.last_update_timestamp, 105);
}
#[test]
fn controller_clamps_accumulated_error_and_redemption_rate() {
let mut controller = controller_state();
controller.accumulated_error = 90;
controller.proportional_gain = 0;
controller.integral_gain = CONTROLLER_GAIN_SCALE;
controller.max_integral_error = 100;
controller.max_redemption_rate = 80;
let updated = compute_next_controller_state(&controller, 900, 101);
assert_eq!(updated.accumulated_error, 100);
assert_eq!(updated.redemption_rate, 80);
}
}