mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(stablecoin): implement update_redemption_rate host function
closes #170
This commit is contained in:
@@ -14,6 +14,9 @@ pub mod open_position;
|
||||
/// Repay outstanding stablecoin debt against an existing position.
|
||||
pub mod repay_debt;
|
||||
|
||||
/// Permissionless poke: run one redemption-rate controller tick.
|
||||
pub mod update_redemption_rate;
|
||||
|
||||
/// Withdraw collateral from an existing position back to a user-controlled holding.
|
||||
pub mod withdraw_collateral;
|
||||
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Host-side implementation of `Instruction::UpdateRedemptionRate` (spec §10.3).
|
||||
//!
|
||||
//! Wall-clock time comes from the system `CLOCK_01` account, same as the other
|
||||
//! pokes.
|
||||
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ChainedCall, ProgramId},
|
||||
};
|
||||
use stablecoin_core::{
|
||||
compute_redemption_price_state_pda, controller::run_controller_tick,
|
||||
math::compute_current_redemption_price, ControllerOutput, ProtocolParameters,
|
||||
RedemptionPriceState,
|
||||
};
|
||||
use twap_oracle_core::OraclePriceAccount;
|
||||
|
||||
use crate::accrue_stability_fee::read_clock;
|
||||
|
||||
/// Run one tick of the redemption-rate controller and re-anchor the redemption
|
||||
/// price.
|
||||
///
|
||||
/// Permissionless but strict: unlike [`crate::refresh_globals`], every gate here
|
||||
/// is hard. It panics when called before
|
||||
/// `minimum_milliseconds_between_rate_updates` has elapsed, and when the oracle
|
||||
/// is stale or reports a zero price — a keeper may want that as an explicit
|
||||
/// failure signal rather than a silent skip. Never blocked by the frozen flag.
|
||||
///
|
||||
/// See spec §10.3 for the full account contract and panic conditions.
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn update_redemption_rate(
|
||||
caller: AccountWithMetadata,
|
||||
protocol_parameters: AccountWithMetadata,
|
||||
redemption_price_state: AccountWithMetadata,
|
||||
market_price_oracle: AccountWithMetadata,
|
||||
clock: AccountWithMetadata,
|
||||
stablecoin_program_id: ProgramId,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
assert!(caller.is_authorized, "Caller authorization is missing");
|
||||
|
||||
let (params, redemption) = decode_redemption_inputs(
|
||||
&protocol_parameters,
|
||||
&redemption_price_state,
|
||||
stablecoin_program_id,
|
||||
);
|
||||
let oracle = decode_oracle(&market_price_oracle, ¶ms);
|
||||
let now = read_clock(&clock);
|
||||
|
||||
// Hard gates. `refresh_globals` treats these same three conditions as soft
|
||||
// skips; here they are failures (spec §10.3 vs §10.3a).
|
||||
assert!(
|
||||
now.saturating_sub(oracle.timestamp) <= params.maximum_oracle_price_age_milliseconds,
|
||||
"Market price oracle observation is stale"
|
||||
);
|
||||
assert!(oracle.price > 0, "Market price oracle reports zero price");
|
||||
assert!(
|
||||
now.saturating_sub(redemption.last_updated_at)
|
||||
>= params.minimum_milliseconds_between_rate_updates,
|
||||
"update_redemption_rate called too soon since last update"
|
||||
);
|
||||
|
||||
let redemption_post = advance_redemption_price(
|
||||
&redemption_price_state,
|
||||
¶ms,
|
||||
&redemption,
|
||||
oracle.price,
|
||||
now,
|
||||
);
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new(caller.account),
|
||||
AccountPostState::new(protocol_parameters.account),
|
||||
AccountPostState::new(redemption_post),
|
||||
AccountPostState::new(market_price_oracle.account),
|
||||
AccountPostState::new(clock.account),
|
||||
];
|
||||
|
||||
(post_states, vec![])
|
||||
}
|
||||
|
||||
/// Validate and decode the two accounts the redemption half needs.
|
||||
///
|
||||
/// Shared with [`crate::refresh_globals`] so the combined poke can never drift
|
||||
/// from the standalone one.
|
||||
pub(crate) fn decode_redemption_inputs(
|
||||
protocol_parameters: &AccountWithMetadata,
|
||||
redemption_price_state: &AccountWithMetadata,
|
||||
stablecoin_program_id: ProgramId,
|
||||
) -> (ProtocolParameters, RedemptionPriceState) {
|
||||
assert_ne!(
|
||||
protocol_parameters.account,
|
||||
Account::default(),
|
||||
"ProtocolParameters account must be initialized"
|
||||
);
|
||||
assert_eq!(
|
||||
protocol_parameters.account.program_owner, stablecoin_program_id,
|
||||
"ProtocolParameters not owned by this stablecoin program"
|
||||
);
|
||||
assert_ne!(
|
||||
redemption_price_state.account,
|
||||
Account::default(),
|
||||
"RedemptionPriceState account must be initialized"
|
||||
);
|
||||
assert_eq!(
|
||||
redemption_price_state.account.program_owner, stablecoin_program_id,
|
||||
"RedemptionPriceState not owned by this stablecoin program"
|
||||
);
|
||||
assert_eq!(
|
||||
redemption_price_state.account_id,
|
||||
compute_redemption_price_state_pda(stablecoin_program_id),
|
||||
"RedemptionPriceState account ID does not match expected PDA derivation"
|
||||
);
|
||||
|
||||
let params = ProtocolParameters::try_from(&protocol_parameters.account.data)
|
||||
.expect("ProtocolParameters must decode");
|
||||
let redemption = RedemptionPriceState::try_from(&redemption_price_state.account.data)
|
||||
.expect("RedemptionPriceState must decode");
|
||||
|
||||
(params, redemption)
|
||||
}
|
||||
|
||||
/// Validate the oracle account and decode its observation.
|
||||
///
|
||||
/// The oracle's `account_id` must equal
|
||||
/// `ProtocolParameters.market_price_oracle_id`. Its `program_owner` is
|
||||
/// deliberately NOT pinned, so a future aggregator or an alternative producer
|
||||
/// can replace the TWAP oracle without a code change here. Shared with
|
||||
/// [`crate::refresh_globals`], where the id mismatch is likewise a hard panic.
|
||||
pub(crate) fn decode_oracle(
|
||||
market_price_oracle: &AccountWithMetadata,
|
||||
params: &ProtocolParameters,
|
||||
) -> OraclePriceAccount {
|
||||
assert_ne!(
|
||||
market_price_oracle.account,
|
||||
Account::default(),
|
||||
"Market price oracle account must be initialized"
|
||||
);
|
||||
assert_eq!(
|
||||
market_price_oracle.account_id, params.market_price_oracle_id,
|
||||
"Market price oracle account_id does not match ProtocolParameters.market_price_oracle_id"
|
||||
);
|
||||
OraclePriceAccount::try_from(&market_price_oracle.account.data)
|
||||
.expect("Market price oracle must decode as OraclePriceAccount")
|
||||
}
|
||||
|
||||
/// Project the redemption price to `now`, run one controller tick against
|
||||
/// `market_price`, and return the re-anchored account.
|
||||
///
|
||||
/// Shared with [`crate::refresh_globals`] — the redemption half is identical in
|
||||
/// both. The projection deliberately runs BEFORE the tick and uses the OLD rate;
|
||||
/// the tick then produces the NEW rate that later reads compound against.
|
||||
pub(crate) fn advance_redemption_price(
|
||||
redemption_price_state: &AccountWithMetadata,
|
||||
params: &ProtocolParameters,
|
||||
redemption: &RedemptionPriceState,
|
||||
market_price: u128,
|
||||
now: u64,
|
||||
) -> Account {
|
||||
let milliseconds_elapsed = now.saturating_sub(redemption.last_updated_at);
|
||||
let current_price = compute_current_redemption_price(
|
||||
redemption.redemption_price_at_last_update,
|
||||
redemption.redemption_rate_per_millisecond,
|
||||
redemption.last_updated_at,
|
||||
now,
|
||||
);
|
||||
|
||||
let ControllerOutput {
|
||||
redemption_rate_per_millisecond,
|
||||
controller_integral_term,
|
||||
} = run_controller_tick(
|
||||
current_price,
|
||||
market_price,
|
||||
redemption.controller_integral_term,
|
||||
params.controller_proportional_gain,
|
||||
params.controller_integral_gain,
|
||||
milliseconds_elapsed,
|
||||
);
|
||||
|
||||
let mut redemption_post = redemption_price_state.account.clone();
|
||||
redemption_post.data = Data::from(&RedemptionPriceState {
|
||||
redemption_price_at_last_update: current_price,
|
||||
redemption_rate_per_millisecond,
|
||||
controller_integral_term,
|
||||
last_updated_at: now,
|
||||
});
|
||||
redemption_post
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
clippy::indexing_slicing,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used,
|
||||
reason = "tests deliberately panic on bad state via assert!/#[should_panic] and index fixed-size vectors"
|
||||
)]
|
||||
mod tests {
|
||||
use nssa_core::account::AccountId;
|
||||
use stablecoin_core::math::FIXED_POINT_ONE;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::{
|
||||
caller_account, clock_account, oracle_account, protocol_parameters_account,
|
||||
redemption_price_state_account, redemption_price_state_account_with_integral_term,
|
||||
uninitialized, ParameterOverrides, MARKET_PRICE_BELOW_ANCHOR, NOW, REDEMPTION_PRICE_ANCHOR,
|
||||
STABLECOIN_PROGRAM_ID, T0,
|
||||
};
|
||||
|
||||
fn invoke() -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(T0),
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_returns_five_post_states_and_no_chained_calls() {
|
||||
let (post_states, chained_calls) = invoke();
|
||||
assert_eq!(post_states.len(), 5);
|
||||
assert!(chained_calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_re_anchors_with_the_projected_price_and_controller_output() {
|
||||
let (post_states, _) = invoke();
|
||||
let decoded =
|
||||
RedemptionPriceState::try_from(&post_states[2].account().data).expect("decode");
|
||||
|
||||
let projected =
|
||||
compute_current_redemption_price(REDEMPTION_PRICE_ANCHOR, FIXED_POINT_ONE, T0, NOW);
|
||||
let expected = run_controller_tick(
|
||||
projected,
|
||||
MARKET_PRICE_BELOW_ANCHOR,
|
||||
0,
|
||||
FIXED_POINT_ONE as i128,
|
||||
0,
|
||||
NOW - T0,
|
||||
);
|
||||
|
||||
assert_eq!(decoded.redemption_price_at_last_update, projected);
|
||||
assert_eq!(
|
||||
decoded.redemption_rate_per_millisecond,
|
||||
expected.redemption_rate_per_millisecond
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.controller_integral_term,
|
||||
expected.controller_integral_term
|
||||
);
|
||||
assert_eq!(decoded.last_updated_at, NOW);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn market_below_redemption_drives_the_rate_above_one() {
|
||||
// error = redemption − market > 0 with a positive Kp: the rate rises, so
|
||||
// the redemption price climbs and pulls the market up (negative feedback).
|
||||
let (post_states, _) = invoke();
|
||||
let decoded = RedemptionPriceState::try_from(&post_states[2].account().data).unwrap();
|
||||
assert!(decoded.redemption_rate_per_millisecond > FIXED_POINT_ONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn market_above_redemption_drives_the_rate_below_one() {
|
||||
let (post_states, _) = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(T0),
|
||||
oracle_account(NOW, REDEMPTION_PRICE_ANCHOR * 2),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
let decoded = RedemptionPriceState::try_from(&post_states[2].account().data).unwrap();
|
||||
assert!(decoded.redemption_rate_per_millisecond < FIXED_POINT_ONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carries_the_prior_integral_term_forward() {
|
||||
let standing = 12_345_678_i128;
|
||||
let (post_states, _) = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides {
|
||||
controller_proportional_gain: 0,
|
||||
controller_integral_gain: 0,
|
||||
..ParameterOverrides::default()
|
||||
}),
|
||||
redemption_price_state_account_with_integral_term(T0, standing),
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
let decoded = RedemptionPriceState::try_from(&post_states[2].account().data).unwrap();
|
||||
// Ki = 0 means nothing is integrated this tick, so the term is unchanged.
|
||||
assert_eq!(decoded.controller_integral_term, standing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_claims_no_pda_and_echoes_oracle_and_clock() {
|
||||
let (post_states, _) = invoke();
|
||||
assert_eq!(post_states[2].required_claim(), None);
|
||||
assert_eq!(
|
||||
post_states[3].account(),
|
||||
&oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR).account
|
||||
);
|
||||
assert_eq!(post_states[4].account(), &clock_account(NOW).account);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_is_allowed_while_frozen() {
|
||||
let (post_states, _) = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides {
|
||||
is_frozen: true,
|
||||
..ParameterOverrides::default()
|
||||
}),
|
||||
redemption_price_state_account(T0),
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
let decoded = RedemptionPriceState::try_from(&post_states[2].account().data).unwrap();
|
||||
assert_eq!(decoded.last_updated_at, NOW);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Caller authorization is missing")]
|
||||
fn requires_caller_authorization() {
|
||||
let mut caller = caller_account();
|
||||
caller.is_authorized = false;
|
||||
let _ = update_redemption_rate(
|
||||
caller,
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(T0),
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "ProtocolParameters account must be initialized")]
|
||||
fn rejects_uninitialized_protocol_parameters() {
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
uninitialized(stablecoin_core::compute_protocol_parameters_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
)),
|
||||
redemption_price_state_account(T0),
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "RedemptionPriceState account must be initialized")]
|
||||
fn rejects_uninitialized_redemption_price_state() {
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
uninitialized(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)),
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "RedemptionPriceState not owned by this stablecoin program")]
|
||||
fn rejects_foreign_owned_redemption_price_state() {
|
||||
let mut state = redemption_price_state_account(T0);
|
||||
state.account.program_owner = [9u32; 8];
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
state,
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(
|
||||
expected = "RedemptionPriceState account ID does not match expected PDA derivation"
|
||||
)]
|
||||
fn rejects_wrong_redemption_price_state_pda() {
|
||||
let mut state = redemption_price_state_account(T0);
|
||||
state.account_id = AccountId::new([0xDE; 32]);
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
state,
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Market price oracle account must be initialized")]
|
||||
fn rejects_uninitialized_oracle() {
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(T0),
|
||||
uninitialized(crate::test_support::oracle_id()),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(
|
||||
expected = "Market price oracle account_id does not match ProtocolParameters.market_price_oracle_id"
|
||||
)]
|
||||
fn rejects_oracle_id_mismatch() {
|
||||
let mut oracle = oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR);
|
||||
oracle.account_id = AccountId::new([0xDE; 32]);
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(T0),
|
||||
oracle,
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Market price oracle observation is stale")]
|
||||
fn rejects_stale_oracle() {
|
||||
// 1_000_000 ms old > the 900_000 ms maximum age.
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(T0),
|
||||
oracle_account(NOW - 1_000_000, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Market price oracle reports zero price")]
|
||||
fn rejects_zero_oracle_price() {
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(T0),
|
||||
oracle_account(NOW, 0),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "update_redemption_rate called too soon since last update")]
|
||||
fn rejects_call_before_the_minimum_interval() {
|
||||
// 100_000 ms since the last update < the 300_000 ms minimum.
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(NOW - 100_000),
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock_account(NOW),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Clock account must be the system CLOCK_01 account")]
|
||||
fn rejects_wrong_clock_account() {
|
||||
let mut clock = clock_account(NOW);
|
||||
clock.account_id = AccountId::new([0xC1; 32]);
|
||||
let _ = update_redemption_rate(
|
||||
caller_account(),
|
||||
protocol_parameters_account(ParameterOverrides::default()),
|
||||
redemption_price_state_account(T0),
|
||||
oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR),
|
||||
clock,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user