feat(stablecoin): Implement stability fee accrual

This commit is contained in:
Ricardo Guilherme Schmidt 2026-06-24 14:41:38 -03:00
parent fe4c7a96da
commit 2df239e742
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
17 changed files with 3418 additions and 1550 deletions

11
Cargo.lock generated
View File

@ -462,9 +462,9 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.7"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ata-methods"
@ -3307,9 +3307,9 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18"
[[package]]
name = "rustc-hash"
version = "2.1.2"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc-hex"
@ -3788,9 +3788,12 @@ dependencies = [
name = "stablecoin_program"
version = "0.1.0"
dependencies = [
"borsh",
"clock_core",
"lee_core",
"stablecoin_core",
"token_core",
"twap_oracle_core",
]
[[package]]

View File

@ -2,6 +2,168 @@
"version": "0.1.0",
"name": "stablecoin",
"instructions": [
{
"name": "initialize_program",
"accounts": [
{
"name": "admin",
"writable": false,
"signer": false,
"init": false
},
{
"name": "protocol_parameters",
"writable": false,
"signer": false,
"init": false
},
{
"name": "stability_fee_accumulator",
"writable": false,
"signer": false,
"init": false
},
{
"name": "redemption_price_state",
"writable": false,
"signer": false,
"init": false
},
{
"name": "stablecoin_definition",
"writable": false,
"signer": false,
"init": false
},
{
"name": "stablecoin_master_holding",
"writable": false,
"signer": false,
"init": false
},
{
"name": "collateral_definition",
"writable": false,
"signer": false,
"init": false
},
{
"name": "market_price_oracle",
"writable": false,
"signer": false,
"init": false
},
{
"name": "clock",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
{
"name": "freeze_authority_account_id",
"type": "account_id"
},
{
"name": "initial_stability_fee_per_millisecond",
"type": "u128"
},
{
"name": "initial_controller_proportional_gain",
"type": "i128"
},
{
"name": "initial_controller_integral_gain",
"type": "i128"
},
{
"name": "initial_minimum_collateralization_ratio",
"type": "u128"
},
{
"name": "minimum_milliseconds_between_rate_updates",
"type": "u64"
},
{
"name": "maximum_oracle_price_age_milliseconds",
"type": "u64"
},
{
"name": "initial_redemption_price",
"type": "u128"
},
{
"name": "stablecoin_name",
"type": "string"
}
]
},
{
"name": "accrue_stability_fee",
"accounts": [
{
"name": "caller",
"writable": false,
"signer": false,
"init": false
},
{
"name": "protocol_parameters",
"writable": false,
"signer": false,
"init": false
},
{
"name": "stability_fee_accumulator",
"writable": false,
"signer": false,
"init": false
},
{
"name": "clock",
"writable": false,
"signer": false,
"init": false
}
],
"args": []
},
{
"name": "set_stability_fee_per_millisecond",
"accounts": [
{
"name": "admin",
"writable": false,
"signer": false,
"init": false
},
{
"name": "protocol_parameters",
"writable": false,
"signer": false,
"init": false
},
{
"name": "stability_fee_accumulator",
"writable": false,
"signer": false,
"init": false
},
{
"name": "clock",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
{
"name": "new_rate",
"type": "u128"
}
]
},
{
"name": "open_position",
"accounts": [
@ -30,7 +192,19 @@
"init": false
},
{
"name": "token_definition",
"name": "collateral_definition",
"writable": false,
"signer": false,
"init": false
},
{
"name": "protocol_parameters",
"writable": false,
"signer": false,
"init": false
},
{
"name": "clock",
"writable": false,
"signer": false,
"init": false
@ -38,7 +212,76 @@
],
"args": [
{
"name": "collateral_amount",
"name": "position_nonce",
"type": "u64"
},
{
"name": "initial_collateral_amount",
"type": "u128"
}
]
},
{
"name": "generate_debt",
"accounts": [
{
"name": "owner",
"writable": false,
"signer": false,
"init": false
},
{
"name": "position",
"writable": false,
"signer": false,
"init": false
},
{
"name": "stablecoin_definition",
"writable": false,
"signer": false,
"init": false
},
{
"name": "user_stablecoin_holding",
"writable": false,
"signer": false,
"init": false
},
{
"name": "stability_fee_accumulator",
"writable": false,
"signer": false,
"init": false
},
{
"name": "redemption_price_state",
"writable": false,
"signer": false,
"init": false
},
{
"name": "market_price_oracle",
"writable": false,
"signer": false,
"init": false
},
{
"name": "protocol_parameters",
"writable": false,
"signer": false,
"init": false
},
{
"name": "clock",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
{
"name": "amount",
"type": "u128"
}
]
@ -69,6 +312,30 @@
"writable": true,
"signer": false,
"init": false
},
{
"name": "stability_fee_accumulator",
"writable": false,
"signer": false,
"init": false
},
{
"name": "redemption_price_state",
"writable": false,
"signer": false,
"init": false
},
{
"name": "protocol_parameters",
"writable": false,
"signer": false,
"init": false
},
{
"name": "clock",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
@ -104,6 +371,24 @@
"writable": true,
"signer": true,
"init": false
},
{
"name": "stability_fee_accumulator",
"writable": false,
"signer": false,
"init": false
},
{
"name": "protocol_parameters",
"writable": false,
"signer": false,
"init": false
},
{
"name": "clock",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
@ -116,25 +401,129 @@
],
"accounts": [
{
"name": "Position",
"name": "ProtocolParameters",
"type": {
"kind": "struct",
"fields": [
{
"name": "collateral_vault_id",
"name": "admin_account_id",
"type": "account_id"
},
{
"name": "freeze_authority_account_id",
"type": "account_id"
},
{
"name": "stablecoin_definition_id",
"type": "account_id"
},
{
"name": "collateral_definition_id",
"type": "account_id"
},
{
"name": "market_price_oracle_id",
"type": "account_id"
},
{
"name": "stability_fee_per_millisecond",
"type": "u128"
},
{
"name": "controller_proportional_gain",
"type": "i128"
},
{
"name": "controller_integral_gain",
"type": "i128"
},
{
"name": "minimum_collateralization_ratio",
"type": "u128"
},
{
"name": "minimum_milliseconds_between_rate_updates",
"type": "u64"
},
{
"name": "maximum_oracle_price_age_milliseconds",
"type": "u64"
},
{
"name": "is_frozen",
"type": "bool"
}
]
}
},
{
"name": "StabilityFeeAccumulator",
"type": {
"kind": "struct",
"fields": [
{
"name": "accumulated_rate_at_last_accrual",
"type": "u128"
},
{
"name": "last_accrued_at",
"type": "u64"
}
]
}
},
{
"name": "RedemptionPriceState",
"type": {
"kind": "struct",
"fields": [
{
"name": "redemption_price_at_last_update",
"type": "u128"
},
{
"name": "redemption_rate_per_millisecond",
"type": "u128"
},
{
"name": "controller_integral_term",
"type": "i128"
},
{
"name": "last_updated_at",
"type": "u64"
}
]
}
},
{
"name": "Position",
"type": {
"kind": "struct",
"fields": [
{
"name": "owner_account_id",
"type": "account_id"
},
{
"name": "position_nonce",
"type": "u64"
},
{
"name": "vault_account_id",
"type": "account_id"
},
{
"name": "collateral_amount",
"type": "u128"
},
{
"name": "debt_amount",
"name": "normalized_debt_amount",
"type": "u128"
},
{
"name": "opened_at",
"type": "u64"
}
]
}

View File

@ -1,10 +1,29 @@
#![allow(
clippy::panic,
clippy::unwrap_used,
reason = "integration fixtures use fixed balances and transaction assertions"
)]
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, Position};
use stablecoin_core::{
compound_rate, 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, ProtocolParameters, RedemptionPriceState,
StabilityFeeAccumulator, FIXED_POINT_ONE,
};
use token_core::{TokenDefinition, TokenHolding};
use twap_oracle_core::OraclePriceAccount;
const POSITION_NONCE: u64 = 7;
const CLOCK_START: u64 = 1_000;
const Q64_ONE: u128 = 18_446_744_073_709_551_616;
struct Keys;
struct Ids;
@ -12,11 +31,15 @@ struct Balances;
struct Accounts;
impl Keys {
fn admin() -> PrivateKey {
PrivateKey::try_new([40; 32]).expect("valid private key")
}
fn owner() -> PrivateKey {
PrivateKey::try_new([41; 32]).expect("valid private key")
}
fn user_holding() -> PrivateKey {
fn user_collateral_holding() -> PrivateKey {
PrivateKey::try_new([42; 32]).expect("valid private key")
}
@ -34,20 +57,54 @@ impl Ids {
stablecoin_methods::STABLECOIN_ID
}
fn collateral_definition() -> AccountId {
AccountId::new([5; 32])
fn twap_oracle_program() -> nssa_core::program::ProgramId {
twap_oracle_methods::TWAP_ORACLE_ID
}
fn admin() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::admin()))
}
fn freeze_authority() -> AccountId {
AccountId::new([44; 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 collateral_definition() -> AccountId {
AccountId::new([5; 32])
}
fn user_collateral_holding() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(
&Keys::user_collateral_holding(),
))
}
fn oracle() -> AccountId {
AccountId::new([7; 32])
}
fn protocol_parameters() -> AccountId {
compute_protocol_parameters_pda(Self::stablecoin_program())
}
fn stability_fee_accumulator() -> AccountId {
compute_stability_fee_accumulator_pda(Self::stablecoin_program())
}
fn redemption_price_state() -> AccountId {
compute_redemption_price_state_pda(Self::stablecoin_program())
}
fn stablecoin_definition() -> AccountId {
AccountId::new([6; 32])
compute_stablecoin_definition_pda(Self::stablecoin_program())
}
fn stablecoin_master_holding() -> AccountId {
compute_stablecoin_master_holding_pda(Self::stablecoin_program())
}
fn user_stablecoin_holding() -> AccountId {
@ -57,11 +114,7 @@ impl Ids {
}
fn position() -> AccountId {
compute_position_pda(
Self::stablecoin_program(),
Self::owner(),
Self::collateral_definition(),
)
compute_position_pda(Self::stablecoin_program(), Self::owner(), POSITION_NONCE)
}
fn vault() -> AccountId {
@ -70,43 +123,31 @@ impl Ids {
}
impl Balances {
fn user_holding_init() -> u128 {
fn user_collateral_init() -> u128 {
1_000_000
}
fn collateral_deposit() -> u128 {
500_000
500
}
fn collateral_withdraw() -> u128 {
200_000
400
}
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 {
fn generated_debt() -> u128 {
100
}
}
impl Accounts {
fn collateral_definition_init() -> Account {
fn collateral_definition() -> Account {
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
balance: 0,
data: Data::from(&TokenDefinition::Fungible {
name: String::from("Gold"),
total_supply: Balances::user_holding_init(),
total_supply: Balances::user_collateral_init(),
metadata_id: None,
authority: None,
}),
@ -114,53 +155,41 @@ impl Accounts {
}
}
fn user_holding_init() -> Account {
fn user_collateral_holding() -> Account {
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: Balances::user_holding_init(),
balance: Balances::user_collateral_init(),
}),
nonce: Nonce(0),
}
}
fn stablecoin_definition_init() -> Account {
fn user_stablecoin_holding() -> 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,
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::stablecoin_definition(),
balance: Balances::user_stablecoin_holding_init(),
balance: 0,
}),
nonce: Nonce(0),
}
}
fn position_with_debt_init() -> Account {
fn oracle(timestamp: u64) -> Account {
Account {
program_owner: stablecoin_methods::STABLECOIN_ID,
balance: 0_u128,
data: Data::from(&Position {
collateral_vault_id: Ids::vault(),
collateral_definition_id: Ids::collateral_definition(),
collateral_amount: Balances::collateral_deposit(),
debt_amount: Balances::initial_debt(),
program_owner: Ids::twap_oracle_program(),
balance: 0,
data: Data::from(&OraclePriceAccount {
base_asset: Ids::stablecoin_definition(),
quote_asset: Ids::collateral_definition(),
price: Q64_ONE,
timestamp,
source_id: AccountId::new([8; 32]),
confidence_interval: 0,
}),
nonce: Nonce(0),
}
@ -190,9 +219,18 @@ fn state_for_stablecoin_tests() -> V03State {
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
Accounts::collateral_definition(),
);
state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init());
state.force_insert_account(
Ids::user_collateral_holding(),
Accounts::user_collateral_holding(),
);
state.force_insert_account(
Ids::user_stablecoin_holding(),
Accounts::user_stablecoin_holding(),
);
state.force_insert_account(Ids::oracle(), Accounts::oracle(CLOCK_START));
advance_clock(&mut state, CLOCK_START);
state
}
@ -200,61 +238,59 @@ 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.debt_amount, 0);
assert_eq!(position.collateral_vault_id, Ids::vault());
assert_eq!(
position.collateral_definition_id,
Ids::collateral_definition()
);
}
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")
}
fn advance_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);
}
#[test]
fn stablecoin_open_position_then_withdraw_collateral() {
let mut state = state_for_stablecoin_tests();
fn initialize_protocol(state: &mut V03State, rate: u128) {
let instruction = stablecoin_core::Instruction::InitializeProgram {
freeze_authority_account_id: Ids::freeze_authority(),
initial_stability_fee_per_millisecond: rate,
initial_controller_proportional_gain: 0,
initial_controller_integral_gain: 0,
initial_minimum_collateralization_ratio: FIXED_POINT_ONE / 10 * 11,
minimum_milliseconds_between_rate_updates: 1,
maximum_oracle_price_age_milliseconds: 86_400_000,
initial_redemption_price: FIXED_POINT_ONE,
stablecoin_name: String::from("DAI"),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::admin(),
Ids::protocol_parameters(),
Ids::stability_fee_accumulator(),
Ids::redemption_price_state(),
Ids::stablecoin_definition(),
Ids::stablecoin_master_holding(),
Ids::collateral_definition(),
Ids::oracle(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![current_nonce(state, Ids::admin())],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::admin()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, 0)
.expect("initialize_program must succeed");
}
// Open the position: deposit collateral from the user's holding into a fresh vault.
let open = stablecoin_core::Instruction::OpenPosition {
collateral_amount: Balances::collateral_deposit(),
fn open_position(state: &mut V03State) {
let instruction = stablecoin_core::Instruction::OpenPosition {
position_nonce: POSITION_NONCE,
initial_collateral_amount: Balances::collateral_deposit(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
@ -262,78 +298,31 @@ fn stablecoin_open_position_then_withdraw_collateral() {
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::user_holding(),
Ids::user_collateral_holding(),
Ids::collateral_definition(),
Ids::protocol_parameters(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_holding()),
current_nonce(state, Ids::owner()),
current_nonce(state, Ids::user_collateral_holding()),
],
open,
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::owner(), &Keys::user_holding()],
&[&Keys::owner(), &Keys::user_collateral_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(),
fn generate_debt(state: &mut V03State) {
let instruction = stablecoin_core::Instruction::GenerateDebt {
amount: Balances::generated_debt(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
@ -342,12 +331,87 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() {
Ids::position(),
Ids::stablecoin_definition(),
Ids::user_stablecoin_holding(),
Ids::stability_fee_accumulator(),
Ids::redemption_price_state(),
Ids::oracle(),
Ids::protocol_parameters(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![current_nonce(state, Ids::owner())],
instruction,
)
.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("generate_debt must succeed");
}
fn accrue_stability_fee(state: &mut V03State) {
execute_accrue_stability_fee(state).expect("accrue_stability_fee must succeed");
}
fn execute_accrue_stability_fee(state: &mut V03State) -> Result<(), LeeError> {
let instruction = stablecoin_core::Instruction::AccrueStabilityFee;
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::protocol_parameters(),
Ids::stability_fee_accumulator(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![current_nonce(state, Ids::owner())],
instruction,
)
.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)
}
fn set_stability_fee_per_millisecond(state: &mut V03State, new_rate: u128) {
let instruction = stablecoin_core::Instruction::SetStabilityFeePerMillisecond { new_rate };
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::admin(),
Ids::protocol_parameters(),
Ids::stability_fee_accumulator(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![current_nonce(state, Ids::admin())],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::admin()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, 0)
.expect("set_stability_fee_per_millisecond must succeed");
}
fn repay_debt(state: &mut V03State) {
let instruction = stablecoin_core::Instruction::RepayDebt {
amount: Balances::generated_debt(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::stablecoin_definition(),
Ids::user_stablecoin_holding(),
Ids::stability_fee_accumulator(),
Ids::protocol_parameters(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_stablecoin_holding()),
current_nonce(state, Ids::owner()),
current_nonce(state, Ids::user_stablecoin_holding()),
],
repay,
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
@ -358,43 +422,224 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() {
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.debt_amount,
Balances::initial_debt() - Balances::debt_repay_amount()
);
assert_eq!(position.collateral_amount, Balances::collateral_deposit());
fn withdraw_collateral(state: &mut V03State) {
execute_withdraw_collateral(state, Balances::collateral_withdraw())
.expect("withdraw_collateral must succeed");
}
// 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"),
}
fn execute_withdraw_collateral(state: &mut V03State, amount: u128) -> Result<(), LeeError> {
let instruction = stablecoin_core::Instruction::WithdrawCollateral { amount };
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::user_collateral_holding(),
Ids::stability_fee_accumulator(),
Ids::redemption_price_state(),
Ids::protocol_parameters(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![current_nonce(state, Ids::owner())],
instruction,
)
.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)
}
// 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()
);
}
fn position(state: &V03State) -> Position {
Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position")
}
fn stability_fee_accumulator(state: &V03State) -> StabilityFeeAccumulator {
StabilityFeeAccumulator::try_from(
&state
.get_account_by_id(Ids::stability_fee_accumulator())
.data,
)
.expect("valid StabilityFeeAccumulator")
}
fn protocol_parameters(state: &V03State) -> ProtocolParameters {
ProtocolParameters::try_from(&state.get_account_by_id(Ids::protocol_parameters()).data)
.expect("valid ProtocolParameters")
}
fn redemption_price_state(state: &V03State) -> RedemptionPriceState {
RedemptionPriceState::try_from(&state.get_account_by_id(Ids::redemption_price_state()).data)
.expect("valid RedemptionPriceState")
}
fn fungible_balance(state: &V03State, account_id: AccountId) -> u128 {
match TokenHolding::try_from(&state.get_account_by_id(account_id).data)
.expect("valid TokenHolding")
{
TokenHolding::Fungible { balance, .. } => balance,
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => {
panic!("expected Fungible holding")
}
}
}
fn fungible_supply(state: &V03State, account_id: AccountId) -> u128 {
match TokenDefinition::try_from(&state.get_account_by_id(account_id).data)
.expect("valid TokenDefinition")
{
TokenDefinition::Fungible { total_supply, .. } => total_supply,
TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"),
}
}
#[test]
fn stablecoin_initialize_then_accrue_fee() {
let mut state = state_for_stablecoin_tests();
let rate = FIXED_POINT_ONE + FIXED_POINT_ONE / 10;
initialize_protocol(&mut state, rate);
let params = protocol_parameters(&state);
assert_eq!(params.stability_fee_per_millisecond, rate);
assert_eq!(
params.stablecoin_definition_id,
Ids::stablecoin_definition()
);
assert_eq!(
params.collateral_definition_id,
Ids::collateral_definition()
);
assert_eq!(params.market_price_oracle_id, Ids::oracle());
assert_eq!(fungible_supply(&state, Ids::stablecoin_definition()), 0);
let accumulator = stability_fee_accumulator(&state);
assert_eq!(
accumulator.accumulated_rate_at_last_accrual,
FIXED_POINT_ONE
);
assert_eq!(accumulator.last_accrued_at, CLOCK_START);
let redemption = redemption_price_state(&state);
assert_eq!(redemption.redemption_price_at_last_update, FIXED_POINT_ONE);
assert_eq!(redemption.last_updated_at, CLOCK_START);
advance_clock(&mut state, CLOCK_START + 2);
accrue_stability_fee(&mut state);
let updated = stability_fee_accumulator(&state);
assert_eq!(
updated.accumulated_rate_at_last_accrual,
compound_rate(rate, 2)
);
assert_eq!(updated.last_accrued_at, CLOCK_START + 2);
}
#[test]
fn stablecoin_set_fee_rate_anchors_old_rate_before_switching() {
let mut state = state_for_stablecoin_tests();
let old_rate = FIXED_POINT_ONE + FIXED_POINT_ONE / 10;
initialize_protocol(&mut state, old_rate);
advance_clock(&mut state, CLOCK_START + 2);
set_stability_fee_per_millisecond(&mut state, FIXED_POINT_ONE);
let updated = stability_fee_accumulator(&state);
let old_window_anchor = compound_rate(old_rate, 2);
assert_eq!(updated.accumulated_rate_at_last_accrual, old_window_anchor);
assert_eq!(updated.last_accrued_at, CLOCK_START + 2);
assert_eq!(
protocol_parameters(&state).stability_fee_per_millisecond,
FIXED_POINT_ONE
);
advance_clock(&mut state, CLOCK_START + 4);
accrue_stability_fee(&mut state);
let reanchored = stability_fee_accumulator(&state);
assert_eq!(
reanchored.accumulated_rate_at_last_accrual,
old_window_anchor
);
assert_eq!(reanchored.last_accrued_at, CLOCK_START + 4);
}
#[test]
fn stablecoin_open_generate_repay_and_withdraw_flow() {
let mut state = state_for_stablecoin_tests();
initialize_protocol(&mut state, FIXED_POINT_ONE);
open_position(&mut state);
let opened = position(&state);
assert_eq!(opened.owner_account_id, Ids::owner());
assert_eq!(opened.position_nonce, POSITION_NONCE);
assert_eq!(opened.vault_account_id, Ids::vault());
assert_eq!(opened.collateral_amount, Balances::collateral_deposit());
assert_eq!(opened.normalized_debt_amount, 0);
assert_eq!(opened.opened_at, CLOCK_START);
assert_eq!(
fungible_balance(&state, Ids::user_collateral_holding()),
Balances::user_collateral_init() - Balances::collateral_deposit()
);
assert_eq!(
fungible_balance(&state, Ids::vault()),
Balances::collateral_deposit()
);
generate_debt(&mut state);
advance_clock(&mut state, CLOCK_START + 1);
accrue_stability_fee(&mut state);
let indebted = position(&state);
assert_eq!(indebted.normalized_debt_amount, Balances::generated_debt());
assert_eq!(
fungible_supply(&state, Ids::stablecoin_definition()),
Balances::generated_debt()
);
assert_eq!(
fungible_balance(&state, Ids::user_stablecoin_holding()),
Balances::generated_debt()
);
repay_debt(&mut state);
let repaid = position(&state);
assert_eq!(repaid.normalized_debt_amount, 0);
assert_eq!(fungible_supply(&state, Ids::stablecoin_definition()), 0);
assert_eq!(fungible_balance(&state, Ids::user_stablecoin_holding()), 0);
withdraw_collateral(&mut state);
let withdrawn = position(&state);
assert_eq!(
withdrawn.collateral_amount,
Balances::collateral_deposit() - Balances::collateral_withdraw()
);
assert_eq!(
fungible_balance(&state, Ids::vault()),
Balances::collateral_deposit() - Balances::collateral_withdraw()
);
assert_eq!(
fungible_balance(&state, Ids::user_collateral_holding()),
Balances::user_collateral_init() - Balances::collateral_deposit()
+ Balances::collateral_withdraw()
);
}
#[test]
fn stablecoin_debt_bearing_withdraw_checks_collateralization() {
let mut state = state_for_stablecoin_tests();
initialize_protocol(&mut state, FIXED_POINT_ONE);
open_position(&mut state);
generate_debt(&mut state);
assert!(matches!(
execute_withdraw_collateral(&mut state, 400),
Err(LeeError::ProgramExecutionFailed(_))
));
assert_eq!(
position(&state).collateral_amount,
Balances::collateral_deposit()
);
execute_withdraw_collateral(&mut state, 300)
.expect("safe debt-bearing withdrawal must succeed");
assert_eq!(position(&state).collateral_amount, 200);
assert_eq!(fungible_balance(&state, Ids::vault()), 200);
}

View File

@ -5,5 +5,8 @@ edition = "2021"
[dependencies]
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6", features = ["host"], package = "lee_core" }
clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" }
borsh = { version = "1.5", features = ["derive"] }
stablecoin_core = { path = "core" }
token_core = { path = "../token/core" }
twap_oracle_core = { path = "../twap_oracle/core" }

View File

@ -2,7 +2,9 @@
pub mod math;
use alloy_primitives::U256;
use borsh::{BorshDeserialize, BorshSerialize};
pub use math::{compound_rate, mul_div as mul_div_floor, mul_div_ceil, FIXED_POINT_ONE};
use nssa_core::{
account::{AccountId, AccountWithMetadata, Data},
program::{PdaSeed, ProgramId},
@ -10,93 +12,155 @@ use nssa_core::{
use serde::{Deserialize, Serialize};
use spel_framework_macros::account_type;
// Stable domain-separation tags for the position PDAs; these must stay unchanged for address
// compatibility.
/// Maximum elapsed time rolled into one compounding projection.
pub const MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS: u64 = 86_400_000;
/// Maximum accepted stability-fee multiplier per millisecond.
pub const MAX_STABILITY_FEE_PER_MILLISECOND: u128 =
FIXED_POINT_ONE + FIXED_POINT_ONE / 100_000_000_000;
/// Minimum accepted collateralization ratio: 110%.
pub const MINIMUM_COLLATERALIZATION_RATIO_LOWER_BOUND: u128 = FIXED_POINT_ONE / 10 * 11;
/// Maximum accepted collateralization ratio: 1000%.
pub const MINIMUM_COLLATERALIZATION_RATIO_UPPER_BOUND: u128 = FIXED_POINT_ONE * 10;
/// Maximum accepted proportional gain magnitude.
pub const MAX_CONTROLLER_PROPORTIONAL_GAIN: u128 = FIXED_POINT_ONE * 1_000;
/// Maximum accepted integral gain magnitude.
pub const MAX_CONTROLLER_INTEGRAL_GAIN: u128 = FIXED_POINT_ONE;
/// Maximum accepted timing parameter in milliseconds.
pub const MAX_TIMING_PARAMETER_MILLISECONDS: u64 = 86_400_000;
const PROTOCOL_PARAMETERS_PDA_DOMAIN: &[u8] = b"PROTOCOL_PARAMETERS";
const STABILITY_FEE_ACCUMULATOR_PDA_DOMAIN: &[u8] = b"STABILITY_FEE_ACCUMULATOR";
const REDEMPTION_PRICE_STATE_PDA_DOMAIN: &[u8] = b"REDEMPTION_PRICE_STATE";
const STABLECOIN_DEFINITION_PDA_DOMAIN: &[u8] = b"STABLECOIN_DEFINITION";
const STABLECOIN_MASTER_HOLDING_PDA_DOMAIN: &[u8] = b"STABLECOIN_MASTER_HOLDING";
const POSITION_PDA_DOMAIN: &[u8] = b"POSITION";
const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT";
/// Stablecoin Program Instruction.
#[derive(Debug, Serialize, Deserialize)]
pub enum Instruction {
/// Initialize protocol globals and stablecoin token definition.
InitializeProgram {
freeze_authority_account_id: AccountId,
initial_stability_fee_per_millisecond: u128,
initial_controller_proportional_gain: i128,
initial_controller_integral_gain: i128,
initial_minimum_collateralization_ratio: u128,
minimum_milliseconds_between_rate_updates: u64,
maximum_oracle_price_age_milliseconds: u64,
initial_redemption_price: u128,
stablecoin_name: String,
},
/// Permissionlessly roll the global stability-fee accumulator forward.
AccrueStabilityFee,
/// Update the stability-fee rate after accruing pending fees at the old rate.
SetStabilityFeePerMillisecond { new_rate: u128 },
/// Open a new collateral-only [`Position`] for the calling owner.
///
/// Required accounts (5):
/// - Owner account (authorized)
/// - Position account (uninitialized, address must match
/// `compute_position_pda(self_program_id, owner, token_definition)`)
/// - Position vault token holding account (uninitialized, address must match
/// `compute_position_vault_pda(self_program_id, position_id)`)
/// - Owner's source token holding for the collateral (authorized, initialized)
/// - Token definition account for the collateral (matches the user holding's `definition_id`;
/// its `program_owner` determines the Token Program used by the chained `InitializeAccount`
/// / `Transfer` calls)
OpenPosition {
/// User-chosen nonce that disambiguates multiple positions.
position_nonce: u64,
/// Amount of collateral tokens to deposit into the position vault.
collateral_amount: u128,
initial_collateral_amount: u128,
},
/// Mint stablecoin debt against an existing position.
GenerateDebt {
/// Amount of stablecoin to mint.
amount: u128,
},
/// Withdraw `amount` collateral tokens from a position back to a user-controlled holding.
///
/// Required accounts (4):
/// - Owner account (authorized)
/// - Position account (initialized, owned by `self_program_id`)
/// - Position vault token holding (address must match
/// `compute_position_vault_pda(self_program_id, position_id)`)
/// - Destination user collateral holding (initialized, owned by the vault's Token Program,
/// `TokenHolding.definition_id == Position.collateral_definition_id`)
///
/// `token_program_id` is derived from `vault.account.program_owner`;
/// `collateral_definition_id` is read from the decoded [`Position`].
///
/// **Note:** until issues #97/#96/#95 land, this instruction hard-asserts
/// `Position.debt_amount == 0` instead of accruing fees and checking the
/// collateralization ratio.
WithdrawCollateral {
/// Amount of collateral tokens to move from the vault back to `destination`.
amount: u128,
},
/// Repay `amount` of outstanding stablecoin debt against an existing position.
///
/// Required accounts (4):
/// - Owner account (authorized; binds caller-as-owner via position PDA re-derivation)
/// - Position account (initialized, owned by `self_program_id`)
/// - Stablecoin token definition account (the definition of the stablecoin being repaid)
/// - User's stablecoin holding (authorized, initialized, owned by the same Token Program as
/// the definition, with `TokenHolding.definition_id == stablecoin_definition.account_id`)
///
/// `token_program_id` is derived from `user_stablecoin_holding.account.program_owner`.
/// `collateral_definition_id` (for position PDA verification) is read from the
/// decoded [`Position`].
///
/// **Note:** until issue #97 (stability fee accrual) lands, this instruction does
/// not accrue fees before reducing debt. A `// TODO(#97)` comment in the host
/// function marks where the accrual code will plug in. Today every position has
/// `debt_amount = 0` (no `generate_debt` yet), so the precondition is vacuously met.
///
/// **Note:** until issue #91 (`generate_debt`) records the stablecoin definition
/// into `Position`, this instruction cannot validate that the passed
/// `stablecoin_token_definition` is the one this position's debt is denominated
/// in. The caller is trusted for that until then.
RepayDebt {
/// Amount of stablecoin debt to repay (also the amount burned from the user's holding).
/// Amount of stablecoin debt to repay and burn from the user's holding.
amount: u128,
},
}
/// Protocol-level configuration account.
#[account_type]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct ProtocolParameters {
pub admin_account_id: AccountId,
pub freeze_authority_account_id: AccountId,
pub stablecoin_definition_id: AccountId,
pub collateral_definition_id: AccountId,
pub market_price_oracle_id: AccountId,
pub stability_fee_per_millisecond: u128,
pub controller_proportional_gain: i128,
pub controller_integral_gain: i128,
pub minimum_collateralization_ratio: u128,
pub minimum_milliseconds_between_rate_updates: u64,
pub maximum_oracle_price_age_milliseconds: u64,
pub is_frozen: bool,
}
/// Global stability-fee accumulator.
#[account_type]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct StabilityFeeAccumulator {
pub accumulated_rate_at_last_accrual: u128,
pub last_accrued_at: u64,
}
/// Redemption-price state used by collateralization checks.
#[account_type]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct RedemptionPriceState {
pub redemption_price_at_last_update: u128,
pub redemption_rate_per_millisecond: u128,
pub controller_integral_term: i128,
pub last_updated_at: u64,
}
/// Persistent state held by a Stablecoin [`Position`] account.
///
/// `debt_amount` is included for forward compatibility with `generate_debt`; until that
/// instruction lands `open_position` always initializes it to `0`.
#[account_type]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct Position {
/// Token holding account (vault PDA) that custodies the collateral backing this position.
pub collateral_vault_id: AccountId,
/// Token definition for the collateral held in `collateral_vault_id`.
pub collateral_definition_id: AccountId,
/// Owner authorized for every position operation.
pub owner_account_id: AccountId,
/// User-chosen nonce. Together with owner, it derives the position PDA.
pub position_nonce: u64,
/// Token holding account that custodies collateral for this position.
pub vault_account_id: AccountId,
/// Amount of collateral tokens deposited.
pub collateral_amount: u128,
/// Outstanding stablecoin debt against this position.
pub debt_amount: u128,
/// Debt shares. Nominal debt is derived from this and the current accumulator.
pub normalized_debt_amount: u128,
/// Unix timestamp in milliseconds when the position was opened.
pub opened_at: u64,
}
impl TryFrom<&Data> for ProtocolParameters {
type Error = std::io::Error;
fn try_from(data: &Data) -> Result<Self, Self::Error> {
Self::try_from_slice(data.as_ref())
}
}
impl TryFrom<&Data> for StabilityFeeAccumulator {
type Error = std::io::Error;
fn try_from(data: &Data) -> Result<Self, Self::Error> {
Self::try_from_slice(data.as_ref())
}
}
impl TryFrom<&Data> for RedemptionPriceState {
type Error = std::io::Error;
fn try_from(data: &Data) -> Result<Self, Self::Error> {
Self::try_from_slice(data.as_ref())
}
}
impl TryFrom<&Data> for Position {
@ -107,64 +171,158 @@ impl TryFrom<&Data> for Position {
}
}
impl From<&Position> for Data {
fn from(position: &Position) -> Self {
let mut data = Vec::with_capacity(std::mem::size_of_val(position));
BorshSerialize::serialize(position, &mut data)
.expect("Serialization to Vec should not fail");
Self::try_from(data).expect("Position encoded data should fit into Data")
impl From<&ProtocolParameters> for Data {
fn from(params: &ProtocolParameters) -> Self {
serialize_to_data(params)
}
}
/// 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
/// so one owner can hold separate positions for separate collateral definitions.
pub fn compute_position_pda_seed(
owner_id: AccountId,
collateral_definition_id: AccountId,
) -> PdaSeed {
impl From<&StabilityFeeAccumulator> for Data {
fn from(accumulator: &StabilityFeeAccumulator) -> Self {
serialize_to_data(accumulator)
}
}
impl From<&RedemptionPriceState> for Data {
fn from(state: &RedemptionPriceState) -> Self {
serialize_to_data(state)
}
}
impl From<&Position> for Data {
fn from(position: &Position) -> Self {
serialize_to_data(position)
}
}
fn serialize_to_data<T: BorshSerialize>(value: &T) -> Data {
let bytes = borsh::to_vec(value).expect("Serialization to Vec should not fail");
Data::try_from(bytes).expect("Encoded account data should fit into Data")
}
fn hash_seed(parts: &[&[u8]]) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::new();
bytes.extend_from_slice(&owner_id.to_bytes());
bytes.extend_from_slice(&collateral_definition_id.to_bytes());
bytes.extend_from_slice(POSITION_PDA_DOMAIN);
let total_len = parts
.iter()
.try_fold(0usize, |acc, part| acc.checked_add(part.len()))
.expect("PDA seed length should fit in usize");
let mut bytes = Vec::with_capacity(total_len);
for part in parts {
bytes.extend_from_slice(part);
}
let mut out = [0u8; 32];
out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes());
PdaSeed::new(out)
}
/// PDA seed for the protocol parameters singleton.
#[must_use]
pub fn compute_protocol_parameters_pda_seed() -> PdaSeed {
hash_seed(&[PROTOCOL_PARAMETERS_PDA_DOMAIN])
}
/// Account id of the protocol parameters singleton.
#[must_use]
pub fn compute_protocol_parameters_pda(stablecoin_program_id: ProgramId) -> AccountId {
AccountId::for_public_pda(
&stablecoin_program_id,
&compute_protocol_parameters_pda_seed(),
)
}
/// PDA seed for the stability-fee accumulator singleton.
#[must_use]
pub fn compute_stability_fee_accumulator_pda_seed() -> PdaSeed {
hash_seed(&[STABILITY_FEE_ACCUMULATOR_PDA_DOMAIN])
}
/// Account id of the stability-fee accumulator singleton.
#[must_use]
pub fn compute_stability_fee_accumulator_pda(stablecoin_program_id: ProgramId) -> AccountId {
AccountId::for_public_pda(
&stablecoin_program_id,
&compute_stability_fee_accumulator_pda_seed(),
)
}
/// PDA seed for the redemption-price state singleton.
#[must_use]
pub fn compute_redemption_price_state_pda_seed() -> PdaSeed {
hash_seed(&[REDEMPTION_PRICE_STATE_PDA_DOMAIN])
}
/// Account id of the redemption-price state singleton.
#[must_use]
pub fn compute_redemption_price_state_pda(stablecoin_program_id: ProgramId) -> AccountId {
AccountId::for_public_pda(
&stablecoin_program_id,
&compute_redemption_price_state_pda_seed(),
)
}
/// PDA seed for the stablecoin token definition.
#[must_use]
pub fn compute_stablecoin_definition_pda_seed() -> PdaSeed {
hash_seed(&[STABLECOIN_DEFINITION_PDA_DOMAIN])
}
/// Account id of the stablecoin token definition.
#[must_use]
pub fn compute_stablecoin_definition_pda(stablecoin_program_id: ProgramId) -> AccountId {
AccountId::for_public_pda(
&stablecoin_program_id,
&compute_stablecoin_definition_pda_seed(),
)
}
/// PDA seed for the stablecoin token definition's paired master holding.
#[must_use]
pub fn compute_stablecoin_master_holding_pda_seed() -> PdaSeed {
hash_seed(&[STABLECOIN_MASTER_HOLDING_PDA_DOMAIN])
}
/// Account id of the stablecoin token definition's paired master holding.
#[must_use]
pub fn compute_stablecoin_master_holding_pda(stablecoin_program_id: ProgramId) -> AccountId {
AccountId::for_public_pda(
&stablecoin_program_id,
&compute_stablecoin_master_holding_pda_seed(),
)
}
/// PDA seed for a [`Position`] account owned by `owner_id` with `position_nonce`.
#[must_use]
pub fn compute_position_pda_seed(owner_id: AccountId, position_nonce: u64) -> PdaSeed {
hash_seed(&[
&owner_id.to_bytes(),
&position_nonce.to_le_bytes(),
POSITION_PDA_DOMAIN,
])
}
/// Account id of the [`Position`] PDA owned by `owner_id` under `stablecoin_program_id`.
#[must_use]
pub fn compute_position_pda(
stablecoin_program_id: ProgramId,
owner_id: AccountId,
collateral_definition_id: AccountId,
position_nonce: u64,
) -> AccountId {
AccountId::for_public_pda(
&stablecoin_program_id,
&compute_position_pda_seed(owner_id, collateral_definition_id),
&compute_position_pda_seed(owner_id, position_nonce),
)
}
/// PDA seed for the collateral vault token holding bound to a [`Position`].
///
/// Derived from the position's address with a distinct domain-separation tag so the vault
/// id cannot collide with the position id even though both PDAs share the same program.
#[must_use]
pub fn compute_position_vault_pda_seed(position_id: AccountId) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = Vec::new();
bytes.extend_from_slice(&position_id.to_bytes());
bytes.extend_from_slice(POSITION_VAULT_PDA_DOMAIN);
let mut out = [0u8; 32];
out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes());
PdaSeed::new(out)
hash_seed(&[&position_id.to_bytes(), POSITION_VAULT_PDA_DOMAIN])
}
/// Account id of the collateral vault PDA for `position_id` under `stablecoin_program_id`.
#[must_use]
pub fn compute_position_vault_pda(
stablecoin_program_id: ProgramId,
position_id: AccountId,
@ -175,20 +333,18 @@ pub fn compute_position_vault_pda(
)
}
/// Verify the position account's address matches
/// `(stablecoin_program_id, owner, collateral_definition_id)` and return the [`PdaSeed`] for
/// use in post-state claims.
/// Verify a position account's address and return the PDA seed for post-state claims.
///
/// # Panics
/// If `position.account_id` does not match the address derived from `owner`,
/// `collateral_definition_id`, and `stablecoin_program_id`.
/// If `position.account_id` does not match the expected PDA.
#[must_use]
pub fn verify_position_and_get_seed(
position: &AccountWithMetadata,
owner: &AccountWithMetadata,
collateral_definition_id: AccountId,
position_nonce: u64,
stablecoin_program_id: ProgramId,
) -> PdaSeed {
let seed = compute_position_pda_seed(owner.account_id, collateral_definition_id);
let seed = compute_position_pda_seed(owner.account_id, position_nonce);
let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed);
assert_eq!(
position.account_id, expected_id,
@ -197,12 +353,11 @@ pub fn verify_position_and_get_seed(
seed
}
/// Verify the vault account's address matches `(stablecoin_program_id, position)` and
/// return the [`PdaSeed`] for use in chained calls.
/// Verify a vault account's address and return the PDA seed for chained calls.
///
/// # Panics
/// If `vault.account_id` does not match the address derived from `position_id` and
/// `stablecoin_program_id`.
/// If `vault.account_id` does not match the expected PDA.
#[must_use]
pub fn verify_position_vault_and_get_seed(
vault: &AccountWithMetadata,
position_id: AccountId,
@ -216,3 +371,237 @@ pub fn verify_position_vault_and_get_seed(
);
seed
}
/// Projects the current global stability-fee accumulator.
///
/// # Panics
/// Panics if fixed-point multiplication overflows.
#[must_use]
pub fn current_accumulated_rate(
state: &StabilityFeeAccumulator,
params: &ProtocolParameters,
now: u64,
) -> u128 {
let elapsed = now
.saturating_sub(state.last_accrued_at)
.min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS);
let factor = compound_rate(params.stability_fee_per_millisecond, elapsed);
mul_div_floor(
state.accumulated_rate_at_last_accrual,
factor,
FIXED_POINT_ONE,
)
}
/// Projects the current redemption price.
///
/// # Panics
/// Panics if fixed-point multiplication overflows.
#[must_use]
pub fn current_redemption_price(state: &RedemptionPriceState, now: u64) -> u128 {
let elapsed = now
.saturating_sub(state.last_updated_at)
.min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS);
let factor = compound_rate(state.redemption_rate_per_millisecond, elapsed);
mul_div_floor(
state.redemption_price_at_last_update,
factor,
FIXED_POINT_ONE,
)
}
/// Computes nominal stablecoin debt from normalized position debt.
///
/// # Panics
/// Panics if fixed-point multiplication overflows.
#[must_use]
pub fn nominal_debt(normalized_debt_amount: u128, current_accumulator: u128) -> u128 {
mul_div_floor(normalized_debt_amount, current_accumulator, FIXED_POINT_ONE)
}
/// Checks the README collateralization inequality using 256-bit intermediates.
///
/// # Panics
/// Panics if an intermediate exceeds 256 bits.
#[must_use]
pub fn is_collateralized(
collateral_amount: u128,
normalized_debt_amount: u128,
current_accumulator: u128,
redemption_price: u128,
minimum_collateralization_ratio: u128,
) -> bool {
let nominal_debt = nominal_debt(normalized_debt_amount, current_accumulator);
if nominal_debt == 0 {
return true;
}
let fixed_point = U256::from(FIXED_POINT_ONE);
let left = U256::from(collateral_amount)
.checked_mul(fixed_point)
.and_then(|value| value.checked_mul(fixed_point))
.expect("collateral side should fit in U256");
let right = U256::from(nominal_debt)
.checked_mul(U256::from(redemption_price))
.and_then(|value| value.checked_mul(U256::from(minimum_collateralization_ratio)))
.expect("debt side should fit in U256");
left >= right
}
/// Validates protocol parameter bounds from the stablecoin README.
///
/// # Panics
/// Panics if any parameter is outside the accepted range.
pub fn assert_protocol_parameter_bounds(
stability_fee_per_millisecond: u128,
controller_proportional_gain: i128,
controller_integral_gain: i128,
minimum_collateralization_ratio: u128,
minimum_milliseconds_between_rate_updates: u64,
maximum_oracle_price_age_milliseconds: u64,
initial_redemption_price: u128,
) {
assert_valid_stability_fee_per_millisecond(stability_fee_per_millisecond);
assert!(
controller_proportional_gain.unsigned_abs() <= MAX_CONTROLLER_PROPORTIONAL_GAIN,
"Controller proportional gain is out of bounds"
);
assert!(
controller_integral_gain.unsigned_abs() <= MAX_CONTROLLER_INTEGRAL_GAIN,
"Controller integral gain is out of bounds"
);
assert!(
(MINIMUM_COLLATERALIZATION_RATIO_LOWER_BOUND..=MINIMUM_COLLATERALIZATION_RATIO_UPPER_BOUND)
.contains(&minimum_collateralization_ratio),
"Minimum collateralization ratio is out of bounds"
);
assert!(
(1..=MAX_TIMING_PARAMETER_MILLISECONDS)
.contains(&minimum_milliseconds_between_rate_updates),
"Minimum milliseconds between rate updates is out of bounds"
);
assert!(
(1..=MAX_TIMING_PARAMETER_MILLISECONDS).contains(&maximum_oracle_price_age_milliseconds),
"Maximum oracle price age is out of bounds"
);
assert!(
initial_redemption_price != 0,
"Initial redemption price must be non-zero"
);
}
/// Validates the stability-fee multiplier bound.
///
/// # Panics
/// Panics if `rate` is outside the accepted range.
pub fn assert_valid_stability_fee_per_millisecond(rate: u128) {
assert!(
(FIXED_POINT_ONE..=MAX_STABILITY_FEE_PER_MILLISECOND).contains(&rate),
"Stability fee per millisecond is out of bounds"
);
}
#[cfg(test)]
mod tests {
use super::*;
const PROGRAM_ID: ProgramId = [1u32; 8];
fn params(rate: u128) -> ProtocolParameters {
ProtocolParameters {
admin_account_id: AccountId::new([1u8; 32]),
freeze_authority_account_id: AccountId::new([2u8; 32]),
stablecoin_definition_id: AccountId::new([3u8; 32]),
collateral_definition_id: AccountId::new([4u8; 32]),
market_price_oracle_id: AccountId::new([5u8; 32]),
stability_fee_per_millisecond: rate,
controller_proportional_gain: 0,
controller_integral_gain: 0,
minimum_collateralization_ratio: MINIMUM_COLLATERALIZATION_RATIO_LOWER_BOUND,
minimum_milliseconds_between_rate_updates: 1,
maximum_oracle_price_age_milliseconds: 1,
is_frozen: false,
}
}
#[test]
fn compound_rate_returns_identity_for_zero_elapsed_time() {
assert_eq!(
compound_rate(FIXED_POINT_ONE + FIXED_POINT_ONE / 10, 0),
FIXED_POINT_ONE
);
}
#[test]
fn compound_rate_keeps_one_rate_at_identity() {
assert_eq!(compound_rate(FIXED_POINT_ONE, 123), FIXED_POINT_ONE);
}
#[test]
fn compound_rate_compounds_small_growth() {
let rate = FIXED_POINT_ONE + FIXED_POINT_ONE / 10;
assert_eq!(
compound_rate(rate, 2),
FIXED_POINT_ONE + FIXED_POINT_ONE / 5 + FIXED_POINT_ONE / 100
);
}
#[test]
fn mul_div_floor_and_ceil_split_remainders() {
assert_eq!(mul_div_floor(10, 10, 6), 16);
assert_eq!(mul_div_ceil(10, 10, 6), 17);
}
#[test]
fn current_accumulated_rate_clamps_elapsed_time() {
let rate = FIXED_POINT_ONE + 1;
let state = StabilityFeeAccumulator {
accumulated_rate_at_last_accrual: FIXED_POINT_ONE,
last_accrued_at: 0,
};
assert_eq!(
current_accumulated_rate(
&state,
&params(rate),
MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS + 1
),
compound_rate(rate, MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS)
);
}
#[test]
fn max_stability_fee_compounds_within_the_clamped_window() {
let factor = compound_rate(
MAX_STABILITY_FEE_PER_MILLISECOND,
MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS,
);
assert!(factor >= FIXED_POINT_ONE);
}
#[test]
#[should_panic(expected = "Stability fee per millisecond is out of bounds")]
fn stability_fee_bound_rejects_rate_above_safe_maximum() {
assert_valid_stability_fee_per_millisecond(MAX_STABILITY_FEE_PER_MILLISECOND + 1);
}
#[test]
fn nominal_debt_grows_with_accumulator() {
assert_eq!(
nominal_debt(100, FIXED_POINT_ONE + FIXED_POINT_ONE / 10),
110
);
}
#[test]
fn pda_helpers_keep_position_and_vault_derivations_distinct() {
let owner = AccountId::new([9u8; 32]);
let position = compute_position_pda(PROGRAM_ID, owner, 7);
let vault = compute_position_vault_pda(PROGRAM_ID, position);
assert_ne!(position, vault);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
#![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::prelude::*;
@ -12,11 +12,109 @@ mod stablecoin {
#[allow(unused_imports)]
use super::*;
/// Initialize protocol globals and the stablecoin token definition.
#[expect(
clippy::too_many_arguments,
reason = "instruction interface initializes all stablecoin singleton accounts"
)]
#[instruction]
pub fn initialize_program(
ctx: ProgramContext,
admin: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
redemption_price_state: AccountWithMetadata,
stablecoin_definition: AccountWithMetadata,
stablecoin_master_holding: AccountWithMetadata,
collateral_definition: AccountWithMetadata,
market_price_oracle: AccountWithMetadata,
clock: AccountWithMetadata,
freeze_authority_account_id: AccountId,
initial_stability_fee_per_millisecond: u128,
initial_controller_proportional_gain: i128,
initial_controller_integral_gain: i128,
initial_minimum_collateralization_ratio: u128,
minimum_milliseconds_between_rate_updates: u64,
maximum_oracle_price_age_milliseconds: u64,
initial_redemption_price: u128,
stablecoin_name: String,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::initialize_program::initialize_program(
admin,
protocol_parameters,
stability_fee_accumulator,
redemption_price_state,
stablecoin_definition,
stablecoin_master_holding,
collateral_definition,
market_price_oracle,
clock,
ctx.self_program_id,
freeze_authority_account_id,
initial_stability_fee_per_millisecond,
initial_controller_proportional_gain,
initial_controller_integral_gain,
initial_minimum_collateralization_ratio,
minimum_milliseconds_between_rate_updates,
maximum_oracle_price_age_milliseconds,
initial_redemption_price,
stablecoin_name,
);
Ok(spel_framework::SpelOutput::execute(
post_states,
chained_calls,
))
}
/// Roll the global stability-fee accumulator forward.
#[instruction]
pub fn accrue_stability_fee(
ctx: ProgramContext,
caller: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
clock: AccountWithMetadata,
) -> SpelResult {
let (post_states, chained_calls) =
stablecoin_program::accrue_stability_fee::accrue_stability_fee(
caller,
protocol_parameters,
stability_fee_accumulator,
clock,
ctx.self_program_id,
);
Ok(spel_framework::SpelOutput::execute(
post_states,
chained_calls,
))
}
/// Update stability-fee rate after accruing pending fees at the old rate.
#[instruction]
pub fn set_stability_fee_per_millisecond(
ctx: ProgramContext,
admin: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
clock: AccountWithMetadata,
new_rate: u128,
) -> SpelResult {
let (post_states, chained_calls) =
stablecoin_program::set_stability_fee_per_millisecond::set_stability_fee_per_millisecond(
admin,
protocol_parameters,
stability_fee_accumulator,
clock,
ctx.self_program_id,
new_rate,
);
Ok(spel_framework::SpelOutput::execute(
post_states,
chained_calls,
))
}
/// Open a new collateral-only position for the calling owner.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition fails (see
/// [`stablecoin_program::open_position::open_position`] for the full list).
#[instruction]
pub fn open_position(
ctx: ProgramContext,
@ -28,17 +126,23 @@ mod stablecoin {
vault: AccountWithMetadata,
#[account(mut, signer)]
user_holding: AccountWithMetadata,
token_definition: AccountWithMetadata,
collateral_amount: u128,
collateral_definition: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
position_nonce: u64,
initial_collateral_amount: u128,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::open_position::open_position(
owner,
position,
vault,
user_holding,
token_definition,
collateral_definition,
protocol_parameters,
clock,
ctx.self_program_id,
collateral_amount,
position_nonce,
initial_collateral_amount,
);
Ok(spel_framework::SpelOutput::execute(
post_states,
@ -46,14 +150,49 @@ mod stablecoin {
))
}
/// Withdraw `amount` collateral tokens from an existing position back to a
/// user-controlled holding.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition
/// fails (see
/// [`stablecoin_program::withdraw_collateral::withdraw_collateral`] for the
/// full list).
/// Mint stablecoin debt against an existing position.
#[expect(
clippy::too_many_arguments,
reason = "instruction interface passes explicit position, token, oracle, and protocol accounts"
)]
#[instruction]
pub fn generate_debt(
ctx: ProgramContext,
owner: AccountWithMetadata,
position: AccountWithMetadata,
stablecoin_definition: AccountWithMetadata,
user_stablecoin_holding: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
redemption_price_state: AccountWithMetadata,
market_price_oracle: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
amount: u128,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::generate_debt::generate_debt(
owner,
position,
stablecoin_definition,
user_stablecoin_holding,
stability_fee_accumulator,
redemption_price_state,
market_price_oracle,
protocol_parameters,
clock,
ctx.self_program_id,
amount,
);
Ok(spel_framework::SpelOutput::execute(
post_states,
chained_calls,
))
}
/// Withdraw collateral from an existing position.
#[expect(
clippy::too_many_arguments,
reason = "instruction interface passes explicit position and protocol accounts"
)]
#[instruction]
pub fn withdraw_collateral(
ctx: ProgramContext,
@ -65,6 +204,10 @@ mod stablecoin {
vault: AccountWithMetadata,
#[account(mut)]
destination: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
redemption_price_state: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
amount: u128,
) -> SpelResult {
let (post_states, chained_calls) =
@ -73,6 +216,10 @@ mod stablecoin {
position,
vault,
destination,
stability_fee_accumulator,
redemption_price_state,
protocol_parameters,
clock,
ctx.self_program_id,
amount,
);
@ -82,12 +229,7 @@ mod stablecoin {
))
}
/// Repay `amount` of outstanding stablecoin debt against an existing position.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition
/// fails (see [`stablecoin_program::repay_debt::repay_debt`] for the
/// full list).
/// Repay stablecoin debt against an existing position.
#[instruction]
pub fn repay_debt(
ctx: ProgramContext,
@ -99,6 +241,9 @@ mod stablecoin {
stablecoin_definition: AccountWithMetadata,
#[account(mut, signer)]
user_stablecoin_holding: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
amount: u128,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::repay_debt::repay_debt(
@ -106,6 +251,9 @@ mod stablecoin {
position,
stablecoin_definition,
user_stablecoin_holding,
stability_fee_accumulator,
protocol_parameters,
clock,
ctx.self_program_id,
amount,
);

View File

@ -0,0 +1,41 @@
use nssa_core::{
account::{AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, ProgramId},
};
use crate::shared::{
accrue_stability_fee_state, read_clock_timestamp, read_protocol_parameters,
read_stability_fee_accumulator,
};
/// Rolls the global stability-fee accumulator forward.
///
/// # Panics
/// Panics if the caller is unauthorized or the protocol accounts are invalid.
pub fn accrue_stability_fee(
caller: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(caller.is_authorized, "Caller authorization is missing");
let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id);
let accumulator =
read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id);
let now = read_clock_timestamp(&clock);
let updated = accrue_stability_fee_state(&accumulator, &params, now);
let mut accumulator_post = stability_fee_accumulator.account.clone();
accumulator_post.data = Data::from(&updated);
let post_states = vec![
AccountPostState::new(caller.account),
AccountPostState::new(protocol_parameters.account),
AccountPostState::new(accumulator_post),
AccountPostState::new(clock.account),
];
(post_states, vec![])
}

View File

@ -0,0 +1,177 @@
use nssa_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, ProgramId},
};
use stablecoin_core::{
compute_stablecoin_definition_pda_seed, verify_position_and_get_seed, Position, FIXED_POINT_ONE,
};
use token_core::TokenHolding;
use twap_oracle_core::OraclePriceAccount;
use crate::shared::{
read_clock_timestamp, read_protocol_parameters, read_redemption_price_state,
read_stability_fee_accumulator,
};
/// Mints stablecoin debt against an existing position.
///
/// # Panics
/// Panics if the position is not authorized, the oracle is stale, or the post-mint position would
/// be undercollateralized.
#[expect(
clippy::too_many_arguments,
reason = "instruction surface passes explicit position, token, oracle, and protocol accounts"
)]
pub fn generate_debt(
owner: AccountWithMetadata,
position: AccountWithMetadata,
stablecoin_definition: AccountWithMetadata,
user_stablecoin_holding: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
redemption_price_state: AccountWithMetadata,
market_price_oracle: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
amount: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(owner.is_authorized, "Owner authorization is missing");
let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id);
assert!(
!params.is_frozen,
"Protocol is frozen; debt generation is disabled"
);
assert_eq!(
stablecoin_definition.account_id, params.stablecoin_definition_id,
"Stablecoin definition does not match protocol parameters"
);
let accumulator =
read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id);
let redemption_state =
read_redemption_price_state(&redemption_price_state, stablecoin_program_id);
let now = read_clock_timestamp(&clock);
let current_accumulator = stablecoin_core::current_accumulated_rate(&accumulator, &params, now);
let current_redemption_price =
stablecoin_core::current_redemption_price(&redemption_state, now);
assert_ne!(
position.account,
Account::default(),
"Position account must be initialized"
);
assert_eq!(
position.account.program_owner, stablecoin_program_id,
"Position is not owned by this stablecoin program"
);
let position_data = Position::try_from(&position.account.data)
.expect("Position account must hold valid Position state");
assert_eq!(
position_data.owner_account_id, owner.account_id,
"Position owner does not match signer"
);
let _position_seed = verify_position_and_get_seed(
&position,
&owner,
position_data.position_nonce,
stablecoin_program_id,
);
assert_ne!(
stablecoin_definition.account,
Account::default(),
"Stablecoin definition account must be initialized"
);
assert_ne!(
user_stablecoin_holding.account,
Account::default(),
"User stablecoin holding must be initialized"
);
assert_eq!(
user_stablecoin_holding.account.program_owner, stablecoin_definition.account.program_owner,
"Stablecoin holding and definition must be owned by the same Token Program"
);
let user_holding = TokenHolding::try_from(&user_stablecoin_holding.account.data)
.expect("User stablecoin holding must hold a valid TokenHolding");
assert_eq!(
user_holding.definition_id(),
stablecoin_definition.account_id,
"Stablecoin holding does not match the provided stablecoin definition"
);
assert_eq!(
market_price_oracle.account_id, params.market_price_oracle_id,
"Market price oracle does not match protocol parameters"
);
let oracle = OraclePriceAccount::try_from(&market_price_oracle.account.data)
.expect("Market price oracle account must hold a valid OraclePriceAccount");
assert_eq!(
oracle.base_asset, params.stablecoin_definition_id,
"Market price oracle base asset must be the stablecoin definition"
);
assert_eq!(
oracle.quote_asset, params.collateral_definition_id,
"Market price oracle quote asset must be the collateral definition"
);
assert!(
oracle.price != 0,
"Market price oracle price must be non-zero"
);
assert!(
now.saturating_sub(oracle.timestamp) <= params.maximum_oracle_price_age_milliseconds,
"Market price oracle is stale"
);
let normalized_delta =
stablecoin_core::mul_div_ceil(amount, FIXED_POINT_ONE, current_accumulator);
let new_normalized_debt = position_data
.normalized_debt_amount
.checked_add(normalized_delta)
.expect("Position normalized debt overflow");
assert!(
stablecoin_core::is_collateralized(
position_data.collateral_amount,
new_normalized_debt,
current_accumulator,
current_redemption_price,
params.minimum_collateralization_ratio,
),
"Position would be undercollateralized after debt generation"
);
let updated_position = Position {
owner_account_id: position_data.owner_account_id,
position_nonce: position_data.position_nonce,
vault_account_id: position_data.vault_account_id,
collateral_amount: position_data.collateral_amount,
normalized_debt_amount: new_normalized_debt,
opened_at: position_data.opened_at,
};
let mut position_post = position.account.clone();
position_post.data = Data::from(&updated_position);
let post_states = vec![
AccountPostState::new(owner.account),
AccountPostState::new(position_post),
AccountPostState::new(stablecoin_definition.account.clone()),
AccountPostState::new(user_stablecoin_holding.account.clone()),
AccountPostState::new(stability_fee_accumulator.account),
AccountPostState::new(redemption_price_state.account),
AccountPostState::new(market_price_oracle.account),
AccountPostState::new(protocol_parameters.account),
AccountPostState::new(clock.account),
];
let mut stablecoin_definition_authorized = stablecoin_definition;
stablecoin_definition_authorized.is_authorized = true;
let mint_call = ChainedCall::new(
stablecoin_definition_authorized.account.program_owner,
vec![stablecoin_definition_authorized, user_stablecoin_holding],
&token_core::Instruction::Mint {
amount_to_mint: amount,
},
)
.with_pda_seeds(vec![compute_stablecoin_definition_pda_seed()]);
(post_states, vec![mint_call])
}

