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
41 changes: 41 additions & 0 deletions artifacts/stablecoin-idl.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,47 @@
}
]
},
{
"name": "deposit_collateral",
"accounts": [
{
"name": "owner",
"writable": false,
"signer": true,
"init": false
},
{
"name": "position",
"writable": true,
"signer": false,
"init": false
},
{
"name": "vault",
"writable": true,
"signer": false,
"init": false
},
{
"name": "user_collateral_holding",
"writable": true,
"signer": true,
"init": false
},
{
"name": "protocol_parameters",
"writable": false,
"signer": false,
"init": false
}
],
"args": [
{
"name": "amount",
"type": "u128"
}
]
},
{
"name": "withdraw_collateral",
"accounts": [
Expand Down
57 changes: 46 additions & 11 deletions programs/integration_tests/tests/stablecoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ impl Balances {
500_000
}

fn collateral_top_up() -> u128 {
100_000
}

fn collateral_withdraw() -> u128 {
200_000
}
Expand Down Expand Up @@ -425,6 +429,44 @@ fn stablecoin_open_position_then_withdraw_collateral() {
Balances::user_holding_init() - Balances::collateral_deposit(),
);

// Top the position up with more collateral from the same user holding.
let deposit = stablecoin_core::Instruction::DepositCollateral {
amount: Balances::collateral_top_up(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::user_holding(),
compute_protocol_parameters_pda(Ids::stablecoin_program()),
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_holding()),
],
deposit,
)
.expect("valid deposit_collateral message");
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::owner(), &Keys::user_holding()],
);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, 0)
.expect("deposit_collateral must succeed");

let after_top_up = Balances::collateral_deposit() + Balances::collateral_top_up();
assert_position(&state, after_top_up);
assert_fungible_balance(&state, Ids::vault(), after_top_up);
assert_fungible_balance(
&state,
Ids::user_holding(),
Balances::user_holding_init() - after_top_up,
);

// Withdraw part of the collateral back to the same user holding.
let withdraw = stablecoin_core::Instruction::WithdrawCollateral {
amount: Balances::collateral_withdraw(),
Expand All @@ -447,20 +489,13 @@ fn stablecoin_open_position_then_withdraw_collateral() {
.transition_from_public_transaction(&tx, 0, 0)
.expect("withdraw_collateral must succeed");

assert_position(
&state,
Balances::collateral_deposit() - Balances::collateral_withdraw(),
);
assert_fungible_balance(
&state,
Ids::vault(),
Balances::collateral_deposit() - Balances::collateral_withdraw(),
);
let after_withdraw = after_top_up - Balances::collateral_withdraw();
assert_position(&state, after_withdraw);
assert_fungible_balance(&state, Ids::vault(), after_withdraw);
assert_fungible_balance(
&state,
Ids::user_holding(),
Balances::user_holding_init() - Balances::collateral_deposit()
+ Balances::collateral_withdraw(),
Balances::user_holding_init() - after_withdraw,
);
}

Expand Down
21 changes: 21 additions & 0 deletions programs/stablecoin/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,27 @@ pub enum Instruction {
/// Collateral tokens to move into the position vault at open time.
initial_collateral_amount: u128,
},
/// Deposit additional collateral into an existing position.
///
/// Allowed while the protocol is frozen — a deposit can only improve the
/// position's collateralization, so §7 keeps it available in emergencies.
/// No collateralization check for the same reason.
///
/// Required accounts (5), in order:
/// 1. `owner` — authorized; must match `Position.owner_account_id`.
/// 2. `position` — initialized, writable, owned by this program; address must match
/// `compute_position_pda(self_program_id, owner, position_nonce)`.
/// 3. `vault` — initialized, writable; must equal `Position.vault_account_id`.
/// 4. `user_collateral_holding` — authorized, initialized; owned by the same Token Program as
/// the vault, with `TokenHolding.definition_id` equal to
/// `ProtocolParameters.collateral_definition_id`.
/// 5. `protocol_parameters` — initialized, read-only; must sit at
/// `compute_protocol_parameters_pda(self_program_id)`. Supplies the collateral definition
/// id. `is_frozen` is deliberately not read.
DepositCollateral {
/// Collateral tokens to move from the user's holding into the vault.
amount: u128,
},
/// Withdraw `amount` collateral tokens from a position back to a user-controlled holding.
///
/// Required accounts (4):
Expand Down
38 changes: 38 additions & 0 deletions programs/stablecoin/methods/guest/src/bin/stablecoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,44 @@ mod stablecoin {
))
}

/// Deposit additional collateral into an existing position (spec §10.5;
/// host fn `stablecoin_program::deposit_collateral`).
///
/// Allowed while the protocol is frozen, and runs no collateralization
/// check — a deposit can only improve the position.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition
/// fails — see the host fn for the full list.
#[instruction]
pub fn deposit_collateral(
ctx: ProgramContext,
#[account(signer)]
owner: AccountWithMetadata,
#[account(mut)]
position: AccountWithMetadata,
#[account(mut)]
vault: AccountWithMetadata,
#[account(mut, signer)]
user_collateral_holding: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
amount: u128,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::deposit_collateral::deposit_collateral(
owner,
position,
vault,
user_collateral_holding,
protocol_parameters,
ctx.self_program_id,
amount,
);
Ok(spel_framework::SpelOutput::execute(
post_states,
chained_calls,
))
}

/// Withdraw `amount` collateral tokens from an existing position back to a
/// user-controlled holding.
///
Expand Down
159 changes: 159 additions & 0 deletions programs/stablecoin/src/deposit_collateral.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
use lee_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, ProgramId},
};
use stablecoin_core::{
compute_protocol_parameters_pda, verify_position_and_get_seed, Position, ProtocolParameters,
};
use token_core::TokenHolding;

