mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
@@ -37,6 +37,10 @@
|
||||
}
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "position_nonce",
|
||||
"type": "u64"
|
||||
},
|
||||
{
|
||||
"name": "collateral_amount",
|
||||
"type": "u128"
|
||||
@@ -217,11 +221,15 @@
|
||||
"kind": "struct",
|
||||
"fields": [
|
||||
{
|
||||
"name": "collateral_vault_id",
|
||||
"name": "owner_account_id",
|
||||
"type": "account_id"
|
||||
},
|
||||
{
|
||||
"name": "collateral_definition_id",
|
||||
"name": "position_nonce",
|
||||
"type": "u64"
|
||||
},
|
||||
{
|
||||
"name": "vault_account_id",
|
||||
"type": "account_id"
|
||||
},
|
||||
{
|
||||
@@ -229,8 +237,12 @@
|
||||
"type": "u128"
|
||||
},
|
||||
{
|
||||
"name": "debt_amount",
|
||||
"name": "normalized_debt_amount",
|
||||
"type": "u128"
|
||||
},
|
||||
{
|
||||
"name": "opened_at",
|
||||
"type": "u64"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -56,11 +56,15 @@ impl Ids {
|
||||
))
|
||||
}
|
||||
|
||||
fn position_nonce() -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
fn position() -> AccountId {
|
||||
compute_position_pda(
|
||||
Self::stablecoin_program(),
|
||||
Self::owner(),
|
||||
Self::collateral_definition(),
|
||||
Self::position_nonce(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,10 +161,12 @@ impl Accounts {
|
||||
program_owner: stablecoin_methods::STABLECOIN_ID,
|
||||
balance: 0_u128,
|
||||
data: Data::from(&Position {
|
||||
collateral_vault_id: Ids::vault(),
|
||||
collateral_definition_id: Ids::collateral_definition(),
|
||||
owner_account_id: Ids::owner(),
|
||||
position_nonce: Ids::position_nonce(),
|
||||
vault_account_id: Ids::vault(),
|
||||
collateral_amount: Balances::collateral_deposit(),
|
||||
debt_amount: Balances::initial_debt(),
|
||||
normalized_debt_amount: Balances::initial_debt(),
|
||||
opened_at: 0,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
}
|
||||
@@ -223,12 +229,10 @@ 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()
|
||||
);
|
||||
assert_eq!(position.normalized_debt_amount, 0);
|
||||
assert_eq!(position.vault_account_id, Ids::vault());
|
||||
assert_eq!(position.owner_account_id, Ids::owner());
|
||||
assert_eq!(position.position_nonce, Ids::position_nonce());
|
||||
}
|
||||
|
||||
fn assert_fungible_balance(state: &V03State, account_id: AccountId, expected_balance: u128) {
|
||||
@@ -254,6 +258,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(),
|
||||
};
|
||||
let message = public_transaction::Message::try_new(
|
||||
@@ -363,7 +368,7 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() {
|
||||
let position =
|
||||
Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position");
|
||||
assert_eq!(
|
||||
position.debt_amount,
|
||||
position.normalized_debt_amount,
|
||||
Balances::initial_debt() - Balances::debt_repay_amount()
|
||||
);
|
||||
assert_eq!(position.collateral_amount, Balances::collateral_deposit());
|
||||
|
||||
@@ -48,6 +48,9 @@ pub enum Instruction {
|
||||
/// 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,
|
||||
},
|
||||
@@ -101,19 +104,30 @@ pub enum Instruction {
|
||||
|
||||
/// 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`.
|
||||
/// 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 {
|
||||
/// 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,
|
||||
/// Amount of collateral tokens deposited.
|
||||
/// 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,
|
||||
/// Outstanding stablecoin debt against this position.
|
||||
pub debt_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 {
|
||||
@@ -126,26 +140,27 @@ 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")
|
||||
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 owned by `owner_id` for `collateral_definition_id`.
|
||||
/// PDA seed for the [`Position`] account at `(owner_id, position_nonce)`.
|
||||
///
|
||||
/// 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 {
|
||||
/// 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(&collateral_definition_id.to_bytes());
|
||||
bytes.extend_from_slice(&position_nonce.to_le_bytes());
|
||||
bytes.extend_from_slice(POSITION_PDA_DOMAIN);
|
||||
|
||||
let mut out = [0u8; 32];
|
||||
@@ -153,15 +168,17 @@ pub fn compute_position_pda_seed(
|
||||
PdaSeed::new(out)
|
||||
}
|
||||
|
||||
/// Account id of the [`Position`] PDA owned by `owner_id` under `stablecoin_program_id`.
|
||||
/// 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,
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,20 +209,19 @@ 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 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 address derived from `owner`,
|
||||
/// `collateral_definition_id`, and `stablecoin_program_id`.
|
||||
/// If `position.account_id` does not match the derived PDA.
|
||||
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,
|
||||
|
||||
@@ -18,6 +18,10 @@ mod stablecoin {
|
||||
/// Returns the host program's panic-converted error if any precondition fails (see
|
||||
/// [`stablecoin_program::open_position::open_position`] for the full list).
|
||||
#[instruction]
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "account inputs + nonce + amount mirror the host function's ABI"
|
||||
)]
|
||||
pub fn open_position(
|
||||
ctx: ProgramContext,
|
||||
#[account(signer)]
|
||||
@@ -29,6 +33,7 @@ mod stablecoin {
|
||||
#[account(mut, signer)]
|
||||
user_holding: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
position_nonce: u64,
|
||||
collateral_amount: u128,
|
||||
) -> SpelResult {
|
||||
let (post_states, chained_calls) = stablecoin_program::open_position::open_position(
|
||||
@@ -38,6 +43,7 @@ mod stablecoin {
|
||||
user_holding,
|
||||
token_definition,
|
||||
ctx.self_program_id,
|
||||
position_nonce,
|
||||
collateral_amount,
|
||||
);
|
||||
Ok(spel_framework::SpelOutput::execute(
|
||||
|
||||
@@ -23,6 +23,10 @@ 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`.
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "account inputs + program id + nonce + amount are all required; a param struct would obscure the host-call ABI"
|
||||
)]
|
||||
pub fn open_position(
|
||||
owner: AccountWithMetadata,
|
||||
position: AccountWithMetadata,
|
||||
@@ -30,6 +34,7 @@ pub fn open_position(
|
||||
user_holding: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
stablecoin_program_id: ProgramId,
|
||||
position_nonce: u64,
|
||||
collateral_amount: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
assert!(owner.is_authorized, "Owner authorization is missing");
|
||||
@@ -61,21 +66,21 @@ pub fn open_position(
|
||||
"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 mut position_post = position.account;
|
||||
position_post.data = Data::from(&Position {
|
||||
collateral_vault_id: vault.account_id,
|
||||
collateral_definition_id: token_definition.account_id,
|
||||
owner_account_id: owner.account_id,
|
||||
position_nonce,
|
||||
vault_account_id: vault.account_id,
|
||||
collateral_amount,
|
||||
debt_amount: 0,
|
||||
normalized_debt_amount: 0,
|
||||
// TODO(Plan 3): read from ctx clock once `open_position` is rebuilt with
|
||||
// the fee-aware flow. Setting 0 keeps Plan 1 a pure refactor.
|
||||
opened_at: 0,
|
||||
});
|
||||
|
||||
let post_states = vec![
|
||||
|
||||
@@ -8,17 +8,17 @@ use token_core::TokenHolding;
|
||||
/// 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
|
||||
/// position post-state uses plain [`AccountPostState::new`] — the PDA was
|
||||
/// already claimed at `open_position` time.
|
||||
/// `Token::Burn` and decreases `Position.normalized_debt_amount` by the same
|
||||
/// amount. 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 Plan 3 (stability fee accrual) lands, the fee-accrual step is a
|
||||
/// no-op (every position structurally has `normalized_debt_amount = 0` today
|
||||
/// because `generate_debt` is unimplemented; "fees-accrued" is therefore
|
||||
/// vacuously true). A `// TODO(Plan 3)` 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
|
||||
/// Until Plan 3 (`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.
|
||||
///
|
||||
@@ -26,12 +26,12 @@ use token_core::TokenHolding;
|
||||
/// - `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 > Position.normalized_debt_amount`.
|
||||
pub fn repay_debt(
|
||||
owner: AccountWithMetadata,
|
||||
position: AccountWithMetadata,
|
||||
@@ -59,7 +59,7 @@ pub fn repay_debt(
|
||||
let _position_seed = verify_position_and_get_seed(
|
||||
&position,
|
||||
&owner,
|
||||
position_data.collateral_definition_id,
|
||||
position_data.position_nonce,
|
||||
stablecoin_program_id,
|
||||
);
|
||||
|
||||
@@ -89,19 +89,22 @@ 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.
|
||||
// TODO(Plan 3): accrue stability fees onto position_data.normalized_debt_amount
|
||||
// here, before the checked_sub below. Today every position has
|
||||
// normalized_debt_amount = 0 (no generate_debt yet), so the precondition is
|
||||
// trivially met.
|
||||
let new_debt = position_data
|
||||
.debt_amount
|
||||
.normalized_debt_amount
|
||||
.checked_sub(amount)
|
||||
.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_debt,
|
||||
opened_at: position_data.opened_at,
|
||||
};
|
||||
let mut position_post = position.account.clone();
|
||||
position_post.data = Data::from(&updated_position);
|
||||
|
||||
@@ -17,6 +17,7 @@ use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8];
|
||||
const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8];
|
||||
const TEST_POSITION_NONCE: u64 = 0;
|
||||
|
||||
fn owner_id() -> AccountId {
|
||||
AccountId::new([0x10u8; 32])
|
||||
@@ -51,11 +52,7 @@ fn token_holding_account(
|
||||
}
|
||||
|
||||
fn position_id() -> AccountId {
|
||||
compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
)
|
||||
compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE)
|
||||
}
|
||||
|
||||
fn vault_id() -> AccountId {
|
||||
@@ -114,16 +111,21 @@ fn destination_holding_id() -> AccountId {
|
||||
AccountId::new([0x40u8; 32])
|
||||
}
|
||||
|
||||
fn init_position_account(collateral_amount: u128, debt_amount: u128) -> AccountWithMetadata {
|
||||
fn init_position_account(
|
||||
collateral_amount: u128,
|
||||
normalized_debt_amount: u128,
|
||||
) -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: STABLECOIN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&Position {
|
||||
collateral_vault_id: vault_id(),
|
||||
collateral_definition_id: collateral_definition_id(),
|
||||
owner_account_id: owner_id(),
|
||||
position_nonce: TEST_POSITION_NONCE,
|
||||
vault_account_id: vault_id(),
|
||||
collateral_amount,
|
||||
debt_amount,
|
||||
normalized_debt_amount,
|
||||
opened_at: 0,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
@@ -186,6 +188,7 @@ fn open_position_claims_pda_and_emits_chained_calls() {
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
collateral_amount,
|
||||
);
|
||||
|
||||
@@ -197,17 +200,19 @@ fn open_position_claims_pda_and_emits_chained_calls() {
|
||||
position_post.required_claim(),
|
||||
Some(Claim::Pda(compute_position_pda_seed(
|
||||
owner_id(),
|
||||
collateral_definition_id()
|
||||
TEST_POSITION_NONCE
|
||||
)))
|
||||
);
|
||||
let position = Position::try_from(&position_post.account().data).expect("valid Position");
|
||||
assert_eq!(
|
||||
position,
|
||||
Position {
|
||||
collateral_vault_id: vault_id(),
|
||||
collateral_definition_id: collateral_definition_id(),
|
||||
owner_account_id: owner_id(),
|
||||
position_nonce: TEST_POSITION_NONCE,
|
||||
vault_account_id: vault_id(),
|
||||
collateral_amount,
|
||||
debt_amount: 0,
|
||||
normalized_debt_amount: 0,
|
||||
opened_at: 0,
|
||||
}
|
||||
);
|
||||
// The runtime sets the program_owner on the claimed account after validating Claim::Pda.
|
||||
@@ -261,6 +266,7 @@ fn open_position_requires_owner_authorization() {
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
500,
|
||||
);
|
||||
}
|
||||
@@ -278,6 +284,7 @@ fn open_position_requires_user_holding_authorization() {
|
||||
holding,
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
500,
|
||||
);
|
||||
}
|
||||
@@ -290,10 +297,12 @@ fn open_position_rejects_initialized_position() {
|
||||
program_owner: STABLECOIN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&Position {
|
||||
collateral_vault_id: vault_id(),
|
||||
collateral_definition_id: collateral_definition_id(),
|
||||
owner_account_id: owner_id(),
|
||||
position_nonce: TEST_POSITION_NONCE,
|
||||
vault_account_id: vault_id(),
|
||||
collateral_amount: 1,
|
||||
debt_amount: 0,
|
||||
normalized_debt_amount: 0,
|
||||
opened_at: 0,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
@@ -308,6 +317,7 @@ fn open_position_rejects_initialized_position() {
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
500,
|
||||
);
|
||||
}
|
||||
@@ -336,6 +346,7 @@ fn open_position_rejects_initialized_vault() {
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
500,
|
||||
);
|
||||
}
|
||||
@@ -356,6 +367,7 @@ fn open_position_rejects_wrong_position_address() {
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
500,
|
||||
);
|
||||
}
|
||||
@@ -376,6 +388,7 @@ fn open_position_rejects_wrong_vault_address() {
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
500,
|
||||
);
|
||||
}
|
||||
@@ -406,6 +419,7 @@ fn open_position_rejects_mismatched_token_definition() {
|
||||
user_holding_account(1_000),
|
||||
other_definition,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
500,
|
||||
);
|
||||
}
|
||||
@@ -425,37 +439,26 @@ fn open_position_rejects_definition_with_wrong_token_program() {
|
||||
user_holding_account(1_000),
|
||||
definition,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
TEST_POSITION_NONCE,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_pda_is_deterministic_and_owner_and_collateral_specific() {
|
||||
let id_a = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
let id_b = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
fn position_pda_is_deterministic_and_owner_and_nonce_specific() {
|
||||
let id_a = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE);
|
||||
let id_b = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE);
|
||||
assert_eq!(id_a, id_b);
|
||||
|
||||
let other_owner = AccountId::new([0x11u8; 32]);
|
||||
assert_ne!(
|
||||
compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
other_owner,
|
||||
collateral_definition_id()
|
||||
),
|
||||
compute_position_pda(STABLECOIN_PROGRAM_ID, other_owner, TEST_POSITION_NONCE),
|
||||
id_a
|
||||
);
|
||||
|
||||
let other_definition = AccountId::new([0x21u8; 32]);
|
||||
let other_nonce = TEST_POSITION_NONCE + 1;
|
||||
assert_ne!(
|
||||
compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), other_definition),
|
||||
compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), other_nonce),
|
||||
id_a
|
||||
);
|
||||
}
|
||||
@@ -463,11 +466,7 @@ fn position_pda_is_deterministic_and_owner_and_collateral_specific() {
|
||||
#[test]
|
||||
fn position_pda_and_vault_pda_do_not_collide() {
|
||||
// Distinct domain tags must keep the position id and its vault id disjoint.
|
||||
let position = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
let position = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE);
|
||||
let vault = compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position);
|
||||
assert_ne!(position, vault);
|
||||
}
|
||||
@@ -494,10 +493,12 @@ fn withdraw_collateral_updates_position_and_emits_transfer() {
|
||||
assert_eq!(
|
||||
position,
|
||||
Position {
|
||||
collateral_vault_id: vault_id(),
|
||||
collateral_definition_id: collateral_definition_id(),
|
||||
owner_account_id: owner_id(),
|
||||
position_nonce: TEST_POSITION_NONCE,
|
||||
vault_account_id: vault_id(),
|
||||
collateral_amount: initial_collateral - amount,
|
||||
debt_amount: 0,
|
||||
normalized_debt_amount: 0,
|
||||
opened_at: 0,
|
||||
}
|
||||
);
|
||||
assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID);
|
||||
@@ -537,7 +538,7 @@ fn withdraw_collateral_allows_full_drain() {
|
||||
);
|
||||
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
|
||||
assert_eq!(position.collateral_amount, 0);
|
||||
assert_eq!(position.debt_amount, 0);
|
||||
assert_eq!(position.normalized_debt_amount, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -641,18 +642,20 @@ fn withdraw_collateral_rejects_wrong_vault_address() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Vault token holding is not for the position's collateral definition")]
|
||||
fn withdraw_collateral_rejects_vault_for_other_definition() {
|
||||
let mut vault = init_vault_account();
|
||||
vault.account.data = Data::from(&TokenHolding::Fungible {
|
||||
#[should_panic(
|
||||
expected = "Destination token definition does not match the position's collateral definition"
|
||||
)]
|
||||
fn withdraw_collateral_rejects_destination_for_other_definition() {
|
||||
let mut destination = destination_holding_account();
|
||||
destination.account.data = Data::from(&TokenHolding::Fungible {
|
||||
definition_id: AccountId::new([0x21u8; 32]),
|
||||
balance: 0,
|
||||
});
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(500, 0),
|
||||
vault,
|
||||
destination_holding_account(),
|
||||
init_vault_account(),
|
||||
destination,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
@@ -691,26 +694,6 @@ fn withdraw_collateral_rejects_destination_with_wrong_token_program() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(
|
||||
expected = "Destination token definition does not match the position's collateral definition"
|
||||
)]
|
||||
fn withdraw_collateral_rejects_destination_for_other_definition() {
|
||||
let mut destination = destination_holding_account();
|
||||
destination.account.data = Data::from(&TokenHolding::Fungible {
|
||||
definition_id: AccountId::new([0x21u8; 32]),
|
||||
balance: 0,
|
||||
});
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(500, 0),
|
||||
init_vault_account(),
|
||||
destination,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "withdraw_collateral with debt is not supported yet")]
|
||||
fn withdraw_collateral_rejects_withdrawal_with_outstanding_debt() {
|
||||
@@ -762,10 +745,12 @@ fn repay_debt_decreases_debt_and_emits_burn() {
|
||||
assert_eq!(
|
||||
position,
|
||||
Position {
|
||||
collateral_vault_id: vault_id(),
|
||||
collateral_definition_id: collateral_definition_id(),
|
||||
owner_account_id: owner_id(),
|
||||
position_nonce: TEST_POSITION_NONCE,
|
||||
vault_account_id: vault_id(),
|
||||
collateral_amount: initial_collateral,
|
||||
debt_amount: initial_debt - amount,
|
||||
normalized_debt_amount: initial_debt - amount,
|
||||
opened_at: 0,
|
||||
}
|
||||
);
|
||||
assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID);
|
||||
@@ -807,7 +792,7 @@ fn repay_debt_allows_full_repayment() {
|
||||
debt,
|
||||
);
|
||||
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
|
||||
assert_eq!(position.debt_amount, 0);
|
||||
assert_eq!(position.normalized_debt_amount, 0);
|
||||
assert_eq!(position.collateral_amount, 500);
|
||||
}
|
||||
|
||||
@@ -823,7 +808,7 @@ fn repay_debt_allows_zero_amount() {
|
||||
0,
|
||||
);
|
||||
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
|
||||
assert_eq!(position.debt_amount, initial_debt);
|
||||
assert_eq!(position.normalized_debt_amount, initial_debt);
|
||||
|
||||
let expected_burn = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
|
||||
@@ -13,22 +13,22 @@ 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
|
||||
/// Until Plan 3 lands (redemption price, price feed, stability fee accrual),
|
||||
/// this instruction hard-asserts `Position.normalized_debt_amount == 0`.
|
||||
/// When that lands, 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.
|
||||
/// `compute_position_vault_pda(stablecoin_program_id, position_id)`.
|
||||
/// - `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 vault holding's collateral
|
||||
/// definition.
|
||||
/// - `Position.normalized_debt_amount` is non-zero.
|
||||
/// - `amount > Position.collateral_amount`.
|
||||
pub fn withdraw_collateral(
|
||||
owner: AccountWithMetadata,
|
||||
@@ -57,7 +57,7 @@ pub fn withdraw_collateral(
|
||||
let _position_seed = verify_position_and_get_seed(
|
||||
&position,
|
||||
&owner,
|
||||
position_data.collateral_definition_id,
|
||||
position_data.position_nonce,
|
||||
stablecoin_program_id,
|
||||
);
|
||||
let vault_seed =
|
||||
@@ -65,11 +65,11 @@ pub fn withdraw_collateral(
|
||||
|
||||
let vault_holding = TokenHolding::try_from(&vault.account.data)
|
||||
.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"
|
||||
);
|
||||
// The vault PDA is verified to belong to this position, so its holding's
|
||||
// definition is the authoritative collateral definition. Plan 1 dropped the
|
||||
// redundant copy from `Position`; `ProtocolParameters` owns the global
|
||||
// collateral definition from Plan 3 onward.
|
||||
let collateral_definition_id = vault_holding.definition_id();
|
||||
|
||||
let token_program_id = vault.account.program_owner;
|
||||
assert_ne!(
|
||||
@@ -85,13 +85,13 @@ pub fn withdraw_collateral(
|
||||
.expect("Destination account must hold a valid TokenHolding");
|
||||
assert_eq!(
|
||||
destination_holding.definition_id(),
|
||||
position_data.collateral_definition_id,
|
||||
collateral_definition_id,
|
||||
"Destination token definition does not match the position's 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"
|
||||
position_data.normalized_debt_amount, 0,
|
||||
"withdraw_collateral with debt is not supported yet — fee accrual + collateralization check land in Plan 3"
|
||||
);
|
||||
let new_collateral = position_data
|
||||
.collateral_amount
|
||||
@@ -99,10 +99,12 @@ pub fn withdraw_collateral(
|
||||
.expect("Withdrawal amount exceeds position collateral");
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user