Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions artifacts/stablecoin-idl.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,25 @@
"init": true
},
{
"name": "user_holding",
"name": "user_collateral_holding",
"writable": true,
"signer": true,
"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
Expand All @@ -247,7 +259,7 @@
"type": "u64"
},
{
"name": "collateral_amount",
"name": "initial_collateral_amount",
"type": "u128"
}
]
Expand Down
39 changes: 38 additions & 1 deletion programs/integration_tests/tests/stablecoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,33 @@ impl Accounts {
}
}

/// A bootstrapped `ProtocolParameters`, force-inserted rather than produced by
/// `initialize_program`, so the position tests stay independent of the bootstrap
/// flow. The full lifecycle test in Plan 3 issue 08 uses the real bootstrap.
fn protocol_parameters_init() -> Account {
Account {
program_owner: Ids::stablecoin_program(),
balance: 0,
data: Data::from(&stablecoin_core::ProtocolParameters {
admin_account_id: Ids::admin(),
freeze_authority_account_id: Ids::freeze_authority(),
stablecoin_definition_id: Ids::stablecoin_definition(),
collateral_definition_id: Ids::collateral_definition(),
market_price_oracle_id: Ids::oracle(),
stability_fee_per_millisecond: protocol_config::STABILITY_FEE_PER_MILLISECOND,
controller_proportional_gain: 0,
controller_integral_gain: 0,
minimum_collateralization_ratio: stablecoin_core::math::FIXED_POINT_ONE * 3 / 2,
minimum_milliseconds_between_rate_updates:
protocol_config::MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES,
maximum_oracle_price_age_milliseconds:
protocol_config::MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS,
is_frozen: false,
}),
nonce: Nonce(0),
}
}

fn oracle_init(base_asset: AccountId, quote_asset: AccountId) -> Account {
Self::oracle_with(
base_asset,
Expand Down Expand Up @@ -296,6 +323,11 @@ fn state_for_stablecoin_tests() -> V03State {
..Account::default()
},
);
state.force_insert_account(
compute_protocol_parameters_pda(Ids::stablecoin_program()),
Accounts::protocol_parameters_init(),
);
seed_clock(&mut state, OPEN_POSITION_NOW);
state
}

