2026-05-19 15:59:10 +02:00
|
|
|
use nssa_core::{
|
|
|
|
|
account::{Account, AccountWithMetadata, Data},
|
|
|
|
|
program::{AccountPostState, ChainedCall, ProgramId},
|
|
|
|
|
};
|
|
|
|
|
use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position};
|
|
|
|
|
use token_core::TokenHolding;
|
|
|
|
|
|
|
|
|
|
/// Withdraw `amount` collateral tokens from `position`'s vault back to `destination`.
|
|
|
|
|
///
|
|
|
|
|
/// Decreases `Position.collateral_amount` by `amount` and emits a single chained
|
|
|
|
|
/// `Token::Transfer` from the vault to `destination`, authorized by the vault
|
|
|
|
|
/// PDA seed. The position post-state uses plain [`AccountPostState::new`] —
|
|
|
|
|
/// the initial PDA claim already happened in
|
|
|
|
|
/// [`crate::open_position::open_position`].
|
|
|
|
|
///
|
2026-08-19 09:59:45 +00:00
|
|
|
/// Until #173 lands (redemption price, price feed, stability fee accrual),
|
2026-08-18 15:33:32 +00:00
|
|
|
/// this instruction hard-asserts `Position.normalized_debt_amount == 0`.
|
|
|
|
|
/// When that lands, this guard is replaced by real fee accrual + a
|
2026-05-19 15:59:10 +02:00
|
|
|
/// 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
|
2026-08-18 15:33:32 +00:00
|
|
|
/// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`.
|
2026-05-19 15:59:10 +02:00
|
|
|
/// - `vault` sits at an address that does not match
|
2026-08-18 15:33:32 +00:00
|
|
|
/// `compute_position_vault_pda(stablecoin_program_id, position_id)`.
|
2026-05-19 15:59:10 +02:00
|
|
|
/// - `destination` is uninitialized, owned by a different Token Program than the vault, or holds a
|
2026-08-18 15:33:32 +00:00
|
|
|
/// [`TokenHolding`] whose `definition_id` does not match the vault holding's collateral
|
|
|
|
|
/// definition.
|
|
|
|
|
/// - `Position.normalized_debt_amount` is non-zero.
|
2026-05-19 15:59:10 +02:00
|
|
|
/// - `amount > Position.collateral_amount`.
|
|
|
|
|
pub fn withdraw_collateral(
|
|
|
|
|
owner: AccountWithMetadata,
|
|
|
|
|
position: AccountWithMetadata,
|
|
|
|
|
vault: AccountWithMetadata,
|
|
|
|
|
destination: AccountWithMetadata,
|
|
|
|
|
stablecoin_program_id: ProgramId,
|
|
|
|
|
amount: u128,
|
|
|
|
|
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
|
|
|
|
assert!(owner.is_authorized, "Owner authorization is missing");
|
|
|
|
|
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");
|
|
|
|
|
// `verify_position_and_get_seed` asserts the position address matches the
|
2026-08-19 09:59:45 +00:00
|
|
|
// (owner, position_nonce) PDA derivation. We do not use the seed
|
2026-05-19 15:59:10 +02:00
|
|
|
// downstream — the position is already PDA-claimed.
|
|
|
|
|
let _position_seed = verify_position_and_get_seed(
|
|
|
|
|
&position,
|
|
|
|
|
&owner,
|
2026-08-18 15:33:32 +00:00
|
|
|
position_data.position_nonce,
|
2026-05-19 15:59:10 +02:00
|
|
|
stablecoin_program_id,
|
|
|
|
|
);
|
2026-08-19 09:59:45 +00:00
|
|
|
// The PDA derivation above already binds the owner; this guards the stored
|
|
|
|
|
// discovery copy against silently drifting out of sync.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
position_data.owner_account_id, owner.account_id,
|
|
|
|
|
"Position owner_account_id does not match the owner account"
|
|
|
|
|
);
|
2026-05-19 15:59:10 +02:00
|
|
|
let vault_seed =
|
|
|
|
|
verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id);
|
2026-08-19 09:59:45 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
position_data.vault_account_id, vault.account_id,
|
|
|
|
|
"Position vault_account_id does not match the vault account"
|
|
|
|
|
);
|
2026-05-19 15:59:10 +02:00
|
|
|
|
|
|
|
|
let vault_holding = TokenHolding::try_from(&vault.account.data)
|
|
|
|
|
.expect("Vault account must hold a valid TokenHolding");
|
2026-08-18 15:33:32 +00:00
|
|
|
// 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
|
2026-08-19 09:59:45 +00:00
|
|
|
// collateral definition from #173 onward.
|
2026-08-18 15:33:32 +00:00
|
|
|
let collateral_definition_id = vault_holding.definition_id();
|
2026-05-19 15:59:10 +02:00
|
|
|
|
|
|
|
|
let token_program_id = vault.account.program_owner;
|
|
|
|
|
assert_ne!(
|
|
|
|
|
destination.account,
|
|
|
|
|
Account::default(),
|
|
|
|
|
"Destination must be initialized"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
destination.account.program_owner, token_program_id,
|
|
|
|
|
"Destination must be owned by the same Token Program as the vault"
|
|
|
|
|
);
|
|
|
|
|
let destination_holding = TokenHolding::try_from(&destination.account.data)
|
|
|
|
|
.expect("Destination account must hold a valid TokenHolding");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
destination_holding.definition_id(),
|
2026-08-18 15:33:32 +00:00
|
|
|
collateral_definition_id,
|
2026-05-19 15:59:10 +02:00
|
|
|
"Destination token definition does not match the position's collateral definition"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
2026-08-18 15:33:32 +00:00
|
|
|
position_data.normalized_debt_amount, 0,
|
2026-08-19 09:59:45 +00:00
|
|
|
"withdraw_collateral with debt is not supported yet — fee accrual + collateralization check land in #173"
|
2026-05-19 15:59:10 +02:00
|
|
|
);
|
|
|
|
|
let new_collateral = position_data
|
|
|
|
|
.collateral_amount
|
|
|
|
|
.checked_sub(amount)
|
|
|
|
|
.expect("Withdrawal amount exceeds position collateral");
|
|
|
|
|
|
|
|
|
|
let updated_position = Position {
|
2026-08-18 15:33:32 +00:00
|
|
|
owner_account_id: position_data.owner_account_id,
|
|
|
|
|
position_nonce: position_data.position_nonce,
|
|
|
|
|
vault_account_id: position_data.vault_account_id,
|
2026-05-19 15:59:10 +02:00
|
|
|
collateral_amount: new_collateral,
|
2026-08-18 15:33:32 +00:00
|
|
|
normalized_debt_amount: position_data.normalized_debt_amount,
|
|
|
|
|
opened_at: position_data.opened_at,
|
2026-05-19 15:59:10 +02:00
|
|
|
};
|
|
|
|
|
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(vault.account.clone()),
|
|
|
|
|
AccountPostState::new(destination.account.clone()),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let mut vault_authorized = vault.clone();
|
|
|
|
|
vault_authorized.is_authorized = true;
|
|
|
|
|
let transfer_call = ChainedCall::new(
|
|
|
|
|
token_program_id,
|
|
|
|
|
vec![vault_authorized, destination],
|
|
|
|
|
&token_core::Instruction::Transfer {
|
|
|
|
|
amount_to_transfer: amount,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.with_pda_seeds(vec![vault_seed]);
|
|
|
|
|
|
|
|
|
|
(post_states, vec![transfer_call])
|
|
|
|
|
}
|