Files
lez-programs/programs/integration_tests/tests/stablecoin.rs
T

875 lines
29 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.
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
use nssa::{
error::LeeError,
program_deployment_transaction::{self, ProgramDeploymentTransaction},
public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State,
};
use nssa_core::account::{Account, AccountId, Data, Nonce};
use stablecoin_core::{
compute_position_pda, compute_position_vault_pda, compute_protocol_parameters_pda,
compute_redemption_price_state_pda, compute_stability_fee_accumulator_pda,
compute_stablecoin_definition_pda, compute_stablecoin_master_holding_pda, Position,
};
use token_core::{TokenDefinition, TokenHolding};
struct Keys;
struct Ids;
struct Balances;
struct Accounts;
impl Keys {
fn owner() -> PrivateKey {
PrivateKey::try_new([41; 32]).expect("valid private key")
}
fn user_holding() -> PrivateKey {
PrivateKey::try_new([42; 32]).expect("valid private key")
}
fn user_stablecoin_holding() -> PrivateKey {
PrivateKey::try_new([43; 32]).expect("valid private key")
}
fn admin() -> PrivateKey {
PrivateKey::try_new([44; 32]).expect("valid private key")
}
}
impl Ids {
fn token_program() -> nssa_core::program::ProgramId {
token_methods::TOKEN_ID
}
fn stablecoin_program() -> nssa_core::program::ProgramId {
stablecoin_methods::STABLECOIN_ID
}
fn collateral_definition() -> AccountId {
AccountId::new([5; 32])
}
fn owner() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::owner()))
}
fn user_holding() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::user_holding()))
}
fn stablecoin_definition() -> AccountId {
AccountId::new([6; 32])
}
fn user_stablecoin_holding() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(
&Keys::user_stablecoin_holding(),
))
}
fn position_nonce() -> u64 {
0
}
fn position() -> AccountId {
compute_position_pda(
Self::stablecoin_program(),
Self::owner(),
Self::position_nonce(),
)
}
fn vault() -> AccountId {
compute_position_vault_pda(Self::stablecoin_program(), Self::position())
}
fn admin() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::admin()))
}
fn freeze_authority() -> AccountId {
AccountId::new([0xFE; 32])
}
fn oracle() -> AccountId {
AccountId::new([0x70; 32])
}
/// The stablecoin's `TokenDefinition` PDA created by `initialize_program`
/// (distinct from `stablecoin_definition`, the externally-owned definition
/// the repay test uses).
fn stablecoin_definition_pda() -> AccountId {
compute_stablecoin_definition_pda(Self::stablecoin_program())
}
}
impl Balances {
fn user_holding_init() -> u128 {
1_000_000
}
fn collateral_deposit() -> u128 {
500_000
}
fn collateral_withdraw() -> u128 {
200_000
}
fn stablecoin_supply_init() -> u128 {
1_000
}
fn user_stablecoin_holding_init() -> u128 {
1_000
}
fn initial_debt() -> u128 {
300
}
fn debt_repay_amount() -> u128 {
100
}
}
impl Accounts {
fn collateral_definition_init() -> Account {
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenDefinition::Fungible {
name: String::from("Gold"),
total_supply: Balances::user_holding_init(),
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
}
fn user_holding_init() -> Account {
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: Balances::user_holding_init(),
}),
nonce: Nonce(0),
}
}
fn stablecoin_definition_init() -> Account {
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenDefinition::Fungible {
name: String::from("DAI"),
total_supply: Balances::stablecoin_supply_init(),
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
}
fn user_stablecoin_holding_init() -> Account {
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::stablecoin_definition(),
balance: Balances::user_stablecoin_holding_init(),
}),
nonce: Nonce(0),
}
}
fn position_with_debt_init() -> Account {
Account {
program_owner: stablecoin_methods::STABLECOIN_ID,
balance: 0_u128,
data: Data::from(&Position {
owner_account_id: Ids::owner(),
position_nonce: Ids::position_nonce(),
vault_account_id: Ids::vault(),
collateral_amount: Balances::collateral_deposit(),
normalized_debt_amount: Balances::initial_debt(),
opened_at: 0,
}),
nonce: Nonce(0),
}
}
fn oracle_init(base_asset: AccountId, quote_asset: AccountId) -> Account {
Self::oracle_with(
base_asset,
quote_asset,
stablecoin_core::math::FIXED_POINT_ONE / 2,
0,
)
}
/// An oracle observation at an explicit price and timestamp — the poke tests
/// need both to control the controller's error term and the freshness gate.
fn oracle_with(
base_asset: AccountId,
quote_asset: AccountId,
price: u128,
timestamp: u64,
) -> Account {
Account {
program_owner: [9u32; 8],
balance: 0_u128,
data: Data::from(&twap_oracle_core::OraclePriceAccount {
base_asset,
quote_asset,
price,
timestamp,
source_id: Ids::oracle(),
confidence_interval: 0,
}),
nonce: Nonce(0),
}
}
}
/// Seeds the canonical `CLOCK_01` account at `timestamp`. `V03State::new()` no longer
/// auto-creates it, and `initialize_program` reads it for wall-clock time.
fn seed_clock(state: &mut V03State, timestamp: u64) {
let data = ClockAccountData {
block_id: 0,
timestamp,
}
.to_bytes();
let clock_account = Account {
data: Data::try_from(data).expect("clock account data fits"),
..Account::default()
};
state.force_insert_account(CLOCK_01_PROGRAM_ACCOUNT_ID, clock_account);
}
fn deploy_programs(state: &mut V03State) {
let token_message =
program_deployment_transaction::Message::new(token_methods::TOKEN_ELF.to_vec());
state
.transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new(
token_message,
))
.expect("token program deployment must succeed");
let stablecoin_message =
program_deployment_transaction::Message::new(stablecoin_methods::STABLECOIN_ELF.to_vec());
state
.transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new(
stablecoin_message,
))
.expect("stablecoin program deployment must succeed");
}
fn state_for_stablecoin_tests() -> V03State {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init());
state
}
fn current_nonce(state: &V03State, account_id: AccountId) -> Nonce {
state.get_account_by_id(account_id).nonce
}
fn state_for_stablecoin_repay_tests() -> V03State {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
state.force_insert_account(
Ids::stablecoin_definition(),
Accounts::stablecoin_definition_init(),
);
state.force_insert_account(Ids::position(), Accounts::position_with_debt_init());
state.force_insert_account(
Ids::user_stablecoin_holding(),
Accounts::user_stablecoin_holding_init(),
);
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");
assert_eq!(position.collateral_amount, expected_collateral);
assert_eq!(position.normalized_debt_amount, 0);
assert_eq!(position.vault_account_id, Ids::vault());
assert_eq!(position.owner_account_id, Ids::owner());
assert_eq!(position.position_nonce, Ids::position_nonce());
}
fn assert_fungible_balance(state: &V03State, account_id: AccountId, expected_balance: u128) {
let holding = TokenHolding::try_from(&state.get_account_by_id(account_id).data)
.expect("valid TokenHolding");
match holding {
TokenHolding::Fungible {
definition_id,
balance,
} => {
assert_eq!(definition_id, Ids::collateral_definition());
assert_eq!(balance, expected_balance);
}
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => {
panic!("expected Fungible holding")
}
}
}
#[test]
fn stablecoin_open_position_then_withdraw_collateral() {
let mut state = state_for_stablecoin_tests();
// Open the position: deposit collateral from the user's holding into a fresh vault.
let open = stablecoin_core::Instruction::OpenPosition {
position_nonce: Ids::position_nonce(),
collateral_amount: Balances::collateral_deposit(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::user_holding(),
Ids::collateral_definition(),
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_holding()),
],
open,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::owner(), &Keys::user_holding()],
);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, 0)
.expect("open_position must succeed");
assert_position(&state, Balances::collateral_deposit());
assert_fungible_balance(&state, Ids::vault(), Balances::collateral_deposit());
assert_fungible_balance(
&state,
Ids::user_holding(),
Balances::user_holding_init() - Balances::collateral_deposit(),
);
// Withdraw part of the collateral back to the same user holding.
let withdraw = stablecoin_core::Instruction::WithdrawCollateral {
amount: Balances::collateral_withdraw(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::user_holding(),
],
vec![current_nonce(&state, Ids::owner())],
withdraw,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, 0)
.expect("withdraw_collateral must succeed");
assert_position(
&state,
Balances::collateral_deposit() - Balances::collateral_withdraw(),
);
assert_fungible_balance(
&state,
Ids::vault(),
Balances::collateral_deposit() - Balances::collateral_withdraw(),
);
assert_fungible_balance(
&state,
Ids::user_holding(),
Balances::user_holding_init() - Balances::collateral_deposit()
+ Balances::collateral_withdraw(),
);
}
#[test]
fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() {
let mut state = state_for_stablecoin_repay_tests();
let repay = stablecoin_core::Instruction::RepayDebt {
amount: Balances::debt_repay_amount(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::stablecoin_definition(),
Ids::user_stablecoin_holding(),
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_stablecoin_holding()),
],
repay,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::owner(), &Keys::user_stablecoin_holding()],
);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, 0)
.expect("repay_debt must succeed");
// Position debt decreased; collateral untouched.
let position =
Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position");
assert_eq!(
position.normalized_debt_amount,
Balances::initial_debt() - Balances::debt_repay_amount()
);
assert_eq!(position.collateral_amount, Balances::collateral_deposit());
// Stablecoin total supply decreased by the burn amount.
let definition =
TokenDefinition::try_from(&state.get_account_by_id(Ids::stablecoin_definition()).data)
.expect("valid TokenDefinition");
match definition {
TokenDefinition::Fungible { total_supply, .. } => {
assert_eq!(
total_supply,
Balances::stablecoin_supply_init() - Balances::debt_repay_amount()
);
}
TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"),
}
// User stablecoin holding decreased by the burn amount.
let holding =
TokenHolding::try_from(&state.get_account_by_id(Ids::user_stablecoin_holding()).data)
.expect("valid TokenHolding");
match holding {
TokenHolding::Fungible { balance, .. } => {
assert_eq!(
balance,
Balances::user_stablecoin_holding_init() - Balances::debt_repay_amount()
);
}
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => {
panic!("expected Fungible holding")
}
}
}
/// Protocol parameters the initialized-protocol helper installs. Kept as
/// constants so the poke tests can reason about the interval / staleness gates.
mod protocol_config {
use stablecoin_core::math::FIXED_POINT_ONE;
/// ~5% annual, expressed per millisecond.
pub(super) const STABILITY_FEE_PER_MILLISECOND: u128 = FIXED_POINT_ONE + 1_500_000_000_000_000;
pub(super) const MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES: u64 = 300_000;
pub(super) const MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS: u64 = 900_000;
pub(super) const INITIAL_REDEMPTION_PRICE: u128 = FIXED_POINT_ONE / 2;
}
/// Deploys both programs and runs `InitializeProgram` at `now`, leaving a fully
/// bootstrapped protocol. Shared by the init test and all three poke tests.
///
/// `controller_proportional_gain` is a parameter because the poke tests need a
/// live controller (a zero gain pins the redemption rate at `FIXED_POINT_ONE`,
/// which would make the update assertions vacuous).
fn initialize_protocol(now: u64, controller_proportional_gain: i128) -> V03State {
use stablecoin_core::math::FIXED_POINT_ONE;
// `V03State::new()` no longer auto-creates the clock account; seed CLOCK_01 with this
// timestamp, which initialize_program reads as `now` to anchor the accumulator and
// redemption-price state.
let mut state = V03State::new();
seed_clock(&mut state, now);
deploy_programs(&mut state);
// Externally-created collateral definition + market-price oracle.
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
state.force_insert_account(
Ids::oracle(),
Accounts::oracle_init(
Ids::stablecoin_definition_pda(),
Ids::collateral_definition(),
),
);
let instruction = stablecoin_core::Instruction::InitializeProgram {
freeze_authority_account_id: Ids::freeze_authority(),
initial_stability_fee_per_millisecond: protocol_config::STABILITY_FEE_PER_MILLISECOND,
initial_controller_proportional_gain: controller_proportional_gain,
initial_controller_integral_gain: 0,
initial_minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2,
minimum_milliseconds_between_rate_updates:
protocol_config::MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES,
maximum_oracle_price_age_milliseconds:
protocol_config::MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS,
initial_redemption_price: protocol_config::INITIAL_REDEMPTION_PRICE,
stablecoin_name: String::from("test-stable"),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::admin(),
compute_protocol_parameters_pda(Ids::stablecoin_program()),
compute_stability_fee_accumulator_pda(Ids::stablecoin_program()),
compute_redemption_price_state_pda(Ids::stablecoin_program()),
Ids::stablecoin_definition_pda(),
compute_stablecoin_master_holding_pda(Ids::stablecoin_program()),
Ids::collateral_definition(),
Ids::oracle(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![current_nonce(&state, Ids::admin())],
instruction,
)
.expect("valid initialize_program message");
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::admin()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 1, now)
.expect("initialize_program must succeed");
state
}
/// Submits a no-parameter poke signed by the admin (pokes are permissionless —
/// the admin key is just a convenient signer) and advances the clock to `now`
/// first, so the guest reads the intended timestamp.
fn submit_poke(
state: &mut V03State,
now: u64,
block_id: u64,
instruction: stablecoin_core::Instruction,
accounts: Vec<AccountId>,
) -> Result<(), LeeError> {
seed_clock(state, now);
let mut account_ids = vec![Ids::admin()];
account_ids.extend(accounts);
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
account_ids,
vec![current_nonce(state, Ids::admin())],
instruction,
)
.expect("valid poke message");
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::admin()]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, block_id, now)
}
fn read_accumulator(state: &V03State) -> stablecoin_core::StabilityFeeAccumulator {
stablecoin_core::StabilityFeeAccumulator::try_from(
&state
.get_account_by_id(compute_stability_fee_accumulator_pda(
Ids::stablecoin_program(),
))
.data,
)
.expect("valid StabilityFeeAccumulator")
}
fn read_redemption_price_state(state: &V03State) -> stablecoin_core::RedemptionPriceState {
stablecoin_core::RedemptionPriceState::try_from(
&state
.get_account_by_id(compute_redemption_price_state_pda(Ids::stablecoin_program()))
.data,
)
.expect("valid RedemptionPriceState")
}
#[test]
fn stablecoin_initialize_program_creates_globals_and_stablecoin_definition() {
use stablecoin_core::math::FIXED_POINT_ONE;
let now: u64 = 1_700_000_000;
let state = initialize_protocol(now, 0);
// ProtocolParameters claimed with the expected handles.
let pp = stablecoin_core::ProtocolParameters::try_from(
&state
.get_account_by_id(compute_protocol_parameters_pda(Ids::stablecoin_program()))
.data,
)
.expect("valid ProtocolParameters");
assert_eq!(pp.admin_account_id, Ids::admin());
assert_eq!(pp.freeze_authority_account_id, Ids::freeze_authority());
assert_eq!(
pp.stablecoin_definition_id,
Ids::stablecoin_definition_pda()
);
assert_eq!(pp.collateral_definition_id, Ids::collateral_definition());
assert_eq!(pp.market_price_oracle_id, Ids::oracle());
assert!(!pp.is_frozen);
// Accumulator anchored at FIXED_POINT_ONE / now.
let acc = stablecoin_core::StabilityFeeAccumulator::try_from(
&state
.get_account_by_id(compute_stability_fee_accumulator_pda(
Ids::stablecoin_program(),
))
.data,
)
.expect("valid StabilityFeeAccumulator");
assert_eq!(acc.accumulated_rate_at_last_accrual, FIXED_POINT_ONE);
assert_eq!(acc.last_accrued_at, now);
// Redemption price anchored at the initial value / now.
let rp = stablecoin_core::RedemptionPriceState::try_from(
&state
.get_account_by_id(compute_redemption_price_state_pda(Ids::stablecoin_program()))
.data,
)
.expect("valid RedemptionPriceState");
assert_eq!(rp.redemption_price_at_last_update, FIXED_POINT_ONE / 2);
assert_eq!(rp.redemption_rate_per_millisecond, FIXED_POINT_ONE);
assert_eq!(rp.controller_integral_term, 0);
assert_eq!(rp.last_updated_at, now);
// Stablecoin definition created via the chained Token::NewFungibleDefinition.
let definition = TokenDefinition::try_from(
&state
.get_account_by_id(Ids::stablecoin_definition_pda())
.data,
)
.expect("valid TokenDefinition");
match definition {
TokenDefinition::Fungible {
name,
total_supply,
metadata_id,
authority,
} => {
assert_eq!(name, "test-stable");
assert_eq!(total_supply, 0);
assert_eq!(metadata_id, None);
// Self/PDA authority: the definition is its own mint authority, so later
// debt operations can mint/burn by presenting the definition PDA seed.
assert_eq!(authority, Some(Ids::stablecoin_definition_pda()));
}
TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"),
}
// Empty master holding created alongside the definition.
let master = TokenHolding::try_from(
&state
.get_account_by_id(compute_stablecoin_master_holding_pda(
Ids::stablecoin_program(),
))
.data,
)
.expect("valid TokenHolding");
match master {
TokenHolding::Fungible {
definition_id,
balance,
} => {
assert_eq!(definition_id, Ids::stablecoin_definition_pda());
assert_eq!(balance, 0);
}
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => {
panic!("expected Fungible holding")
}
}
}
#[test]
fn stablecoin_accrue_stability_fee_advances_accumulator() {
use stablecoin_core::math::FIXED_POINT_ONE;
let start: u64 = 1_700_000_000_000;
let mut state = initialize_protocol(start, 0);
let before = read_accumulator(&state);
assert_eq!(before.accumulated_rate_at_last_accrual, FIXED_POINT_ONE);
assert_eq!(before.last_accrued_at, start);
// Accrual has no throttle, so any positive delta works; one hour, in ms.
let now = start + 3_600_000;
submit_poke(
&mut state,
now,
2,
stablecoin_core::Instruction::AccrueStabilityFee,
vec![
compute_protocol_parameters_pda(Ids::stablecoin_program()),
compute_stability_fee_accumulator_pda(Ids::stablecoin_program()),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
)
.expect("accrue_stability_fee must succeed");
let after = read_accumulator(&state);
assert_eq!(
after.accumulated_rate_at_last_accrual,
stablecoin_core::math::compute_current_accumulated_rate(
FIXED_POINT_ONE,
protocol_config::STABILITY_FEE_PER_MILLISECOND,
start,
now,
),
);
assert!(after.accumulated_rate_at_last_accrual > FIXED_POINT_ONE);
assert_eq!(after.last_accrued_at, now);
}
#[test]
fn stablecoin_update_redemption_rate_drifts_redemption_price() {
use stablecoin_core::math::FIXED_POINT_ONE;
let start: u64 = 1_700_000_000_000;
// A live proportional gain, otherwise the rate would stay pinned at 1.0.
let mut state = initialize_protocol(
start,
i128::try_from(FIXED_POINT_ONE).expect("FIXED_POINT_ONE fits i128"),
);
// Ten minutes later — past the 300_000 ms minimum interval.
let now = start + 600_000;
// A fresh observation well below the 0.5 redemption target, so
// error = redemption market > 0.
state.force_insert_account(
Ids::oracle(),
Accounts::oracle_with(
Ids::stablecoin_definition_pda(),
Ids::collateral_definition(),
FIXED_POINT_ONE / 4,
now,
),
);
submit_poke(
&mut state,
now,
2,
stablecoin_core::Instruction::UpdateRedemptionRate,
vec![
compute_protocol_parameters_pda(Ids::stablecoin_program()),
compute_redemption_price_state_pda(Ids::stablecoin_program()),
Ids::oracle(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
)
.expect("update_redemption_rate must succeed");
let after = read_redemption_price_state(&state);
// Positive error with a positive Kp drives the rate ABOVE 1.0: the redemption
// price rises, pulling the market up toward it (negative feedback, no negation).
assert!(after.redemption_rate_per_millisecond > FIXED_POINT_ONE);
// The rate change is capped per update by RATE_DELTA_CLAMP.
assert_eq!(
after.redemption_rate_per_millisecond,
FIXED_POINT_ONE
+ u128::try_from(stablecoin_core::RATE_DELTA_CLAMP)
.expect("RATE_DELTA_CLAMP is positive"),
);
// Re-anchored at the price projected from the OLD rate (exactly 1.0, so the
// anchor is unchanged) and stamped with now.
assert_eq!(
after.redemption_price_at_last_update,
protocol_config::INITIAL_REDEMPTION_PRICE
);
assert_eq!(after.last_updated_at, now);
}
#[test]
fn stablecoin_refresh_globals_advances_both_then_fee_only_when_oracle_stale() {
use stablecoin_core::math::FIXED_POINT_ONE;
let start: u64 = 1_700_000_000_000;
let mut state = initialize_protocol(
start,
i128::try_from(FIXED_POINT_ONE).expect("FIXED_POINT_ONE fits i128"),
);
let refresh_accounts = || {
vec![
compute_protocol_parameters_pda(Ids::stablecoin_program()),
compute_stability_fee_accumulator_pda(Ids::stablecoin_program()),
compute_redemption_price_state_pda(Ids::stablecoin_program()),
Ids::oracle(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
]
};
// --- Pass 1: fresh oracle, interval due => BOTH halves run. ---
let first = start + 600_000;
state.force_insert_account(
Ids::oracle(),
Accounts::oracle_with(
Ids::stablecoin_definition_pda(),
Ids::collateral_definition(),
FIXED_POINT_ONE / 4,
first,
),
);
submit_poke(
&mut state,
first,
2,
stablecoin_core::Instruction::RefreshGlobals,
refresh_accounts(),
)
.expect("refresh_globals must advance both halves");
let accumulator_after_first = read_accumulator(&state);
let redemption_after_first = read_redemption_price_state(&state);
assert_eq!(accumulator_after_first.last_accrued_at, first);
assert!(accumulator_after_first.accumulated_rate_at_last_accrual > FIXED_POINT_ONE);
assert!(redemption_after_first.redemption_rate_per_millisecond > FIXED_POINT_ONE);
assert_eq!(redemption_after_first.last_updated_at, first);
// --- Pass 2: the oracle observation is left where it was and the clock jumps
// well past the max age => only the fee half runs, and the call still succeeds. ---
let second = first + 2_000_000; // > the 900_000 ms maximum oracle age
submit_poke(
&mut state,
second,
3,
stablecoin_core::Instruction::RefreshGlobals,
refresh_accounts(),
)
.expect("refresh_globals must still succeed with a stale oracle");
let accumulator_after_second = read_accumulator(&state);
let redemption_after_second = read_redemption_price_state(&state);
// Fee half ran again.
assert_eq!(accumulator_after_second.last_accrued_at, second);
assert!(
accumulator_after_second.accumulated_rate_at_last_accrual
> accumulator_after_first.accumulated_rate_at_last_accrual
);
// Redemption half skipped without panicking — byte-identical to pass 1.
assert_eq!(redemption_after_second, redemption_after_first);
}