mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-03 05:29:50 +00:00
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:
parent
fe4c7a96da
commit
f3a94e6bff
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3791,6 +3791,7 @@ dependencies = [
|
||||
"lee_core",
|
||||
"stablecoin_core",
|
||||
"token_core",
|
||||
"twap_oracle_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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.
|
||||
|
||||
1
programs/stablecoin/methods/guest/Cargo.lock
generated
1
programs/stablecoin/methods/guest/Cargo.lock
generated
@ -2712,6 +2712,7 @@ dependencies = [
|
||||
"lee_core",
|
||||
"stablecoin_core",
|
||||
"token_core",
|
||||
"twap_oracle_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
532
programs/stablecoin/src/redemption_controller.rs
Normal file
532
programs/stablecoin/src/redemption_controller.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user