View File

@ -0,0 +1,206 @@
use nssa_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, Claim, ProgramId},
};
use stablecoin_core::{
assert_protocol_parameter_bounds, compute_protocol_parameters_pda,
compute_protocol_parameters_pda_seed, compute_redemption_price_state_pda,
compute_redemption_price_state_pda_seed, compute_stability_fee_accumulator_pda,
compute_stability_fee_accumulator_pda_seed, compute_stablecoin_definition_pda,
compute_stablecoin_definition_pda_seed, compute_stablecoin_master_holding_pda,
compute_stablecoin_master_holding_pda_seed, ProtocolParameters, RedemptionPriceState,
StabilityFeeAccumulator, FIXED_POINT_ONE,
};
use token_core::TokenDefinition;
use twap_oracle_core::OraclePriceAccount;
use crate::shared::read_clock_timestamp;
/// Initializes stablecoin protocol globals and creates the stablecoin token definition.
///
/// # Panics
/// Panics if any account is not the expected PDA/state shape or any parameter is out of bounds.
#[expect(
clippy::too_many_arguments,
reason = "instruction surface initializes all protocol singleton accounts in one call"
)]
pub fn initialize_program(
admin: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
redemption_price_state: AccountWithMetadata,
stablecoin_definition: AccountWithMetadata,
stablecoin_master_holding: AccountWithMetadata,
collateral_definition: AccountWithMetadata,
market_price_oracle: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
freeze_authority_account_id: nssa_core::account::AccountId,
initial_stability_fee_per_millisecond: u128,
initial_controller_proportional_gain: i128,
initial_controller_integral_gain: i128,
initial_minimum_collateralization_ratio: u128,
minimum_milliseconds_between_rate_updates: u64,
maximum_oracle_price_age_milliseconds: u64,
initial_redemption_price: u128,
stablecoin_name: String,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(admin.is_authorized, "Admin authorization is missing");
assert_protocol_parameter_bounds(
initial_stability_fee_per_millisecond,
initial_controller_proportional_gain,
initial_controller_integral_gain,
initial_minimum_collateralization_ratio,
minimum_milliseconds_between_rate_updates,
maximum_oracle_price_age_milliseconds,
initial_redemption_price,
);
assert_uninitialized_pda(
&protocol_parameters,
compute_protocol_parameters_pda(stablecoin_program_id),
"Protocol parameters",
);
assert_uninitialized_pda(
&stability_fee_accumulator,
compute_stability_fee_accumulator_pda(stablecoin_program_id),
"Stability fee accumulator",
);
assert_uninitialized_pda(
&redemption_price_state,
compute_redemption_price_state_pda(stablecoin_program_id),
"Redemption price state",
);
assert_uninitialized_pda(
&stablecoin_definition,
compute_stablecoin_definition_pda(stablecoin_program_id),
"Stablecoin definition",
);
assert_uninitialized_pda(
&stablecoin_master_holding,
compute_stablecoin_master_holding_pda(stablecoin_program_id),
"Stablecoin master holding",
);
assert_ne!(
collateral_definition.account,
Account::default(),
"Collateral definition account must be initialized"
);
let collateral_definition_data = TokenDefinition::try_from(&collateral_definition.account.data)
.expect("Collateral definition account must hold a valid TokenDefinition");
assert!(
matches!(collateral_definition_data, TokenDefinition::Fungible { .. }),
"Collateral definition must be fungible"
);
assert_ne!(
market_price_oracle.account,
Account::default(),
"Market price oracle account must be initialized"
);
let oracle = OraclePriceAccount::try_from(&market_price_oracle.account.data)
.expect("Market price oracle account must hold a valid OraclePriceAccount");
assert_eq!(
oracle.base_asset, stablecoin_definition.account_id,
"Market price oracle base asset must be the stablecoin definition"
);
assert_eq!(
oracle.quote_asset, collateral_definition.account_id,
"Market price oracle quote asset must be the collateral definition"
);
let now = read_clock_timestamp(&clock);
let protocol_data = ProtocolParameters {
admin_account_id: admin.account_id,
freeze_authority_account_id,
stablecoin_definition_id: stablecoin_definition.account_id,
collateral_definition_id: collateral_definition.account_id,
market_price_oracle_id: market_price_oracle.account_id,
stability_fee_per_millisecond: initial_stability_fee_per_millisecond,
controller_proportional_gain: initial_controller_proportional_gain,
controller_integral_gain: initial_controller_integral_gain,
minimum_collateralization_ratio: initial_minimum_collateralization_ratio,
minimum_milliseconds_between_rate_updates,
maximum_oracle_price_age_milliseconds,
is_frozen: false,
};
let mut protocol_post = protocol_parameters.account.clone();
protocol_post.data = Data::from(&protocol_data);
let mut fee_post = stability_fee_accumulator.account.clone();
fee_post.data = Data::from(&StabilityFeeAccumulator {
accumulated_rate_at_last_accrual: FIXED_POINT_ONE,
last_accrued_at: now,
});
let mut redemption_post = redemption_price_state.account.clone();
redemption_post.data = Data::from(&RedemptionPriceState {
redemption_price_at_last_update: initial_redemption_price,
redemption_rate_per_millisecond: FIXED_POINT_ONE,
controller_integral_term: 0,
last_updated_at: now,
});
let mut stablecoin_definition_authorized = stablecoin_definition.clone();
stablecoin_definition_authorized.is_authorized = true;
let mut stablecoin_master_holding_authorized = stablecoin_master_holding.clone();
stablecoin_master_holding_authorized.is_authorized = true;
let token_program_id = collateral_definition.account.program_owner;
let create_stablecoin_call = ChainedCall::new(
token_program_id,
vec![
stablecoin_definition_authorized,
stablecoin_master_holding_authorized,
],
&token_core::Instruction::NewFungibleDefinition {
name: stablecoin_name,
total_supply: 0,
mint_authority: Some(stablecoin_definition.account_id),
},
)
.with_pda_seeds(vec![
compute_stablecoin_definition_pda_seed(),
compute_stablecoin_master_holding_pda_seed(),
]);
let post_states = vec![
AccountPostState::new(admin.account),
AccountPostState::new_claimed(
protocol_post,
Claim::Pda(compute_protocol_parameters_pda_seed()),
),
AccountPostState::new_claimed(
fee_post,
Claim::Pda(compute_stability_fee_accumulator_pda_seed()),
),
AccountPostState::new_claimed(
redemption_post,
Claim::Pda(compute_redemption_price_state_pda_seed()),
),
AccountPostState::new(stablecoin_definition.account),
AccountPostState::new(stablecoin_master_holding.account),
AccountPostState::new(collateral_definition.account),
AccountPostState::new(market_price_oracle.account),
AccountPostState::new(clock.account),
];
(post_states, vec![create_stablecoin_call])
}
fn assert_uninitialized_pda(
account: &AccountWithMetadata,
expected_id: nssa_core::account::AccountId,
label: &str,
) {
assert_eq!(
account.account_id, expected_id,
"{label} account ID does not match PDA"
);
assert_eq!(
account.account,
Account::default(),
"{label} account must be uninitialized"
);
}

