mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-04 05:59:33 +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
4a6192d84f
commit
d2f3e1d8fb
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3791,6 +3791,7 @@ dependencies = [
|
|||||||
"lee_core",
|
"lee_core",
|
||||||
"stablecoin_core",
|
"stablecoin_core",
|
||||||
"token_core",
|
"token_core",
|
||||||
|
"twap_oracle_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -112,6 +112,86 @@
|
|||||||
"type": "u128"
|
"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": [
|
"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",
|
"name": "TokenDefinition",
|
||||||
"type": {
|
"type": {
|
||||||
|
|||||||
@ -3,8 +3,12 @@ use nssa::{
|
|||||||
public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State,
|
public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State,
|
||||||
};
|
};
|
||||||
use nssa_core::account::{Account, AccountId, Data, Nonce};
|
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 token_core::{TokenDefinition, TokenHolding};
|
||||||
|
use twap_oracle_core::OraclePriceAccount;
|
||||||
|
|
||||||
struct Keys;
|
struct Keys;
|
||||||
struct Ids;
|
struct Ids;
|
||||||
@ -50,6 +54,26 @@ impl Ids {
|
|||||||
AccountId::new([6; 32])
|
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 {
|
fn user_stablecoin_holding() -> AccountId {
|
||||||
AccountId::from(&PublicKey::new_from_private_key(
|
AccountId::from(&PublicKey::new_from_private_key(
|
||||||
&Keys::user_stablecoin_holding(),
|
&Keys::user_stablecoin_holding(),
|
||||||
@ -86,6 +110,14 @@ impl Balances {
|
|||||||
1_000
|
1_000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn redemption_price() -> u128 {
|
||||||
|
1_000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn market_price_below_redemption() -> u128 {
|
||||||
|
900
|
||||||
|
}
|
||||||
|
|
||||||
fn user_stablecoin_holding_init() -> u128 {
|
fn user_stablecoin_holding_init() -> u128 {
|
||||||
1_000
|
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 {
|
fn position_with_debt_init() -> Account {
|
||||||
Account {
|
Account {
|
||||||
program_owner: stablecoin_methods::STABLECOIN_ID,
|
program_owner: stablecoin_methods::STABLECOIN_ID,
|
||||||
@ -219,6 +267,20 @@ fn state_for_stablecoin_repay_tests() -> V03State {
|
|||||||
state
|
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) {
|
fn assert_position(state: &V03State, expected_collateral: u128) {
|
||||||
let position =
|
let position =
|
||||||
Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid 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" }
|
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" }
|
stablecoin_core = { path = "core" }
|
||||||
token_core = { path = "../token/core" }
|
token_core = { path = "../token/core" }
|
||||||
|
twap_oracle_core = { path = "../twap_oracle/core" }
|
||||||
|
|||||||
@ -14,6 +14,12 @@ use spel_framework_macros::account_type;
|
|||||||
// compatibility.
|
// compatibility.
|
||||||
const POSITION_PDA_DOMAIN: &[u8] = b"POSITION";
|
const POSITION_PDA_DOMAIN: &[u8] = b"POSITION";
|
||||||
const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT";
|
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.
|
/// Stablecoin Program Instruction.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[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 of stablecoin debt to repay (also the amount burned from the user's holding).
|
||||||
amount: u128,
|
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.
|
/// Persistent state held by a Stablecoin [`Position`] account.
|
||||||
@ -99,6 +146,42 @@ pub struct Position {
|
|||||||
pub debt_amount: u128,
|
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 {
|
impl TryFrom<&Data> for Position {
|
||||||
type Error = std::io::Error;
|
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`.
|
/// 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
|
/// 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
|
/// Verify the position account's address matches
|
||||||
/// `(stablecoin_program_id, owner, collateral_definition_id)` and return the [`PdaSeed`] for
|
/// `(stablecoin_program_id, owner, collateral_definition_id)` and return the [`PdaSeed`] for
|
||||||
/// use in post-state claims.
|
/// 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",
|
"lee_core",
|
||||||
"stablecoin_core",
|
"stablecoin_core",
|
||||||
"token_core",
|
"token_core",
|
||||||
|
"twap_oracle_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
#![cfg_attr(not(test), no_main)]
|
#![cfg_attr(not(test), no_main)]
|
||||||
|
|
||||||
use nssa_core::account::AccountWithMetadata;
|
use nssa_core::account::{AccountId, AccountWithMetadata};
|
||||||
use spel_framework::context::ProgramContext;
|
use spel_framework::{context::ProgramContext, prelude::*};
|
||||||
use spel_framework::prelude::*;
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
risc0_zkvm::guest::entry!(main);
|
risc0_zkvm::guest::entry!(main);
|
||||||
@ -114,4 +113,80 @@ mod stablecoin {
|
|||||||
chained_calls,
|
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.
|
/// Open a new collateral-only position for a calling owner.
|
||||||
pub mod open_position;
|
pub mod open_position;
|
||||||
|
|
||||||
|
/// Initialize and update redemption-rate feedback controller state.
|
||||||
|
pub mod redemption_controller;
|
||||||
|
|
||||||
/// Repay outstanding stablecoin debt against an existing position.
|
/// Repay outstanding stablecoin debt against an existing position.
|
||||||
pub mod repay_debt;
|
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