/// Deposit `amount` additional collateral tokens into an existing `position`'s vault.
///
/// Increases `Position.collateral_amount` by `amount` and emits a single chained
/// `Token::Transfer` from the user's authorized holding into the vault. No PDA
/// seed is attached: the sender is the user's own holding, which the transaction's
/// witness set authorizes.
///
/// Deliberately allowed while the protocol is frozen, and deliberately skips the
/// §6.2 collateralization check — a deposit can only improve the position, so
/// spec §7 keeps this path open in emergencies.
///
/// # Panics
/// - `owner` or `user_collateral_holding` is not authorized.
/// - `position` is uninitialized, not owned by `stablecoin_program_id`, does not decode as a
/// [`Position`], or sits at an address that does not match
/// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`.
/// - `Position.owner_account_id` does not match `owner`.
/// - `vault` does not match `Position.vault_account_id`, is uninitialized, does not decode as a
/// [`TokenHolding`], or holds a token other than the protocol's collateral definition.
/// - `protocol_parameters` is uninitialized, not owned by `stablecoin_program_id`, or does not
/// decode.
/// - `user_collateral_holding` is owned by a different Token Program than the vault, or its
/// `definition_id` is not `ProtocolParameters.collateral_definition_id`.
/// - `Position.collateral_amount + amount` overflows.
pub fn deposit_collateral(
owner: AccountWithMetadata,
position: AccountWithMetadata,
vault: AccountWithMetadata,
user_collateral_holding: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stablecoin_program_id: ProgramId,
amount: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(owner.is_authorized, "Owner authorization is missing");
assert!(
user_collateral_holding.is_authorized,
"User collateral holding 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");
// Binds the position to this owner. The seed is unused downstream — the
// position was already PDA-claimed by `open_position`.
let _position_seed = verify_position_and_get_seed(
&position,
&owner,
position_data.position_nonce,
stablecoin_program_id,
);
assert_eq!(
position_data.owner_account_id, owner.account_id,
"Position owner_account_id does not match the owner account"
);
assert_eq!(
position_data.vault_account_id, vault.account_id,
"Position vault_account_id does not match the vault account"
);

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"
);
// Pin the address: ownership plus a successful decode would otherwise let
// any stablecoin-owned account that decodes as ProtocolParameters stand in
// for the global config and redirect the collateral-definition binding.
assert_eq!(
protocol_parameters.account_id,
compute_protocol_parameters_pda(stablecoin_program_id),
"ProtocolParameters account ID does not match expected PDA derivation"
);
let parameters = ProtocolParameters::try_from(&protocol_parameters.account.data)
.expect("ProtocolParameters must decode");
// `is_frozen` is deliberately not read: a deposit only improves the
// position's collateralization, so spec §7 keeps it available when frozen.

// Validate the vault before trusting its `program_owner` to route the chained
// call. Without this the transfer would be aimed at whatever program owns the
// account and left to fail downstream, and a vault holding some other token
// would silently mis-bank the deposit.
assert_ne!(
vault.account,
Account::default(),
"Vault account must be initialized"
);
let vault_holding = TokenHolding::try_from(&vault.account.data)
.expect("Vault account must hold a valid TokenHolding");
assert_eq!(
vault_holding.definition_id(),
parameters.collateral_definition_id,
"Vault holding does not match the protocol's collateral definition"
);

let token_program_id = vault.account.program_owner;
assert_eq!(
user_collateral_holding.account.program_owner, token_program_id,
"User collateral holding must be owned by the same Token Program as the vault"
);
let user_holding = TokenHolding::try_from(&user_collateral_holding.account.data)
.expect("User holding must be a valid TokenHolding");
assert_eq!(
user_holding.definition_id(),
parameters.collateral_definition_id,
"User collateral holding does not match the protocol's collateral definition"
);

let new_collateral = position_data

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This update ignores vault_holding's balance, so a direct token donation leaves the accounting permanently desynchronized. Token::Transfer requires only the sender's authorization. Starting from position 500 / vault 500, another holder can transfer 1 to the vault; depositing 100 then writes position 600 while the chained transfer writes vault 601. Withdrawing the recorded 600 leaves position 0 / vault 1, and no further withdrawal can recover it.
Please reconcile from the fungible vault balance before adding amount (or provide an equivalent recovery path) and cover transfer -> deposit.

.collateral_amount
.checked_add(amount)
.expect("Position collateral_amount overflow");

let mut position_post = position.account.clone();
position_post.data = Data::from(&Position {
collateral_amount: new_collateral,
..position_data
});

let post_states = vec![
AccountPostState::new(owner.account),
AccountPostState::new(position_post),
AccountPostState::new(vault.account.clone()),
AccountPostState::new(user_collateral_holding.account.clone()),
AccountPostState::new(protocol_parameters.account),
];

// No PDA seed: the sender is the user's own holding, authorized by the
// transaction's witness set. The receiving vault needs no authorization.
let transfer_call = ChainedCall::new(
token_program_id,
vec![user_collateral_holding, vault],
&token_core::Instruction::Transfer {
amount_to_transfer: amount,
},
);

(post_states, vec![transfer_call])
}
3 changes: 3 additions & 0 deletions programs/stablecoin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ pub mod accrue_stability_fee;
/// Shared validation helpers reused across the position-lifecycle instructions.
pub mod checks;

/// Deposit additional collateral into an existing position.
pub mod deposit_collateral;

/// Bootstrap the protocol: create the global PDAs and the stablecoin definition.
pub mod initialize_program;

Expand Down
Loading
Loading