View File

@ -2,12 +2,26 @@
pub use stablecoin_core as core;
/// Roll the global stability-fee accumulator forward.
pub mod accrue_stability_fee;
/// Mint stablecoin debt against an existing position.
pub mod generate_debt;
/// Initialize protocol globals and the stablecoin token definition.
pub mod initialize_program;
/// Open a new collateral-only position for a calling owner.
pub mod open_position;
/// Repay outstanding stablecoin debt against an existing position.
pub mod repay_debt;
/// Update the stability-fee rate after accruing at the old rate.
pub mod set_stability_fee_per_millisecond;
mod shared;
/// Withdraw collateral from an existing position back to a user-controlled holding.
pub mod withdraw_collateral;

View File

@ -5,16 +5,17 @@ use nssa_core::{
use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position};
use token_core::TokenHolding;
use crate::shared::{read_clock_timestamp, read_protocol_parameters};
/// Open a new collateral-only position for `owner`.
///
/// This claims the [`Position`] PDA, issues two chained token-program calls under the
/// stablecoin's PDA authority, and stores `collateral_amount` with `debt_amount = 0`:
/// stablecoin's PDA authority, and stores `initial_collateral_amount` with zero normalized debt:
/// 1. `InitializeAccount` materializes the vault token holding for the collateral.
/// 2. `Transfer` moves `collateral_amount` collateral tokens from the user's holding into the
/// freshly initialized vault.
/// 2. `Transfer` moves `initial_collateral_amount` collateral tokens from the user's holding into
/// the freshly initialized vault.
///
/// `debt_amount` is deferred to a future `generate_debt` instruction and is intentionally
/// not parameterized here.
/// Stablecoin debt is created later by `generate_debt`; it is intentionally not parameterized here.
///
/// # Panics
/// - `owner` or `user_holding` is not authorized.
@ -23,16 +24,33 @@ use token_core::TokenHolding;
/// - `user_holding` cannot be decoded as a [`TokenHolding`].
/// - `user_holding`'s definition does not match `token_definition`.
/// - `token_definition.program_owner` does not match `user_holding.program_owner`.
#[expect(
clippy::too_many_arguments,
reason = "instruction surface passes explicit owner, position, vault, collateral, and protocol accounts"
)]
pub fn open_position(
owner: AccountWithMetadata,
position: AccountWithMetadata,
vault: AccountWithMetadata,
user_holding: AccountWithMetadata,
token_definition: AccountWithMetadata,
collateral_definition: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
collateral_amount: u128,
position_nonce: u64,
initial_collateral_amount: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(owner.is_authorized, "Owner authorization is missing");
let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id);
assert!(
!params.is_frozen,
"Protocol is frozen; opening positions is disabled"
);
assert_eq!(
collateral_definition.account_id, params.collateral_definition_id,
"Collateral definition does not match protocol parameters"
);
assert!(
user_holding.is_authorized,
"User collateral holding authorization is missing"
@ -52,30 +70,29 @@ pub fn open_position(
.expect("User holding must be a valid Token Holding")
.definition_id();
assert_eq!(
user_holding_definition_id, token_definition.account_id,
user_holding_definition_id, collateral_definition.account_id,
"User collateral holding does not match the provided token definition"
);
let token_program_id = user_holding.account.program_owner;
assert_eq!(
token_definition.account.program_owner, token_program_id,
collateral_definition.account.program_owner, token_program_id,
"Collateral token definition is not owned by the user holding's Token Program"
);
let position_seed = verify_position_and_get_seed(
&position,
&owner,
token_definition.account_id,
stablecoin_program_id,
);
let position_seed =
verify_position_and_get_seed(&position, &owner, position_nonce, stablecoin_program_id);
let vault_seed =
verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id);
let now = read_clock_timestamp(&clock);
let mut position_post = position.account;
position_post.data = Data::from(&Position {
collateral_vault_id: vault.account_id,
collateral_definition_id: token_definition.account_id,
collateral_amount,
debt_amount: 0,
owner_account_id: owner.account_id,
position_nonce,
vault_account_id: vault.account_id,
collateral_amount: initial_collateral_amount,
normalized_debt_amount: 0,
opened_at: now,
});
let post_states = vec![
@ -83,7 +100,9 @@ pub fn open_position(
AccountPostState::new_claimed(position_post, Claim::Pda(position_seed)),
AccountPostState::new(vault.account.clone()),
AccountPostState::new(user_holding.account.clone()),
AccountPostState::new(token_definition.account.clone()),
AccountPostState::new(collateral_definition.account.clone()),
AccountPostState::new(protocol_parameters.account),
AccountPostState::new(clock.account.clone()),
];
// Chained Token::InitializeAccount owns the vault as a Token holding. The Stablecoin
@ -92,7 +111,7 @@ pub fn open_position(
vault_authorized.is_authorized = true;
let initialize_call = ChainedCall::new(
token_program_id,
vec![token_definition.clone(), vault_authorized],
vec![collateral_definition.clone(), vault_authorized],
&token_core::Instruction::InitializeAccount,
)
.with_pda_seeds(vec![vault_seed]);
@ -105,7 +124,7 @@ pub fn open_position(
program_owner: token_program_id,
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: token_definition.account_id,
definition_id: collateral_definition.account_id,
balance: 0,
}),
nonce: vault.account.nonce,
@ -117,7 +136,7 @@ pub fn open_position(
token_program_id,
vec![user_holding, post_init_vault],
&token_core::Instruction::Transfer {
amount_to_transfer: collateral_amount,
amount_to_transfer: initial_collateral_amount,
},
);

View File

@ -5,42 +5,54 @@ use nssa_core::{
use stablecoin_core::{verify_position_and_get_seed, Position};
use token_core::TokenHolding;
use crate::shared::{
read_clock_timestamp, read_protocol_parameters, read_stability_fee_accumulator,
};
/// Repay `amount` of outstanding stablecoin debt against an existing position.
///
/// Burns `amount` stablecoins from `user_stablecoin_holding` via a chained
/// `Token::Burn` and decreases `Position.debt_amount` by the same amount. The
/// `Token::Burn` and decreases `Position.normalized_debt_amount` by the repayment's normalized
/// equivalent. The
/// position post-state uses plain [`AccountPostState::new`] — the PDA was
/// already claimed at `open_position` time.
///
/// Until issue #97 (stability fee accrual) lands, the fee-accrual step is a
/// no-op (every position structurally has `debt_amount = 0` today because
/// `generate_debt` is unimplemented; "fees-accrued" is therefore vacuously
/// true). A `// TODO(#97)` comment marks where the accrual code will plug in
/// — right before the `checked_sub` below.
///
/// Until issue #91 (`generate_debt`) records the stablecoin definition into
/// `Position`, this instruction cannot validate that `stablecoin_definition`
/// is the correct one for the position's debt. The caller is trusted.
///
/// # Panics
/// - `owner` is not authorized.
/// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not
/// decode as a [`Position`], or sits at an address that does not match
/// `compute_position_pda(stablecoin_program_id, owner, Position.collateral_definition_id)`.
/// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`.
/// - `user_stablecoin_holding` is not authorized, is uninitialized, is owned by a different Token
/// Program than `stablecoin_definition`, or holds a [`TokenHolding`] whose `definition_id` does
/// not match `stablecoin_definition.account_id`.
/// - `stablecoin_definition` is uninitialized.
/// - `amount > Position.debt_amount`.
/// - `amount` is greater than the current nominal debt ceiling or normalizes to zero while nonzero.
#[expect(
clippy::too_many_arguments,
reason = "instruction surface passes explicit position, token, fee, and protocol accounts"
)]
pub fn repay_debt(
owner: AccountWithMetadata,
position: AccountWithMetadata,
stablecoin_definition: AccountWithMetadata,
user_stablecoin_holding: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
amount: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(owner.is_authorized, "Owner authorization is missing");
let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id);
assert_eq!(
stablecoin_definition.account_id, params.stablecoin_definition_id,
"Stablecoin definition does not match protocol parameters"
);
let accumulator =
read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id);
let now = read_clock_timestamp(&clock);
let current_accumulator = stablecoin_core::current_accumulated_rate(&accumulator, &params, now);
assert_ne!(
position.account,
Account::default(),
@ -53,13 +65,17 @@ pub fn repay_debt(
let position_data = Position::try_from(&position.account.data)
.expect("Position account must hold valid Position state");
assert_eq!(
position_data.owner_account_id, owner.account_id,
"Position owner does not match signer"
);
// `verify_position_and_get_seed` asserts the position address matches the
// (owner, collateral_definition) PDA derivation. The returned seed is
// (owner, position_nonce) PDA derivation. The returned seed is
// dropped — the position is already PDA-claimed.
let _position_seed = verify_position_and_get_seed(
&position,
&owner,
position_data.collateral_definition_id,
position_data.position_nonce,
stablecoin_program_id,
);
@ -89,19 +105,37 @@ pub fn repay_debt(
"Stablecoin holding does not match the provided stablecoin definition"
);
// TODO(#97): accrue stability fees onto position_data.debt_amount here, before
// the checked_sub below. Today every position has debt_amount = 0 (no
// generate_debt yet), so the precondition is trivially met.
let new_debt = position_data
.debt_amount
.checked_sub(amount)
let maximum_repay_amount = stablecoin_core::mul_div_ceil(
position_data.normalized_debt_amount,
current_accumulator,
stablecoin_core::FIXED_POINT_ONE,
);
assert!(
amount <= maximum_repay_amount,
"Repay amount exceeds outstanding debt"
);
let normalized_delta = stablecoin_core::mul_div_floor(
amount,
stablecoin_core::FIXED_POINT_ONE,
current_accumulator,
);
assert!(
amount == 0 || normalized_delta != 0,
"Repay amount is too small to reduce outstanding debt"
);
let new_normalized_debt = position_data
.normalized_debt_amount
.checked_sub(normalized_delta)
.expect("Repay amount exceeds outstanding debt");
let updated_position = Position {
collateral_vault_id: position_data.collateral_vault_id,
collateral_definition_id: position_data.collateral_definition_id,
owner_account_id: position_data.owner_account_id,
position_nonce: position_data.position_nonce,
vault_account_id: position_data.vault_account_id,
collateral_amount: position_data.collateral_amount,
debt_amount: new_debt,
normalized_debt_amount: new_normalized_debt,
opened_at: position_data.opened_at,
};
let mut position_post = position.account.clone();
position_post.data = Data::from(&updated_position);
@ -111,6 +145,9 @@ pub fn repay_debt(
AccountPostState::new(position_post),
AccountPostState::new(stablecoin_definition.account.clone()),
AccountPostState::new(user_stablecoin_holding.account.clone()),
AccountPostState::new(stability_fee_accumulator.account),
AccountPostState::new(protocol_parameters.account),
AccountPostState::new(clock.account),
];
let token_program_id = user_stablecoin_holding.account.program_owner;

View File

@ -0,0 +1,52 @@
use nssa_core::{
account::{AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, ProgramId},
};
use stablecoin_core::assert_valid_stability_fee_per_millisecond;
use crate::shared::{
accrue_stability_fee_state, read_clock_timestamp, read_protocol_parameters,
read_stability_fee_accumulator,
};
/// Updates the protocol stability-fee rate after accruing at the old rate.
///
/// # Panics
/// Panics if `admin` is not the configured admin or `new_rate` is out of bounds.
pub fn set_stability_fee_per_millisecond(
admin: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
new_rate: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(admin.is_authorized, "Admin authorization is missing");
assert_valid_stability_fee_per_millisecond(new_rate);
let mut params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id);
assert_eq!(
admin.account_id, params.admin_account_id,
"Admin account does not match protocol admin"
);
let accumulator =
read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id);
let now = read_clock_timestamp(&clock);
let updated_accumulator = accrue_stability_fee_state(&accumulator, &params, now);
params.stability_fee_per_millisecond = new_rate;
let mut protocol_post = protocol_parameters.account.clone();
protocol_post.data = Data::from(&params);
let mut accumulator_post = stability_fee_accumulator.account.clone();
accumulator_post.data = Data::from(&updated_accumulator);
let post_states = vec![
AccountPostState::new(admin.account),
AccountPostState::new(protocol_post),
AccountPostState::new(accumulator_post),
AccountPostState::new(clock.account),
];
(post_states, vec![])
}

