Skip to content

[Plan 3 · 03] deposit_collateral instruction #176

Description

@gravityblast

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.rspub 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

  • Step 1: Add the Instruction variant

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.

  • Step 2: Skeleton the host function

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])
}
  • Step 3: Register the module

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.

  • Step 4: Wire the guest entrypoint

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.

  • Step 5: Happy-path test

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.

  • Step 6: Frozen-state test (positive — deposit is allowed)

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.

  • Step 7: Zero-amount no-op test

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.

  • Step 8: Authorization + initialization panic tests

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.

  • Step 9: Vault / user-holding mismatch panics + overflow

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.

  • Step 10: Lint + IDL
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
make idl

Expected: green; artifacts/stablecoin-idl.json updated.

  • Step 11: Commit
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.

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Task.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions