mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
412 lines
18 KiB
Rust
412 lines
18 KiB
Rust
//! Core data structures and utilities for the Stablecoin Program.
|
|
|
|
pub mod math;
|
|
|
|
pub mod protocol_parameters;
|
|
|
|
pub mod redemption_price_state;
|
|
|
|
pub mod stability_fee_accumulator;
|
|
|
|
use borsh::{BorshDeserialize, BorshSerialize};
|
|
use nssa_core::{
|
|
account::{AccountId, AccountWithMetadata, Data},
|
|
program::{PdaSeed, ProgramId},
|
|
};
|
|
pub use protocol_parameters::{
|
|
compute_protocol_parameters_pda, compute_protocol_parameters_pda_seed, ProtocolParameters,
|
|
};
|
|
pub use redemption_price_state::{
|
|
compute_redemption_price_state_pda, compute_redemption_price_state_pda_seed,
|
|
RedemptionPriceState,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use spel_framework_macros::account_type;
|
|
pub use stability_fee_accumulator::{
|
|
compute_stability_fee_accumulator_pda, compute_stability_fee_accumulator_pda_seed,
|
|
StabilityFeeAccumulator,
|
|
};
|
|
|
|
// Stable domain-separation tags for the position PDAs; these must stay unchanged for address
|
|
// compatibility.
|
|
const POSITION_PDA_DOMAIN: &[u8] = b"POSITION";
|
|
const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT";
|
|
const STABLECOIN_DEFINITION_PDA_DOMAIN: [u8; 32] = *b"STABLECOIN__DEFINITION__________";
|
|
const STABLECOIN_MASTER_HOLDING_PDA_DOMAIN: [u8; 32] = *b"STABLECOIN__MASTER_HOLDING______";
|
|
|
|
/// Stablecoin Program Instruction.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub enum Instruction {
|
|
/// Bootstrap the protocol. One-shot — fails if any of the five global PDAs
|
|
/// is already initialized.
|
|
///
|
|
/// Required accounts (9), in order:
|
|
/// 1. `admin` — authorized; becomes `ProtocolParameters.admin_account_id`.
|
|
/// 2. `protocol_parameters` — uninitialized, PDA at
|
|
/// `compute_protocol_parameters_pda(self_program_id)`.
|
|
/// 3. `stability_fee_accumulator` — uninitialized, PDA at
|
|
/// `compute_stability_fee_accumulator_pda(self_program_id)`.
|
|
/// 4. `redemption_price_state` — uninitialized, PDA at
|
|
/// `compute_redemption_price_state_pda(self_program_id)`.
|
|
/// 5. `stablecoin_definition` — uninitialized, PDA at
|
|
/// `compute_stablecoin_definition_pda(self_program_id)`; claimed via the chained
|
|
/// `Token::NewFungibleDefinition`.
|
|
/// 6. `stablecoin_master_holding` — uninitialized, PDA at
|
|
/// `compute_stablecoin_master_holding_pda(self_program_id)`; also claimed via the chained
|
|
/// call (Token-Program API artifact — receives `total_supply = 0`).
|
|
/// 7. `collateral_definition` — initialized; validated as `TokenDefinition::Fungible` and
|
|
/// recorded into `ProtocolParameters.collateral_definition_id` (immutable thereafter).
|
|
/// 8. `market_price_oracle` — initialized; validated as an `OraclePriceAccount` with
|
|
/// `base_asset` = stablecoin definition, `quote_asset` = collateral definition.
|
|
/// 9. `clock` — the system `CLOCK_01` account; read-only. Its timestamp anchors the
|
|
/// accumulator and redemption-price state. (The pinned spel-framework exposes no
|
|
/// `ProgramContext` clock, so wall-clock time is read from this account.)
|
|
InitializeProgram {
|
|
/// Designated freeze authority. Held in `ProtocolParameters`; can be
|
|
/// rotated post-init via `set_freeze_authority`.
|
|
freeze_authority_account_id: AccountId,
|
|
/// Per-millisecond stability fee multiplier in fixed-point. Bound:
|
|
/// `FIXED_POINT_ONE <= x <= FIXED_POINT_ONE * 2` (see spec §8).
|
|
initial_stability_fee_per_millisecond: u128,
|
|
/// PI controller `Kp`. Signed. Bound: `|x| <= FIXED_POINT_ONE * 10^3`.
|
|
initial_controller_proportional_gain: i128,
|
|
/// PI controller `Ki`. Signed. Bound: `|x| <= FIXED_POINT_ONE`.
|
|
initial_controller_integral_gain: i128,
|
|
/// Minimum collateralization ratio in fixed-point. Bound:
|
|
/// `FIXED_POINT_ONE * 1.1 <= x <= FIXED_POINT_ONE * 10`.
|
|
initial_minimum_collateralization_ratio: u128,
|
|
/// Min milliseconds between `update_redemption_rate` calls. Bound: 1..=86_400_000.
|
|
minimum_milliseconds_between_rate_updates: u64,
|
|
/// Max accepted oracle observation age, in milliseconds. Bound: 1..=86_400_000.
|
|
maximum_oracle_price_age_milliseconds: u64,
|
|
/// Initial redemption price in fixed-point (collateral-per-stablecoin).
|
|
/// Must be > 0; chosen by the deployer to reflect the launch target.
|
|
initial_redemption_price: u128,
|
|
/// Name baked into the stablecoin's `TokenDefinition::Fungible`.
|
|
stablecoin_name: String,
|
|
},
|
|
/// 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, position_nonce)`)
|
|
/// - 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 {
|
|
/// Caller-chosen nonce that, with the owner's account id, forms the
|
|
/// position PDA's seed pre-image. Lets one owner hold many positions.
|
|
position_nonce: u64,
|
|
/// Amount of collateral tokens to deposit into the position vault.
|
|
collateral_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` matches the vault holding's definition)
|
|
///
|
|
/// `token_program_id` is derived from `vault.account.program_owner`;
|
|
/// the collateral definition is read from the PDA-verified vault holding.
|
|
///
|
|
/// **Note:** until issues #97/#95 land, this instruction hard-asserts
|
|
/// `Position.normalized_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`.
|
|
/// `position_nonce` (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
|
|
/// `normalized_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: u128,
|
|
},
|
|
}
|
|
|
|
/// Persistent state held by a Stablecoin [`Position`] account.
|
|
///
|
|
/// See spec §4.4. `normalized_debt_amount` is the RAI-style "shares in a debt
|
|
/// pool whose value per share is the stability-fee accumulator" — multiply by
|
|
/// the current accumulator to get the position's nominal debt at any moment.
|
|
#[account_type]
|
|
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
|
|
pub struct Position {
|
|
/// Owner of the position. Must be `is_authorized` for every position op.
|
|
/// Stored for client discovery (PDA seed isn't reversible).
|
|
pub owner_account_id: AccountId,
|
|
/// Caller-chosen nonce; together with `owner_account_id` forms the PDA
|
|
/// seed for this position.
|
|
pub position_nonce: u64,
|
|
/// Collateral vault PDA (= `compute_position_vault_pda(program, position_id)`).
|
|
/// Stored explicitly for op-time efficiency.
|
|
pub vault_account_id: AccountId,
|
|
/// Collateral tokens currently held in the vault. Invariant:
|
|
/// equals `vault_holding.balance` after every modifying op.
|
|
pub collateral_amount: u128,
|
|
/// Stablecoin atomic units divided by the accumulator at mint time.
|
|
/// Nominal debt at time T = `normalized_debt_amount * accumulated_rate(T) / FIXED_POINT_ONE`.
|
|
pub normalized_debt_amount: u128,
|
|
/// Unix milliseconds when the position was first opened. UX/analytics only;
|
|
/// not used in protocol logic.
|
|
pub opened_at: u64,
|
|
}
|
|
|
|
impl TryFrom<&Data> for Position {
|
|
type Error = std::io::Error;
|
|
|
|
fn try_from(data: &Data) -> Result<Self, Self::Error> {
|
|
Self::try_from_slice(data.as_ref())
|
|
}
|
|
}
|
|
|
|
impl From<&Position> for Data {
|
|
fn from(position: &Position) -> Self {
|
|
let len = borsh::object_length(position).expect("Position length must be known");
|
|
let mut buf = Vec::with_capacity(len);
|
|
BorshSerialize::serialize(position, &mut buf)
|
|
.expect("Position serialization should not fail");
|
|
Self::try_from(buf).expect("Position encoded data should fit into Data")
|
|
}
|
|
}
|
|
|
|
/// PDA seed for the [`Position`] account at `(owner_id, position_nonce)`.
|
|
///
|
|
/// The single-instance protocol has only one collateral definition globally
|
|
/// (stored on `ProtocolParameters`), so it no longer factors into the seed.
|
|
/// The 64-bit `position_nonce` is caller-chosen and lets one owner hold
|
|
/// many positions (spec §3.2).
|
|
#[must_use]
|
|
pub fn compute_position_pda_seed(owner_id: AccountId, position_nonce: u64) -> 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(&position_nonce.to_le_bytes());
|
|
bytes.extend_from_slice(POSITION_PDA_DOMAIN);
|
|
|
|
let mut out = [0u8; 32];
|
|
out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes());
|
|
PdaSeed::new(out)
|
|
}
|
|
|
|
/// Account id of the [`Position`] PDA for `(owner_id, position_nonce)` under
|
|
/// `stablecoin_program_id`.
|
|
#[must_use]
|
|
pub fn compute_position_pda(
|
|
stablecoin_program_id: ProgramId,
|
|
owner_id: AccountId,
|
|
position_nonce: u64,
|
|
) -> AccountId {
|
|
AccountId::for_public_pda(
|
|
&stablecoin_program_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.
|
|
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)
|
|
}
|
|
|
|
/// Account id of the collateral vault PDA for `position_id` under `stablecoin_program_id`.
|
|
pub fn compute_position_vault_pda(
|
|
stablecoin_program_id: ProgramId,
|
|
position_id: AccountId,
|
|
) -> AccountId {
|
|
AccountId::for_public_pda(
|
|
&stablecoin_program_id,
|
|
&compute_position_vault_pda_seed(position_id),
|
|
)
|
|
}
|
|
|
|
/// Verify the position account's address matches `(stablecoin_program_id,
|
|
/// owner, position_nonce)` and return the [`PdaSeed`] for use in post-state
|
|
/// claims.
|
|
///
|
|
/// # Panics
|
|
/// If `position.account_id` does not match the derived PDA.
|
|
pub fn verify_position_and_get_seed(
|
|
position: &AccountWithMetadata,
|
|
owner: &AccountWithMetadata,
|
|
position_nonce: u64,
|
|
stablecoin_program_id: ProgramId,
|
|
) -> PdaSeed {
|
|
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,
|
|
"Position account ID does not match expected derivation"
|
|
);
|
|
seed
|
|
}
|
|
|
|
/// Verify the vault account's address matches `(stablecoin_program_id, position)` and
|
|
/// return the [`PdaSeed`] for use in chained calls.
|
|
///
|
|
/// # Panics
|
|
/// If `vault.account_id` does not match the address derived from `position_id` and
|
|
/// `stablecoin_program_id`.
|
|
pub fn verify_position_vault_and_get_seed(
|
|
vault: &AccountWithMetadata,
|
|
position_id: AccountId,
|
|
stablecoin_program_id: ProgramId,
|
|
) -> PdaSeed {
|
|
let seed = compute_position_vault_pda_seed(position_id);
|
|
let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed);
|
|
assert_eq!(
|
|
vault.account_id, expected_id,
|
|
"Position vault account ID does not match expected derivation"
|
|
);
|
|
seed
|
|
}
|
|
|
|
/// PDA seed for the stablecoin's Token Program-owned `TokenDefinition`
|
|
/// account, derived under the stablecoin program id so the stablecoin
|
|
/// program's PDA seed authorizes every chained `Token::Mint` / `Token::Burn`.
|
|
#[must_use]
|
|
pub fn compute_stablecoin_definition_pda_seed() -> PdaSeed {
|
|
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
|
|
|
let mut out = [0u8; 32];
|
|
out.copy_from_slice(Impl::hash_bytes(&STABLECOIN_DEFINITION_PDA_DOMAIN).as_bytes());
|
|
PdaSeed::new(out)
|
|
}
|
|
|
|
#[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 empty `TokenHolding` paired with the stablecoin definition
|
|
/// at `Token::NewFungibleDefinition` time. The protocol passes `total_supply = 0`
|
|
/// so this holding stays empty forever; the PDA contains the artifact at a
|
|
/// deterministic address. See spec §3.1 / §10.1 for the full rationale.
|
|
#[must_use]
|
|
pub fn compute_stablecoin_master_holding_pda_seed() -> PdaSeed {
|
|
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
|
|
|
let mut out = [0u8; 32];
|
|
out.copy_from_slice(Impl::hash_bytes(&STABLECOIN_MASTER_HOLDING_PDA_DOMAIN).as_bytes());
|
|
PdaSeed::new(out)
|
|
}
|
|
|
|
#[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(),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod global_pda_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn stablecoin_definition_and_master_holding_pdas_are_distinct() {
|
|
let program_id: ProgramId = [11u32; 8];
|
|
let def = compute_stablecoin_definition_pda(program_id);
|
|
let master = compute_stablecoin_master_holding_pda(program_id);
|
|
assert_ne!(def, master);
|
|
assert_ne!(def, compute_protocol_parameters_pda(program_id));
|
|
assert_ne!(master, compute_stability_fee_accumulator_pda(program_id));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod instruction_tests {
|
|
use super::*;
|
|
use crate::math::FIXED_POINT_ONE;
|
|
|
|
#[test]
|
|
fn initialize_program_json_roundtrip() {
|
|
let original = Instruction::InitializeProgram {
|
|
freeze_authority_account_id: AccountId::new([0xFF; 32]),
|
|
initial_stability_fee_per_millisecond: FIXED_POINT_ONE + 1_500_000_000_000_000,
|
|
initial_controller_proportional_gain: -42,
|
|
initial_controller_integral_gain: 123_456,
|
|
initial_minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2,
|
|
minimum_milliseconds_between_rate_updates: 300_000,
|
|
maximum_oracle_price_age_milliseconds: 900_000,
|
|
initial_redemption_price: FIXED_POINT_ONE / 2,
|
|
stablecoin_name: "test-stable".to_owned(),
|
|
};
|
|
let json = serde_json::to_string(&original).expect("serialize");
|
|
let decoded: Instruction = serde_json::from_str(&json).expect("deserialize");
|
|
match decoded {
|
|
Instruction::InitializeProgram {
|
|
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,
|
|
} => {
|
|
assert_eq!(freeze_authority_account_id, AccountId::new([0xFF; 32]));
|
|
assert_eq!(
|
|
initial_stability_fee_per_millisecond,
|
|
FIXED_POINT_ONE + 1_500_000_000_000_000
|
|
);
|
|
assert_eq!(initial_controller_proportional_gain, -42);
|
|
assert_eq!(initial_controller_integral_gain, 123_456);
|
|
assert_eq!(
|
|
initial_minimum_collateralization_ratio,
|
|
FIXED_POINT_ONE * 3 / 2
|
|
);
|
|
assert_eq!(minimum_milliseconds_between_rate_updates, 300_000);
|
|
assert_eq!(maximum_oracle_price_age_milliseconds, 900_000);
|
|
assert_eq!(initial_redemption_price, FIXED_POINT_ONE / 2);
|
|
assert_eq!(stablecoin_name, "test-stable");
|
|
}
|
|
_ => panic!("decoded into wrong variant"),
|
|
}
|
|
}
|
|
}
|