View File

@ -0,0 +1,107 @@
use borsh::BorshDeserialize as _;
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
use nssa_core::{
account::{Account, AccountWithMetadata},
program::ProgramId,
};
use stablecoin_core::{
compute_protocol_parameters_pda, compute_redemption_price_state_pda,
compute_stability_fee_accumulator_pda, current_accumulated_rate, ProtocolParameters,
RedemptionPriceState, StabilityFeeAccumulator,
};
pub(crate) fn read_clock_timestamp(clock: &AccountWithMetadata) -> u64 {
assert_eq!(
clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
"Clock account must be the canonical 1-block LEZ clock account"
);
assert_ne!(
clock.account,
Account::default(),
"Clock account must be initialized"
);
ClockAccountData::try_from_slice(clock.account.data.as_ref())
.expect("Clock account must hold valid ClockAccountData")
.timestamp
}
pub(crate) fn read_protocol_parameters(
protocol_parameters: &AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> ProtocolParameters {
assert_eq!(
protocol_parameters.account_id,
compute_protocol_parameters_pda(stablecoin_program_id),
"Protocol parameters account ID does not match PDA"
);
assert_initialized_owned(
protocol_parameters,
stablecoin_program_id,
"Protocol parameters",
);
ProtocolParameters::try_from(&protocol_parameters.account.data)
.expect("Protocol parameters account must hold valid ProtocolParameters state")
}
pub(crate) fn read_stability_fee_accumulator(
stability_fee_accumulator: &AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> StabilityFeeAccumulator {
assert_eq!(
stability_fee_accumulator.account_id,
compute_stability_fee_accumulator_pda(stablecoin_program_id),
"Stability fee accumulator account ID does not match PDA"
);
assert_initialized_owned(
stability_fee_accumulator,
stablecoin_program_id,
"Stability fee accumulator",
);
StabilityFeeAccumulator::try_from(&stability_fee_accumulator.account.data)
.expect("Stability fee accumulator account must hold valid state")
}
pub(crate) fn read_redemption_price_state(
redemption_price_state: &AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> RedemptionPriceState {
assert_eq!(
redemption_price_state.account_id,
compute_redemption_price_state_pda(stablecoin_program_id),
"Redemption price state account ID does not match PDA"
);
assert_initialized_owned(
redemption_price_state,
stablecoin_program_id,
"Redemption price state",
);
RedemptionPriceState::try_from(&redemption_price_state.account.data)
.expect("Redemption price state account must hold valid state")
}
pub(crate) fn accrue_stability_fee_state(
accumulator: &StabilityFeeAccumulator,
params: &ProtocolParameters,
now: u64,
) -> StabilityFeeAccumulator {
StabilityFeeAccumulator {
accumulated_rate_at_last_accrual: current_accumulated_rate(accumulator, params, now),
last_accrued_at: now,
}
}
fn assert_initialized_owned(
account: &AccountWithMetadata,
stablecoin_program_id: ProgramId,
label: &str,
) {
assert_ne!(
account.account,
Account::default(),
"{label} account must be initialized"
);
assert_eq!(
account.account.program_owner, stablecoin_program_id,
"{label} account is not owned by this stablecoin program"
);
}

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,11 @@ use nssa_core::{
use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position};
use token_core::TokenHolding;
use crate::shared::{
read_clock_timestamp, read_protocol_parameters, read_redemption_price_state,
read_stability_fee_accumulator,
};
/// Withdraw `amount` collateral tokens from `position`'s vault back to `destination`.
///
/// Decreases `Position.collateral_amount` by `amount` and emits a single chained
@ -13,32 +18,49 @@ use token_core::TokenHolding;
/// the initial PDA claim already happened in
/// [`crate::open_position::open_position`].
///
/// Until issues #95 / #96 / #97 land (redemption price, price feed, stability
/// fee accrual), this instruction hard-asserts `Position.debt_amount == 0`.
/// When those land, this guard is replaced by real fee accrual + a
/// collateralization-ratio check against the post-withdrawal collateral.
///
/// # Panics
/// - `owner` is not authorized.
/// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not
/// decode as a [`Position`], or sits at an address that does not match
/// `compute_position_pda(stablecoin_program_id, owner, Position.collateral_definition_id)`.
/// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`.
/// - `vault` sits at an address that does not match
/// `compute_position_vault_pda(stablecoin_program_id, position_id)`, or holds a [`TokenHolding`]
/// whose `definition_id` does not match the position's collateral definition.
/// whose `definition_id` does not match the protocol collateral definition.
/// - `destination` is uninitialized, owned by a different Token Program than the vault, or holds a
/// [`TokenHolding`] whose `definition_id` does not match the position's collateral definition.
/// - `Position.debt_amount` is non-zero.
/// [`TokenHolding`] whose `definition_id` does not match the protocol collateral definition.
/// - `amount > Position.collateral_amount`.
/// - the post-withdrawal position would be undercollateralized.
#[expect(
clippy::too_many_arguments,
reason = "instruction surface passes explicit position, vault, fee, redemption, and protocol accounts"
)]
pub fn withdraw_collateral(
owner: AccountWithMetadata,
position: AccountWithMetadata,
vault: AccountWithMetadata,
destination: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
redemption_price_state: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
amount: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(owner.is_authorized, "Owner authorization is missing");
let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id);
assert!(
!params.is_frozen,
"Protocol is frozen; collateral withdrawal is disabled"
);
let accumulator =
read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id);
let redemption_state =
read_redemption_price_state(&redemption_price_state, stablecoin_program_id);
let now = read_clock_timestamp(&clock);
let current_accumulator = stablecoin_core::current_accumulated_rate(&accumulator, &params, now);
let current_redemption_price =
stablecoin_core::current_redemption_price(&redemption_state, now);
assert_ne!(
position.account,
Account::default(),
@ -51,15 +73,23 @@ pub fn withdraw_collateral(
let position_data = Position::try_from(&position.account.data)
.expect("Position account must hold valid Position state");
assert_eq!(
position_data.owner_account_id, owner.account_id,
"Position owner does not match signer"
);
// `verify_position_and_get_seed` asserts the position address matches the
// (owner, collateral_definition) PDA derivation. We do not use the seed
// (owner, position_nonce) PDA derivation. We do not use the seed
// downstream — the position is already PDA-claimed.
let _position_seed = verify_position_and_get_seed(
&position,
&owner,
position_data.collateral_definition_id,
position_data.position_nonce,
stablecoin_program_id,
);
assert_eq!(
vault.account_id, position_data.vault_account_id,
"Vault account does not match position vault"
);
let vault_seed =
verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id);
@ -67,8 +97,8 @@ pub fn withdraw_collateral(
.expect("Vault account must hold a valid TokenHolding");
assert_eq!(
vault_holding.definition_id(),
position_data.collateral_definition_id,
"Vault token holding is not for the position's collateral definition"
params.collateral_definition_id,
"Vault token holding does not match protocol collateral definition"
);
let token_program_id = vault.account.program_owner;
@ -85,24 +115,32 @@ pub fn withdraw_collateral(
.expect("Destination account must hold a valid TokenHolding");
assert_eq!(
destination_holding.definition_id(),
position_data.collateral_definition_id,
"Destination token definition does not match the position's collateral definition"
params.collateral_definition_id,
"Destination token definition does not match protocol collateral definition"
);
assert_eq!(
position_data.debt_amount, 0,
"withdraw_collateral with debt is not supported yet — stability fee accrual and collateralization check land with #97/#96"
);
let new_collateral = position_data
.collateral_amount
.checked_sub(amount)
.expect("Withdrawal amount exceeds position collateral");
assert!(
stablecoin_core::is_collateralized(
new_collateral,
position_data.normalized_debt_amount,
current_accumulator,
current_redemption_price,
params.minimum_collateralization_ratio,
),
"Position would be undercollateralized after withdrawal"
);
let updated_position = Position {
collateral_vault_id: position_data.collateral_vault_id,
collateral_definition_id: position_data.collateral_definition_id,
owner_account_id: position_data.owner_account_id,
position_nonce: position_data.position_nonce,
vault_account_id: position_data.vault_account_id,
collateral_amount: new_collateral,
debt_amount: position_data.debt_amount,
normalized_debt_amount: position_data.normalized_debt_amount,
opened_at: position_data.opened_at,
};
let mut position_post = position.account.clone();
position_post.data = Data::from(&updated_position);
@ -112,6 +150,10 @@ pub fn withdraw_collateral(
AccountPostState::new(position_post),
AccountPostState::new(vault.account.clone()),
AccountPostState::new(destination.account.clone()),
AccountPostState::new(stability_fee_accumulator.account),
AccountPostState::new(redemption_price_state.account),
AccountPostState::new(protocol_parameters.account),
AccountPostState::new(clock.account.clone()),
];
let mut vault_authorized = vault.clone();