Expand Down Expand Up @@ -356,7 +388,7 @@ fn stablecoin_open_position_then_withdraw_collateral() {
// Open the position: deposit collateral from the user's holding into a fresh vault.
let open = stablecoin_core::Instruction::OpenPosition {
position_nonce: Ids::position_nonce(),
collateral_amount: Balances::collateral_deposit(),
initial_collateral_amount: Balances::collateral_deposit(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
Expand All @@ -366,6 +398,8 @@ fn stablecoin_open_position_then_withdraw_collateral() {
Ids::vault(),
Ids::user_holding(),
Ids::collateral_definition(),
compute_protocol_parameters_pda(Ids::stablecoin_program()),
CLOCK_01_PROGRAM_ACCOUNT_ID,
],
vec![
current_nonce(&state, Ids::owner()),
Expand Down Expand Up @@ -503,6 +537,9 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() {

/// Protocol parameters the initialized-protocol helper installs. Kept as
/// constants so the poke tests can reason about the interval / staleness gates.
/// Wall clock for the open/withdraw fixture, in Unix milliseconds.
const OPEN_POSITION_NOW: u64 = 1_700_000_000_000;

mod protocol_config {
use stablecoin_core::math::FIXED_POINT_ONE;

Expand Down
32 changes: 20 additions & 12 deletions programs/stablecoin/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,22 +138,30 @@ pub enum Instruction {
RefreshGlobals,
/// 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)
/// The position starts with no debt — spec §10.4 deliberately omits an
/// initial-debt parameter; borrowing is a separate `GenerateDebt`. Blocked
/// while the protocol is frozen.
///
/// Required accounts (7), in order:
/// 1. `owner` — authorized.
/// 2. `position` — uninitialized; address must match `compute_position_pda(self_program_id,
/// owner, position_nonce)`.
/// 3. `vault` — uninitialized position vault token holding; address must match
/// `compute_position_vault_pda(self_program_id, position_id)`.
/// 4. `user_collateral_holding` — authorized, initialized; the owner's source holding for the
/// collateral.
/// 5. `collateral_definition` — initialized; must equal
/// `protocol_parameters.collateral_definition_id`. Its `program_owner` determines the Token
/// Program used by the chained `InitializeAccount` and `Transfer` calls.
/// 6. `protocol_parameters` — initialized, read-only; supplies the single global collateral
/// definition id and the freeze flag.
/// 7. `clock` — the system `CLOCK_01` account; read-only. Stamps `opened_at`.
Comment on lines +153 to +158
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,
/// Collateral tokens to move into the position vault at open time.
initial_collateral_amount: u128,
},
/// Withdraw `amount` collateral tokens from a position back to a user-controlled holding.
///
Expand Down
24 changes: 17 additions & 7 deletions programs/stablecoin/methods/guest/src/bin/stablecoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,13 @@ mod stablecoin {
))
}

/// Open a new collateral-only position for the calling owner.
/// Open a new collateral-only position for the calling owner (spec §10.4;
/// host fn `stablecoin_program::open_position`).
///
/// Reads the single global collateral definition id and the freeze flag from
/// `protocol_parameters`. Wall-clock time for `opened_at` comes from the
/// system `CLOCK_01` account passed as the 7th input — the pinned
/// `ProgramContext` exposes no clock.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition fails (see
Expand All @@ -212,20 +218,24 @@ mod stablecoin {
#[account(init)]
vault: AccountWithMetadata,
#[account(mut, signer)]
user_holding: AccountWithMetadata,
token_definition: AccountWithMetadata,
user_collateral_holding: AccountWithMetadata,
collateral_definition: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
position_nonce: u64,
collateral_amount: u128,
initial_collateral_amount: u128,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::open_position::open_position(
owner,
position,
vault,
user_holding,
token_definition,
user_collateral_holding,
collateral_definition,
protocol_parameters,
clock,
ctx.self_program_id,
position_nonce,
collateral_amount,
initial_collateral_amount,
);
Ok(spel_framework::SpelOutput::execute(
post_states,
Expand Down
74 changes: 49 additions & 25 deletions programs/stablecoin/src/open_position.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use lee_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, Claim, ProgramId},
};
use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position};
use stablecoin_core::{
verify_position_and_get_seed, verify_position_vault_and_get_seed, Position, ProtocolParameters,
};
use token_core::TokenHolding;

/// Open a new collateral-only position for `owner`.
Expand All @@ -17,12 +19,12 @@ use token_core::TokenHolding;
/// not parameterized here.
///
/// # Panics
/// - `owner` or `user_holding` is not authorized.
/// - `owner` or `user_collateral_holding` is not authorized.
/// - `position` or `vault` is already initialized.
/// - `position.account_id` / `vault.account_id` do not match their PDA derivations.
/// - `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`.
/// - `user_collateral_holding` cannot be decoded as a [`TokenHolding`].
/// - `user_collateral_holding`'s definition does not match `collateral_definition`.
/// - `collateral_definition.program_owner` does not match `user_collateral_holding.program_owner`.
#[allow(
clippy::too_many_arguments,
reason = "account inputs + program id + nonce + amount are all required; a param struct would obscure the host-call ABI"
Expand All @@ -31,15 +33,17 @@ pub fn open_position(
owner: AccountWithMetadata,
position: AccountWithMetadata,
vault: AccountWithMetadata,
user_holding: AccountWithMetadata,
token_definition: AccountWithMetadata,
user_collateral_holding: AccountWithMetadata,
collateral_definition: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
position_nonce: u64,
collateral_amount: u128,
initial_collateral_amount: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(owner.is_authorized, "Owner authorization is missing");
assert!(
user_holding.is_authorized,
user_collateral_holding.is_authorized,
"User collateral holding authorization is missing"
);
assert_eq!(
Expand All @@ -53,16 +57,36 @@ pub fn open_position(
"Position vault account must be uninitialized"
);

let user_holding_definition_id = TokenHolding::try_from(&user_holding.account.data)
.expect("User holding must be a valid Token Holding")
.definition_id();
assert_ne!(
protocol_parameters.account,
Account::default(),
"ProtocolParameters account must be initialized"
);
assert_eq!(
protocol_parameters.account.program_owner, stablecoin_program_id,
"ProtocolParameters account must be owned by the stablecoin program"
);
let parameters = ProtocolParameters::try_from(&protocol_parameters.account.data)
.expect("ProtocolParameters must decode");
Comment on lines +65 to +70
assert!(!parameters.is_frozen, "Protocol is frozen");
assert_eq!(
collateral_definition.account_id, parameters.collateral_definition_id,
"Collateral definition does not match the one bound at initialize_program"
);

let now = crate::accrue_stability_fee::read_clock(&clock);

let user_collateral_holding_definition_id =
TokenHolding::try_from(&user_collateral_holding.account.data)
.expect("User holding must be a valid Token Holding")
.definition_id();
assert_eq!(
user_holding_definition_id, token_definition.account_id,
user_collateral_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;
let token_program_id = user_collateral_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"
);

Expand All @@ -76,19 +100,19 @@ pub fn open_position(
owner_account_id: owner.account_id,
position_nonce,
vault_account_id: vault.account_id,
collateral_amount,
collateral_amount: initial_collateral_amount,
normalized_debt_amount: 0,
// TODO(#173): read from ctx clock once `open_position` is rebuilt with
// the fee-aware flow. Setting 0 keeps #156 a pure refactor.
opened_at: 0,
opened_at: now,
});

let post_states = vec![
AccountPostState::new(owner.account),
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(user_collateral_holding.account.clone()),
AccountPostState::new(collateral_definition.account.clone()),
AccountPostState::new(protocol_parameters.account),
AccountPostState::new(clock.account),
];

// Chained Token::InitializeAccount owns the vault as a Token holding. The Stablecoin
Expand All @@ -97,7 +121,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]);
Expand All @@ -110,7 +134,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,
Expand All @@ -120,9 +144,9 @@ pub fn open_position(
};
let transfer_call = ChainedCall::new(
token_program_id,
vec![user_holding, post_init_vault],
vec![user_collateral_holding, post_init_vault],
&token_core::Instruction::Transfer {
amount_to_transfer: collateral_amount,
amount_to_transfer: initial_collateral_amount,
},
);

Expand Down
Loading
Loading