feat(stablecoin): expose initialize_program guest entry + e2e test

closes #164
This commit is contained in:
Andrea Franz
2026-08-20 14:21:58 +02:00
parent 0410d83ae4
commit ebc9b4b9fe
3 changed files with 370 additions and 1 deletions
+97
View File
@@ -2,6 +2,103 @@
"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": "open_position",
"accounts": [
+208 -1
View File
@@ -1,9 +1,14 @@
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
use nssa::{
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::{
compute_position_pda, compute_position_vault_pda, compute_protocol_parameters_pda,
compute_redemption_price_state_pda, compute_stability_fee_accumulator_pda,
compute_stablecoin_definition_pda, compute_stablecoin_master_holding_pda, Position,
};
use token_core::{TokenDefinition, TokenHolding};
struct Keys;
@@ -23,6 +28,10 @@ impl Keys {
fn user_stablecoin_holding() -> PrivateKey {
PrivateKey::try_new([43; 32]).expect("valid private key")
}
fn admin() -> PrivateKey {
PrivateKey::try_new([44; 32]).expect("valid private key")
}
}
impl Ids {
@@ -71,6 +80,25 @@ impl Ids {
fn vault() -> AccountId {
compute_position_vault_pda(Self::stablecoin_program(), Self::position())
}
fn admin() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::admin()))
}
fn freeze_authority() -> AccountId {
AccountId::new([0xFE; 32])
}
fn oracle() -> AccountId {
AccountId::new([0x70; 32])
}
/// The stablecoin's `TokenDefinition` PDA created by `initialize_program`
/// (distinct from `stablecoin_definition`, the externally-owned definition
/// the repay test uses).
fn stablecoin_definition_pda() -> AccountId {
compute_stablecoin_definition_pda(Self::stablecoin_program())
}
}
impl Balances {
@@ -171,6 +199,37 @@ impl Accounts {
nonce: Nonce(0),
}
}
fn oracle_init(base_asset: AccountId, quote_asset: AccountId) -> Account {
Account {
program_owner: [9u32; 8],
balance: 0_u128,
data: Data::from(&twap_oracle_core::OraclePriceAccount {
base_asset,
quote_asset,
price: stablecoin_core::math::FIXED_POINT_ONE / 2,
timestamp: 0,
source_id: Ids::oracle(),
confidence_interval: 0,
}),
nonce: Nonce(0),
}
}
}
/// Seeds the canonical `CLOCK_01` account at `timestamp`. `V03State::new()` no longer
/// auto-creates it, and `initialize_program` reads it for wall-clock time.
fn seed_clock(state: &mut V03State, timestamp: u64) {
let data = ClockAccountData {
block_id: 0,
timestamp,
}
.to_bytes();
let clock_account = Account {
data: Data::try_from(data).expect("clock account data fits"),
..Account::default()
};
state.force_insert_account(CLOCK_01_PROGRAM_ACCOUNT_ID, clock_account);
}
fn deploy_programs(state: &mut V03State) {
@@ -403,3 +462,151 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() {
}
}
}
#[test]
fn stablecoin_initialize_program_creates_globals_and_stablecoin_definition() {
use stablecoin_core::math::FIXED_POINT_ONE;
// `V03State::new()` no longer auto-creates the clock account; seed CLOCK_01 with this
// timestamp, which initialize_program reads as `now` to anchor the accumulator and
// redemption-price state.
let now: u64 = 1_700_000_000;
let mut state = V03State::new();
seed_clock(&mut state, now);
deploy_programs(&mut state);
// Externally-created collateral definition + market-price oracle.
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
state.force_insert_account(
Ids::oracle(),
Accounts::oracle_init(
Ids::stablecoin_definition_pda(),
Ids::collateral_definition(),
),
);
let instruction = stablecoin_core::Instruction::InitializeProgram {
freeze_authority_account_id: Ids::freeze_authority(),
initial_stability_fee_per_millisecond: FIXED_POINT_ONE + 1_500_000_000_000_000,
initial_controller_proportional_gain: 0,
initial_controller_integral_gain: 0,
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: String::from("test-stable"),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::admin(),
compute_protocol_parameters_pda(Ids::stablecoin_program()),
compute_stability_fee_accumulator_pda(Ids::stablecoin_program()),
compute_redemption_price_state_pda(Ids::stablecoin_program()),
Ids::stablecoin_definition_pda(),
compute_stablecoin_master_holding_pda(Ids::stablecoin_program()),
Ids::collateral_definition(),
Ids::oracle(),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![current_nonce(&state, Ids::admin())],
instruction,
)
.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, 1, now)
.expect("initialize_program must succeed");
// ProtocolParameters claimed with the expected handles.
let pp = stablecoin_core::ProtocolParameters::try_from(
&state
.get_account_by_id(compute_protocol_parameters_pda(Ids::stablecoin_program()))
.data,
)
.expect("valid ProtocolParameters");
assert_eq!(pp.admin_account_id, Ids::admin());
assert_eq!(pp.freeze_authority_account_id, Ids::freeze_authority());
assert_eq!(
pp.stablecoin_definition_id,
Ids::stablecoin_definition_pda()
);
assert_eq!(pp.collateral_definition_id, Ids::collateral_definition());
assert_eq!(pp.market_price_oracle_id, Ids::oracle());
assert!(!pp.is_frozen);
// Accumulator anchored at FIXED_POINT_ONE / now.
let acc = stablecoin_core::StabilityFeeAccumulator::try_from(
&state
.get_account_by_id(compute_stability_fee_accumulator_pda(
Ids::stablecoin_program(),
))
.data,
)
.expect("valid StabilityFeeAccumulator");
assert_eq!(acc.accumulated_rate_at_last_accrual, FIXED_POINT_ONE);
assert_eq!(acc.last_accrued_at, now);
// Redemption price anchored at the initial value / now.
let rp = stablecoin_core::RedemptionPriceState::try_from(
&state
.get_account_by_id(compute_redemption_price_state_pda(Ids::stablecoin_program()))
.data,
)
.expect("valid RedemptionPriceState");
assert_eq!(rp.redemption_price_at_last_update, FIXED_POINT_ONE / 2);
assert_eq!(rp.redemption_rate_per_millisecond, FIXED_POINT_ONE);
assert_eq!(rp.controller_integral_term, 0);
assert_eq!(rp.last_updated_at, now);
// Stablecoin definition created via the chained Token::NewFungibleDefinition.
let definition = TokenDefinition::try_from(
&state
.get_account_by_id(Ids::stablecoin_definition_pda())
.data,
)
.expect("valid TokenDefinition");
match definition {
TokenDefinition::Fungible {
name,
total_supply,
metadata_id,
authority,
} => {
assert_eq!(name, "test-stable");
assert_eq!(total_supply, 0);
assert_eq!(metadata_id, None);
// Self/PDA authority: the definition is its own mint authority, so later
// debt operations can mint/burn by presenting the definition PDA seed.
assert_eq!(authority, Some(Ids::stablecoin_definition_pda()));
}
TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"),
}
// Empty master holding created alongside the definition.
let master = TokenHolding::try_from(
&state
.get_account_by_id(compute_stablecoin_master_holding_pda(
Ids::stablecoin_program(),
))
.data,
)
.expect("valid TokenHolding");
match master {
TokenHolding::Fungible {
definition_id,
balance,
} => {
assert_eq!(definition_id, Ids::stablecoin_definition_pda());
assert_eq!(balance, 0);
}
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => {
panic!("expected Fungible holding")
}
}
}
@@ -12,6 +12,71 @@ mod stablecoin {
#[allow(unused_imports)]
use super::*;
/// Bootstrap the protocol (see spec §10.1 and host-function
/// `stablecoin_program::initialize_program::initialize_program`).
///
/// Wall-clock time is read from the system `CLOCK_01` account passed as the
/// 9th input — the pinned `ProgramContext` exposes no clock.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition
/// fails — see the host fn for the full list.
#[instruction]
#[allow(
clippy::too_many_arguments,
reason = "nine account inputs + the numerical params mirror the host function's ABI"
)]
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: 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,
) -> 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,
stablecoin_program::initialize_program::InitializeProgramParams {
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: &stablecoin_name,
},
);
Ok(spel_framework::SpelOutput::execute(
post_states,
chained_calls,
))
}
/// Open a new collateral-only position for the calling owner.
///
/// # Errors