Issue 03 — deposit_collateral instruction
Plan: Stablecoin Plan 3 — Position Lifecycle
Depends on: Plan 1 (whole plan).
Blocks: 08 (lifecycle integration test).
Goal: Add a new deposit_collateral instruction that lets a position owner top up collateral on an existing position. Single chained Token::Transfer from the user's authorized collateral holding into the vault, position's collateral_amount increments. Allowed when frozen (it can only improve the protocol's collateralization).
Spec reference
§10.5 deposit_collateral — the 5-account input list, single chained Token::Transfer, no collateralization check (deposit-only improves it).
Why
deposit_collateral is the inverse of withdraw_collateral. The Plan 1 scaffold had open_position deposit the initial collateral, but no way to add more later. Without deposit_collateral, an owner whose position drifts toward the collateralization threshold (because debt accrued or the redemption price moved) has no path to add collateral short of closing the position — which is impossible while debt is outstanding. This unblocks the most common defensive user action.
It's allowed when frozen because the spec deliberately keeps "things that improve protocol health" available during emergencies (§7 invariant 8): if the protocol is frozen because something went wrong, owners should still be able to deleverage by adding collateral or repaying debt.
Architecture
A new programs/stablecoin/src/deposit_collateral.rs module with a pure host function. The chained Token::Transfer goes from the user's authorized holding to the vault, no PDA seed required (the sender is user-authorized). The position post-state mutates collateral_amount only; everything else unchanged.
Note: protocol_parameters is in the input list per the spec, but this instruction only reads collateral_definition_id from it (to validate the user holding's definition_id matches). It does NOT check is_frozen — deposits are explicitly allowed when frozen.
Files
- Create:
programs/stablecoin/src/deposit_collateral.rs — the host function.
- Modify:
programs/stablecoin/src/lib.rs — pub mod deposit_collateral;.
- Modify:
programs/stablecoin/core/src/lib.rs — add Instruction::DepositCollateral { amount: u128 }.
- Modify:
programs/stablecoin/methods/guest/src/bin/stablecoin.rs — guest entrypoint.
- Modify:
programs/stablecoin/src/tests.rs — happy-path + panic tests.
- Regenerate:
artifacts/stablecoin-idl.json via make idl.
Acceptance criteria
cargo test -p stablecoin_program tests::deposit_collateral_ passes (9+ tests below).
make clippy is green.
make idl regenerates a stable IDL.
- Happy-path emits exactly 5 post-states (owner, position, vault, user_holding, protocol_parameters) and 1 chained
Token::Transfer.
- Frozen-state test does NOT panic (deposits allowed when frozen — opposite of
open_position's frozen test).
position.collateral_amount overflow panics with "Position collateral_amount overflow".
Implementation steps
In programs/stablecoin/core/src/lib.rs, inside the pub enum Instruction { ... }, after OpenPosition, add:
/// Deposit additional collateral into an existing position.
///
/// Required accounts (5):
/// - Owner account (authorized; binds caller-as-owner via Position.owner_account_id)
/// - Position account (initialized, owned by `self_program_id`)
/// - Position vault token holding (must equal `Position.vault_account_id`)
/// - User's source collateral holding (authorized, initialized, owned by the same
/// Token Program as the vault, with `TokenHolding.definition_id ==
/// ProtocolParameters.collateral_definition_id`)
/// - ProtocolParameters account (initialized, read-only — for collateral definition lookup)
DepositCollateral {
/// Collateral tokens to move from `user_collateral_holding` into the vault.
amount: u128,
},
Run cargo check -p stablecoin_core. Expected: clean.
Create programs/stablecoin/src/deposit_collateral.rs:
use nssa_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, ProgramId},
};
use stablecoin_core::{verify_position_and_get_seed, Position, ProtocolParameters};
use token_core::TokenHolding;
/// Deposit `amount` additional collateral tokens into an existing position.
///
/// Allowed when the protocol is frozen (spec §10.5 + §7 invariant 8). Emits a
/// single chained `Token::Transfer` from `user_collateral_holding` to `vault`.
///
/// # Panics
/// - `owner.is_authorized = false`.
/// - `user_collateral_holding.is_authorized = false`.
/// - `position` uninitialized, not owned by `stablecoin_program_id`, or fails PDA verification.
/// - `protocol_parameters` uninitialized or fails to decode.
/// - `vault.account_id != position.vault_account_id`.
/// - User holding's `definition_id` mismatches `protocol_parameters.collateral_definition_id`
/// or its Token Program differs from the vault's.
/// - `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");
// PDA address check — re-derive the seed and assert.
let _position_seed = verify_position_and_get_seed(
&position,
&owner,
position_data.position_nonce,
stablecoin_program_id,
);
assert_ne!(
protocol_parameters.account,
Account::default(),
"ProtocolParameters account must be initialized"
);
let params = ProtocolParameters::try_from(&protocol_parameters.account.data)
.expect("ProtocolParameters must decode");
// Vault address must match the position's recorded vault.
assert_eq!(
vault.account_id, position_data.vault_account_id,
"Vault account does not match Position.vault_account_id"
);
// User holding must hold the global collateral definition under the same Token Program
// as the vault.
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_definition_id = TokenHolding::try_from(&user_collateral_holding.account.data)
.expect("User holding must be a valid TokenHolding")
.definition_id();
assert_eq!(
user_holding_definition_id, params.collateral_definition_id,
"User collateral holding does not match ProtocolParameters.collateral_definition_id"
);
// Apply the increment with overflow protection.
let new_collateral = position_data
.collateral_amount
.checked_add(amount)
.expect("Position collateral_amount overflow");
let updated_position = Position {
collateral_amount: new_collateral,
..position_data
};
let mut position_post = position.account.clone();
position_post.data = Data::from(&updated_position);
let post_states = vec![
AccountPostState::new(owner.account.clone()),
AccountPostState::new(position_post),
AccountPostState::new(vault.account.clone()),
AccountPostState::new(user_collateral_holding.account.clone()),
AccountPostState::new(protocol_parameters.account.clone()),
];
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])
}
Edit programs/stablecoin/src/lib.rs and add (alphabetical or logical order; matches existing style):
/// Add additional collateral to an existing position.
pub mod deposit_collateral;
Run cargo check -p stablecoin_program. Expected: clean.
In programs/stablecoin/methods/guest/src/bin/stablecoin.rs, append a new #[instruction] inside the #[lez_program] module (after open_position):
/// Deposit additional collateral into an existing position.
///
/// # Errors
/// Returns the host program's panic-converted error if any precondition fails (see
/// [`stablecoin_program::deposit_collateral::deposit_collateral`] for the full list).
#[instruction]
pub fn deposit_collateral(
ctx: ProgramContext,
owner: AccountWithMetadata,
position: AccountWithMetadata,
vault: AccountWithMetadata,
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,
))
}
(No ctx.now needed — deposit_collateral doesn't reference time.)
Run cargo check -p stablecoin_methods. Expected: clean.
In programs/stablecoin/src/tests.rs, append:
#[test]
fn deposit_collateral_adds_to_position_and_emits_transfer() {
let initial_collateral: u128 = 500;
let amount: u128 = 200;
let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(initial_collateral, 0),
init_vault_account(),
user_holding_account(1_000),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
amount,
);
assert_eq!(post_states.len(), 5);
let position_post = &post_states[1];
assert_eq!(position_post.required_claim(), None);
let position = Position::try_from(&position_post.account().data).expect("valid Position");
assert_eq!(position.collateral_amount, initial_collateral + amount);
assert_eq!(position.normalized_debt_amount, 0);
assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID);
// Vault and user holding pre-mutation — chained call carries the change.
assert_eq!(post_states[2].account(), &init_vault_account().account);
assert_eq!(post_states[3].account(), &user_holding_account(1_000).account);
assert_eq!(chained_calls.len(), 1);
let expected_transfer = ChainedCall::new(
TOKEN_PROGRAM_ID,
vec![user_holding_account(1_000), init_vault_account()],
&token_core::Instruction::Transfer {
amount_to_transfer: amount,
},
);
assert_eq!(chained_calls[0], expected_transfer);
}
Run cargo test -p stablecoin_program tests::deposit_collateral_adds_to_position_and_emits_transfer. Expected: 1 PASS.
Append:
#[test]
fn deposit_collateral_works_when_frozen() {
// Same setup but ProtocolParameters has is_frozen = true.
let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(500, 0),
init_vault_account(),
user_holding_account(1_000),
protocol_parameters_account(true),
STABLECOIN_PROGRAM_ID,
200,
);
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
assert_eq!(position.collateral_amount, 700);
assert_eq!(chained_calls.len(), 1);
}
Run cargo test -p stablecoin_program tests::deposit_collateral_works_when_frozen. Expected: 1 PASS.
Append:
#[test]
fn deposit_collateral_allows_zero_amount() {
let initial: u128 = 500;
let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(initial, 0),
init_vault_account(),
user_holding_account(1_000),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
0,
);
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
assert_eq!(position.collateral_amount, initial);
let expected_transfer = ChainedCall::new(
TOKEN_PROGRAM_ID,
vec![user_holding_account(1_000), init_vault_account()],
&token_core::Instruction::Transfer {
amount_to_transfer: 0,
},
);
assert_eq!(chained_calls, vec![expected_transfer]);
}
Run cargo test -p stablecoin_program tests::deposit_collateral_allows_zero_amount. Expected: 1 PASS.
Append:
#[test]
#[should_panic(expected = "Owner authorization is missing")]
fn deposit_collateral_requires_owner_authorization() {
let mut owner = owner_account();
owner.is_authorized = false;
crate::deposit_collateral::deposit_collateral(
owner,
init_position_account(500, 0),
init_vault_account(),
user_holding_account(1_000),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(expected = "User collateral holding authorization is missing")]
fn deposit_collateral_requires_user_holding_authorization() {
let mut holding = user_holding_account(1_000);
holding.is_authorized = false;
crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(500, 0),
init_vault_account(),
holding,
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(expected = "Position account must be initialized")]
fn deposit_collateral_rejects_uninitialized_position() {
crate::deposit_collateral::deposit_collateral(
owner_account(),
uninit_position_account(),
init_vault_account(),
user_holding_account(1_000),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(expected = "Position is not owned by this stablecoin program")]
fn deposit_collateral_rejects_position_owned_by_other_program() {
let mut position = init_position_account(500, 0);
position.account.program_owner = [9u32; 8];
crate::deposit_collateral::deposit_collateral(
owner_account(),
position,
init_vault_account(),
user_holding_account(1_000),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(expected = "Position account ID does not match expected derivation")]
fn deposit_collateral_rejects_wrong_position_address() {
let mut position = init_position_account(500, 0);
position.account_id = AccountId::new([0xFFu8; 32]);
crate::deposit_collateral::deposit_collateral(
owner_account(),
position,
init_vault_account(),
user_holding_account(1_000),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(expected = "ProtocolParameters account must be initialized")]
fn deposit_collateral_rejects_uninitialized_protocol_parameters() {
let pp = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: protocol_parameters_id(),
};
crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(500, 0),
init_vault_account(),
user_holding_account(1_000),
pp,
STABLECOIN_PROGRAM_ID,
100,
);
}
Run cargo test -p stablecoin_program tests::deposit_collateral_. Expected: 9 PASS so far.
Append:
#[test]
#[should_panic(expected = "Vault account does not match Position.vault_account_id")]
fn deposit_collateral_rejects_wrong_vault() {
let mut vault = init_vault_account();
vault.account_id = AccountId::new([0xEEu8; 32]);
crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(500, 0),
vault,
user_holding_account(1_000),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(
expected = "User collateral holding must be owned by the same Token Program as the vault"
)]
fn deposit_collateral_rejects_holding_with_wrong_token_program() {
let mut holding = user_holding_account(1_000);
holding.account.program_owner = [9u32; 8];
crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(500, 0),
init_vault_account(),
holding,
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(
expected = "User collateral holding does not match ProtocolParameters.collateral_definition_id"
)]
fn deposit_collateral_rejects_holding_for_other_definition() {
let mut holding = user_holding_account(1_000);
holding.account.data = Data::from(&TokenHolding::Fungible {
definition_id: AccountId::new([0x21u8; 32]),
balance: 1_000,
});
crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(500, 0),
init_vault_account(),
holding,
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(expected = "Position collateral_amount overflow")]
fn deposit_collateral_rejects_overflow() {
crate::deposit_collateral::deposit_collateral(
owner_account(),
init_position_account(u128::MAX, 0),
init_vault_account(),
user_holding_account(1_000),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
1,
);
}
Run cargo test -p stablecoin_program tests::deposit_collateral_. Expected: 13 PASS total.
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
make idl
Expected: green; artifacts/stablecoin-idl.json updated.
cargo +nightly fmt --all
git add programs/stablecoin/src/deposit_collateral.rs \
programs/stablecoin/src/lib.rs \
programs/stablecoin/src/tests.rs \
programs/stablecoin/core/src/lib.rs \
programs/stablecoin/methods/guest/src/bin/stablecoin.rs \
artifacts/stablecoin-idl.json
git commit -m "feat(stablecoin): implement deposit_collateral
Adds the spec §10.5 instruction: 5-account input list, single chained
Token::Transfer from the user's authorized collateral holding into the
position's vault, position.collateral_amount incremented.
Allowed when frozen (spec §7 invariant 8): deposits can only improve
collateralization, so the freeze authority does not block them.
Reads ProtocolParameters.collateral_definition_id to validate the user
holding's definition. No collateralization check (deposits strictly
improve the ratio).
13 host-function tests cover happy path (1), frozen-state allowed (1),
zero-amount no-op (1), authorization (2), uninitialized position +
wrong owner + wrong PDA (3), uninitialized ProtocolParameters (1),
wrong vault (1), holding mismatches (2), and overflow (1)."
Notes for reviewer
- The user holding's authorization comes from the public transaction's witness set, same as in the original
open_position. The Token Program's Transfer host function will read is_authorized on the sender side.
- The vault is NOT marked authorized for this transfer because the receiving side of a
Token::Transfer does not require authorization in token_program. (Compare with withdraw_collateral, where the vault IS authorized via a PDA seed because the sender side requires it.)
..position_data spread (struct update syntax) is preferred over re-listing every field. It catches future field additions to Position at compile time — adding a new field forces the implementer to decide whether deposit_collateral should change it.
- The instruction does NOT read or modify
StabilityFeeAccumulator. Deposits don't change normalized_debt_amount, so accrual is irrelevant here. (The position's nominal debt grows over time anyway, but that's a read-side projection — no on-chain write needed.)
- No
ctx.now is required — keeping the guest signature minimal.
Dependencies
Depends on: Plan 1 (#156).
Blocks: #181.
Issue 03 —
deposit_collateralinstructionPlan: Stablecoin Plan 3 — Position Lifecycle
Depends on: Plan 1 (whole plan).
Blocks: 08 (lifecycle integration test).
Goal: Add a new
deposit_collateralinstruction that lets a position owner top up collateral on an existing position. Single chainedToken::Transferfrom the user's authorized collateral holding into the vault, position'scollateral_amountincrements. Allowed when frozen (it can only improve the protocol's collateralization).Spec reference
§10.5 deposit_collateral— the 5-account input list, single chained Token::Transfer, no collateralization check (deposit-only improves it).Why
deposit_collateralis the inverse ofwithdraw_collateral. The Plan 1 scaffold hadopen_positiondeposit the initial collateral, but no way to add more later. Withoutdeposit_collateral, an owner whose position drifts toward the collateralization threshold (because debt accrued or the redemption price moved) has no path to add collateral short of closing the position — which is impossible while debt is outstanding. This unblocks the most common defensive user action.It's allowed when frozen because the spec deliberately keeps "things that improve protocol health" available during emergencies (§7 invariant 8): if the protocol is frozen because something went wrong, owners should still be able to deleverage by adding collateral or repaying debt.
Architecture
A new
programs/stablecoin/src/deposit_collateral.rsmodule with a pure host function. The chained Token::Transfer goes from the user's authorized holding to the vault, no PDA seed required (the sender is user-authorized). The position post-state mutatescollateral_amountonly; everything else unchanged.Note:
protocol_parametersis in the input list per the spec, but this instruction only readscollateral_definition_idfrom it (to validate the user holding'sdefinition_idmatches). It does NOT checkis_frozen— deposits are explicitly allowed when frozen.Files
programs/stablecoin/src/deposit_collateral.rs— the host function.programs/stablecoin/src/lib.rs—pub mod deposit_collateral;.programs/stablecoin/core/src/lib.rs— addInstruction::DepositCollateral { amount: u128 }.programs/stablecoin/methods/guest/src/bin/stablecoin.rs— guest entrypoint.programs/stablecoin/src/tests.rs— happy-path + panic tests.artifacts/stablecoin-idl.jsonviamake idl.Acceptance criteria
cargo test -p stablecoin_program tests::deposit_collateral_passes (9+ tests below).make clippyis green.make idlregenerates a stable IDL.Token::Transfer.open_position's frozen test).position.collateral_amountoverflow panics with"Position collateral_amount overflow".Implementation steps
In
programs/stablecoin/core/src/lib.rs, inside thepub enum Instruction { ... }, afterOpenPosition, add:Run
cargo check -p stablecoin_core. Expected: clean.Create
programs/stablecoin/src/deposit_collateral.rs:Edit
programs/stablecoin/src/lib.rsand add (alphabetical or logical order; matches existing style):Run
cargo check -p stablecoin_program. Expected: clean.In
programs/stablecoin/methods/guest/src/bin/stablecoin.rs, append a new#[instruction]inside the#[lez_program]module (afteropen_position):(No
ctx.nowneeded —deposit_collateraldoesn't reference time.)Run
cargo check -p stablecoin_methods. Expected: clean.In
programs/stablecoin/src/tests.rs, append:Run
cargo test -p stablecoin_program tests::deposit_collateral_adds_to_position_and_emits_transfer. Expected: 1 PASS.Append:
Run
cargo test -p stablecoin_program tests::deposit_collateral_works_when_frozen. Expected: 1 PASS.Append:
Run
cargo test -p stablecoin_program tests::deposit_collateral_allows_zero_amount. Expected: 1 PASS.Append:
Run
cargo test -p stablecoin_program tests::deposit_collateral_. Expected: 9 PASS so far.Append:
Run
cargo test -p stablecoin_program tests::deposit_collateral_. Expected: 13 PASS total.make clippy RISC0_DEV_MODE=1 cargo test -p stablecoin_program make idlExpected: green;
artifacts/stablecoin-idl.jsonupdated.cargo +nightly fmt --all git add programs/stablecoin/src/deposit_collateral.rs \ programs/stablecoin/src/lib.rs \ programs/stablecoin/src/tests.rs \ programs/stablecoin/core/src/lib.rs \ programs/stablecoin/methods/guest/src/bin/stablecoin.rs \ artifacts/stablecoin-idl.json git commit -m "feat(stablecoin): implement deposit_collateral Adds the spec §10.5 instruction: 5-account input list, single chained Token::Transfer from the user's authorized collateral holding into the position's vault, position.collateral_amount incremented. Allowed when frozen (spec §7 invariant 8): deposits can only improve collateralization, so the freeze authority does not block them. Reads ProtocolParameters.collateral_definition_id to validate the user holding's definition. No collateralization check (deposits strictly improve the ratio). 13 host-function tests cover happy path (1), frozen-state allowed (1), zero-amount no-op (1), authorization (2), uninitialized position + wrong owner + wrong PDA (3), uninitialized ProtocolParameters (1), wrong vault (1), holding mismatches (2), and overflow (1)."Notes for reviewer
open_position. The Token Program'sTransferhost function will readis_authorizedon the sender side.Token::Transferdoes not require authorization intoken_program. (Compare withwithdraw_collateral, where the vault IS authorized via a PDA seed because the sender side requires it.)..position_dataspread (struct update syntax) is preferred over re-listing every field. It catches future field additions toPositionat compile time — adding a new field forces the implementer to decide whetherdeposit_collateralshould change it.StabilityFeeAccumulator. Deposits don't changenormalized_debt_amount, so accrual is irrelevant here. (The position's nominal debt grows over time anyway, but that's a read-side projection — no on-chain write needed.)ctx.nowis required — keeping the guest signature minimal.Dependencies
Depends on: Plan 1 (#156).
Blocks: #181.