Files
lez-programs/programs/stablecoin/src/update_redemption_rate.rs
T
r4bbit 75a99d1224 refactor: rename nssa/nssa_core → lee/lee_core
Follow the spel fork's `nssa → lee` refactor (chore/update-lez): the
`#[lez_program]` macro now emits `lee_core::…` paths, so the crate must be
referenced by its real name rather than the `nssa_core` alias.

- Cargo.toml: rename the `nssa_core`/`nssa` dep keys to `lee_core`/`lee` and
  drop the now-redundant `package = "lee_core"`/`package = "lee"` aliases
  (dep name == package name).
- Rust: `nssa_core::` → `lee_core::`, `nssa::` → `lee::` across programs,
  modules, and tools.
- Docs: update the library reference in CLAUDE.md and README.md.

Also drop the dead `NSSA_WALLET_HOME_DIR` env var — superseded by
`LEE_WALLET_HOME_DIR`, which is the only wallet-home var any v0.2.x wallet
reads — from the test tooling (setup-amm-testnet.sh, custom-token.mjs,
run-basecamp-e2e.sh) and docs.

Closes #225
2026-08-26 12:30:36 +02:00

486 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 lee_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, &params);
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,
&params,
&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 lee_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,
);
}
}