Skip to content

[Plan 3 · 02] open_position rebuild #175

Description

@gravityblast

Issue 02 — open_position rebuild

Plan: Stablecoin Plan 3 — Position Lifecycle
Depends on: Plan 1 (whole plan — ProtocolParameters, migrated Position struct, math); Plan 3 issue 01 (collateralization helper — not used here directly, but landing it first keeps programs/stablecoin/src/lib.rs from churning).
Blocks: 04 (test fixtures), 05, 06, 07, 08.
Label suggestion: breaking — the host-function signature and the guest entrypoint signature both change. Anything consuming the Plan 1 scaffold needs to be updated together.

Goal: Replace the Plan 1 scaffold open_position with the full spec §10.4 version: takes a position_nonce and initial_collateral_amount, reads ProtocolParameters to look up the collateral definition id and check is_frozen, populates opened_at from ctx.now, and emits the same two chained Token calls. NO initial debt parameter — spec §10.4 explicitly omits one.

Spec reference

Why

The Plan 1 scaffold satisfies the type system but skips three things:

  1. It accepts collateral_definition as an explicit argument rather than looking it up via ProtocolParameters.collateral_definition_id. This means the scaffold can't actually enforce the spec's invariant that all positions share the single global collateral definition.
  2. It writes opened_at = 0 (the Plan 1 TODO(Plan 3) marker).
  3. It never reads is_frozen, so frozen-state blocking isn't implemented.

This issue closes all three. The 6-account input list from spec §10.4 replaces the 5-account scaffold list (adds protocol_parameters).

Architecture

  • The host function moves to the §10.4 signature:

    pub fn open_position(
        owner: AccountWithMetadata,
        position: AccountWithMetadata,
        vault: AccountWithMetadata,
        user_collateral_holding: AccountWithMetadata,
        collateral_definition: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        stablecoin_program_id: ProgramId,
        now: u64,
        position_nonce: u64,
        initial_collateral_amount: u128,
    ) -> (Vec<AccountPostState>, Vec<ChainedCall>);
  • The 5-account Instruction::OpenPosition variant in stablecoin_core adds position_nonce as the second on-the-wire parameter. (Plan 1 issue 05 already changed open_position's Instruction arg list to { collateral_amount, position_nonce } — if so, this issue just reuses that. If Plan 1 hasn't changed it yet, this issue adds the position_nonce field.)

  • The guest entrypoint adds protocol_parameters to its account list, takes position_nonce from the instruction params, and passes ctx.now through.

  • The integration test in programs/integration_tests/tests/stablecoin.rs needs to seed a real (initialized) ProtocolParameters account so the instruction can read collateral_definition_id and is_frozen. The easiest path is to call initialize_program first (Plan 1 issue 08 already wires this end-to-end); the existing test for open+withdraw is rewritten in issue 04 of this plan, so this issue only patches it minimally to keep CI green.

Files

  • Modify: programs/stablecoin/core/src/lib.rs — add position_nonce to Instruction::OpenPosition if not already present from Plan 1.
  • Modify: programs/stablecoin/src/open_position.rs — full rewrite to the §10.4 signature.
  • Modify: programs/stablecoin/methods/guest/src/bin/stablecoin.rsopen_position entrypoint takes protocol_parameters + position_nonce, passes ctx.now.
  • Modify: programs/stablecoin/src/tests.rs — fixtures + tests against the new signature.
  • Modify: programs/integration_tests/tests/stablecoin.rs — seed a ProtocolParameters account in the fixture state; pass it in the open / withdraw / repay messages. (Just enough to compile and pass; the bigger lifecycle test lands in issue 08.)

Acceptance criteria

  • cargo test -p stablecoin_program tests::open_position_ passes (the new test list below — 9+ tests).
  • RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin passes.
  • make clippy is green.
  • make idl regenerates artifacts/stablecoin-idl.json with the new OpenPosition { position_nonce, initial_collateral_amount } parameter list.
  • Happy-path test produces exactly 6 post-states (owner, position [claimed], vault, user_collateral_holding, collateral_definition, protocol_parameters) and exactly 2 chained calls (Token::InitializeAccount + Token::Transfer).
  • Frozen-state test panics with "Protocol is frozen".
  • The Position post-state has opened_at == now (passed in by the test).

Implementation steps

  • Step 1: Confirm Instruction::OpenPosition has position_nonce

Open programs/stablecoin/core/src/lib.rs and find the OpenPosition variant. After Plan 1 issue 05, it should look like:

OpenPosition {
    /// Caller-chosen nonce; together with the owner address forms the PDA.
    position_nonce: u64,
    /// Initial collateral tokens to deposit into the position vault.
    initial_collateral_amount: u128,
},

If Plan 1 didn't add this, edit it now. Field order matters for the serde JSON instruction payload — position_nonce first, initial_collateral_amount second (the spec convention is "PDA-seed args before non-PDA args").

Run cargo check -p stablecoin_core. Expected: clean.

  • Step 2: Rewrite the host function

Replace the entire contents of programs/stablecoin/src/open_position.rs with:

use nssa_core::{
    account::{Account, AccountWithMetadata, Data},
    program::{AccountPostState, ChainedCall, Claim, ProgramId},
};
use stablecoin_core::{
    verify_position_and_get_seed, verify_position_vault_and_get_seed, Position, ProtocolParameters,
};
use token_core::TokenHolding;

/// Open a new collateral-only position for `owner` at `(owner, position_nonce)`.
///
/// Reads `ProtocolParameters` for the single global collateral definition id
/// and the freeze flag, claims the [`Position`] PDA, and emits two chained
/// Token-program calls under the stablecoin's vault PDA authority:
/// 1. `Token::InitializeAccount` materializes the vault token holding.
/// 2. `Token::Transfer` moves `initial_collateral_amount` collateral tokens
///    from the user's holding into the freshly initialized vault.
///
/// `opened_at` is recorded from the runtime clock (`now`) passed in by the
/// guest entry point. The position starts with `normalized_debt_amount = 0`
/// (initial debt is NOT a parameter — spec §10.4).
///
/// # Panics
/// - `owner.is_authorized = false`.
/// - `user_collateral_holding.is_authorized = false`.
/// - `position` or `vault` already initialized.
/// - `protocol_parameters` uninitialized or can't decode.
/// - `protocol_parameters.is_frozen = true`.
/// - `collateral_definition.account_id != protocol_parameters.collateral_definition_id`.
/// - User holding's `definition_id` does not match the collateral definition,
///   or is owned by a different Token Program.
/// - Position or vault PDA derivation mismatch.
#[allow(clippy::too_many_arguments)]
pub fn open_position(
    owner: AccountWithMetadata,
    position: AccountWithMetadata,
    vault: AccountWithMetadata,
    user_collateral_holding: AccountWithMetadata,
    collateral_definition: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
    now: u64,
    position_nonce: u64,
    initial_collateral_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_eq!(
        position.account,
        Account::default(),
        "Position account must be uninitialized"
    );
    assert_eq!(
        vault.account,
        Account::default(),
        "Position vault account must be uninitialized"
    );

    // Decode ProtocolParameters; rejects an uninitialized account because Data::default()
    // doesn't deserialize as a ProtocolParameters.
    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");
    assert!(!params.is_frozen, "Protocol is frozen");

    // Collateral definition: must be the one bound at init.
    assert_eq!(
        collateral_definition.account_id, params.collateral_definition_id,
        "Collateral definition does not match ProtocolParameters.collateral_definition_id"
    );
    assert_ne!(
        collateral_definition.account,
        Account::default(),
        "Collateral definition account must be initialized"
    );

    // User collateral holding shape.
    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, collateral_definition.account_id,
        "User collateral holding does not match the collateral definition"
    );
    let token_program_id = user_collateral_holding.account.program_owner;
    assert_eq!(
        collateral_definition.account.program_owner, token_program_id,
        "Collateral token definition is not owned by the user holding's Token Program"
    );

    // PDA address checks. These return seeds we'll attach to the chained calls / post-state.
    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);

    // Build the new Position record.
    let mut position_post = position.account;
    position_post.data = Data::from(&Position {
        owner_account_id: owner.account_id,
        position_nonce,
        vault_account_id: vault.account_id,
        collateral_amount: initial_collateral_amount,
        normalized_debt_amount: 0,
        opened_at: now,
    });

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

    // Chained calls: same shape as the scaffold — InitializeAccount under the vault PDA seed,
    // then Transfer from the user's authorized holding into the just-initialized vault.
    let mut vault_authorized = vault.clone();
    vault_authorized.is_authorized = true;
    let initialize_call = ChainedCall::new(
        token_program_id,
        vec![collateral_definition.clone(), vault_authorized],
        &token_core::Instruction::InitializeAccount,
    )
    .with_pda_seeds(vec![vault_seed]);

    let post_init_vault = AccountWithMetadata {
        account: Account {
            program_owner: token_program_id,
            balance: 0,
            data: Data::from(&TokenHolding::Fungible {
                definition_id: collateral_definition.account_id,
                balance: 0,
            }),
            nonce: vault.account.nonce,
        },
        is_authorized: false,
        account_id: vault.account_id,
    };
    let transfer_call = ChainedCall::new(
        token_program_id,
        vec![user_collateral_holding, post_init_vault],
        &token_core::Instruction::Transfer {
            amount_to_transfer: initial_collateral_amount,
        },
    );

    (post_states, vec![initialize_call, transfer_call])
}

Run cargo check -p stablecoin_program. Expected: clean (the existing test file in programs/stablecoin/src/tests.rs will FAIL to compile — that's expected; Step 7 rewrites the fixtures).

  • Step 3: Update the guest entrypoint

Edit programs/stablecoin/methods/guest/src/bin/stablecoin.rs. Replace the existing open_position #[instruction] block with:

    /// Open a new collateral-only position for the calling owner.
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails (see
    /// [`stablecoin_program::open_position::open_position`] for the full list).
    #[instruction]
    pub fn open_position(
        ctx: ProgramContext,
        owner: AccountWithMetadata,
        position: AccountWithMetadata,
        vault: AccountWithMetadata,
        user_collateral_holding: AccountWithMetadata,
        collateral_definition: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        position_nonce: u64,
        initial_collateral_amount: u128,
    ) -> SpelResult {
        let (post_states, chained_calls) = stablecoin_program::open_position::open_position(
            owner,
            position,
            vault,
            user_collateral_holding,
            collateral_definition,
            protocol_parameters,
            ctx.self_program_id,
            ctx.now,
            position_nonce,
            initial_collateral_amount,
        );
        Ok(spel_framework::SpelOutput::execute(
            post_states,
            chained_calls,
        ))
    }

(The guest already imports nssa_core::account::AccountWithMetadata and spel_framework::context::ProgramContext; no new imports needed.)

  • Step 4: Add a ProtocolParameters fixture helper to the test file

Open programs/stablecoin/src/tests.rs. Above the #[test] fn open_position_claims_pda_and_emits_chained_calls() test, add helpers that match the new signature. (These additions don't break anything — they're new functions. We'll rewrite the open-position tests next.)

use stablecoin_core::{compute_position_pda_seed, ProtocolParameters};

const NOW: u64 = 1_700_000_000;
const POSITION_NONCE: u64 = 7;

fn position_id() -> AccountId {
    compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), POSITION_NONCE)
}

fn protocol_parameters_id() -> AccountId {
    AccountId::new([0xC0u8; 32])
}

fn freeze_authority_id() -> AccountId {
    AccountId::new([0xFEu8; 32])
}

fn make_protocol_parameters(is_frozen: bool) -> ProtocolParameters {
    ProtocolParameters {
        admin_account_id: AccountId::new([0xA0u8; 32]),
        freeze_authority_account_id: freeze_authority_id(),
        stablecoin_definition_id: stablecoin_definition_id(),
        collateral_definition_id: collateral_definition_id(),
        market_price_oracle_id: AccountId::new([0xB0u8; 32]),
        stability_fee_per_millisecond: stablecoin_core::math::FIXED_POINT_ONE,
        controller_proportional_gain: 0,
        controller_integral_gain: 0,
        minimum_collateralization_ratio: stablecoin_core::math::FIXED_POINT_ONE * 3 / 2,
        minimum_milliseconds_between_rate_updates: 1,
        maximum_oracle_price_age_milliseconds: 86_400,
        is_frozen,
    }
}

fn protocol_parameters_account(is_frozen: bool) -> AccountWithMetadata {
    AccountWithMetadata {
        account: Account {
            program_owner: STABLECOIN_PROGRAM_ID,
            balance: 0,
            data: Data::from(&make_protocol_parameters(is_frozen)),
            nonce: Nonce(0),
        },
        is_authorized: false,
        account_id: protocol_parameters_id(),
    }
}

(The existing position_id() helper in tests.rs is keyed by (owner, collateral_definition_id) — Plan 1 issue 05 already migrated it to (owner, POSITION_NONCE) shape. If you find the old shape still present, replace it as shown.)

Run cargo check -p stablecoin_program --tests. Expected: the existing tests still fail to compile because they call the old open_position signature — that's fine for now. The new helpers compile.

  • Step 5: Rewrite the open_position happy-path test

In programs/stablecoin/src/tests.rs, replace the existing open_position_claims_pda_and_emits_chained_calls test (and any other open_position_* tests below it) with:

#[test]
fn open_position_claims_pda_and_emits_two_chained_calls() {
    let initial_collateral: u128 = 500;
    let (post_states, chained_calls) = crate::open_position::open_position(
        owner_account(),
        uninit_position_account(),
        uninit_vault_account(),
        user_holding_account(1_000),
        collateral_definition_account(),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        POSITION_NONCE,
        initial_collateral,
    );

    assert_eq!(post_states.len(), 6);

    let position_post = &post_states[1];
    assert_eq!(
        position_post.required_claim(),
        Some(Claim::Pda(compute_position_pda_seed(owner_id(), POSITION_NONCE)))
    );
    let position = Position::try_from(&position_post.account().data).expect("valid Position");
    assert_eq!(
        position,
        Position {
            owner_account_id: owner_id(),
            position_nonce: POSITION_NONCE,
            vault_account_id: vault_id(),
            collateral_amount: initial_collateral,
            normalized_debt_amount: 0,
            opened_at: NOW,
        }
    );
    assert_eq!(position_post.account().program_owner, ProgramId::default());

    assert_eq!(chained_calls.len(), 2);
    let mut vault_authorized = uninit_vault_account();
    vault_authorized.is_authorized = true;
    let expected_initialize = ChainedCall::new(
        TOKEN_PROGRAM_ID,
        vec![collateral_definition_account(), vault_authorized],
        &token_core::Instruction::InitializeAccount,
    )
    .with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]);
    assert_eq!(chained_calls[0], expected_initialize);
}

Run cargo test -p stablecoin_program tests::open_position_claims_pda_and_emits_two_chained_calls. Expected: 1 PASS.

  • Step 6: Rewrite the authorization + uninitialized panic tests

In programs/stablecoin/src/tests.rs, replace the previous open_position_requires_owner_authorization, open_position_requires_user_holding_authorization, open_position_rejects_initialized_position, open_position_rejects_initialized_vault, open_position_rejects_wrong_position_address, open_position_rejects_wrong_vault_address, open_position_rejects_mismatched_token_definition, and open_position_rejects_definition_with_wrong_token_program tests with the new-signature versions:

fn invoke_open(
    owner: AccountWithMetadata,
    position: AccountWithMetadata,
    vault: AccountWithMetadata,
    user_holding: AccountWithMetadata,
    collateral_def: AccountWithMetadata,
    pp: AccountWithMetadata,
) -> (Vec<nssa_core::program::AccountPostState>, Vec<ChainedCall>) {
    crate::open_position::open_position(
        owner,
        position,
        vault,
        user_holding,
        collateral_def,
        pp,
        STABLECOIN_PROGRAM_ID,
        NOW,
        POSITION_NONCE,
        500,
    )
}

#[test]
#[should_panic(expected = "Owner authorization is missing")]
fn open_position_requires_owner_authorization() {
    let mut owner = owner_account();
    owner.is_authorized = false;
    let _ = invoke_open(
        owner,
        uninit_position_account(),
        uninit_vault_account(),
        user_holding_account(1_000),
        collateral_definition_account(),
        protocol_parameters_account(false),
    );
}

#[test]
#[should_panic(expected = "User collateral holding authorization is missing")]
fn open_position_requires_user_holding_authorization() {
    let mut holding = user_holding_account(1_000);
    holding.is_authorized = false;
    let _ = invoke_open(
        owner_account(),
        uninit_position_account(),
        uninit_vault_account(),
        holding,
        collateral_definition_account(),
        protocol_parameters_account(false),
    );
}

#[test]
#[should_panic(expected = "Position account must be uninitialized")]
fn open_position_rejects_initialized_position() {
    let position = init_position_account(123, 0);
    let _ = invoke_open(
        owner_account(),
        position,
        uninit_vault_account(),
        user_holding_account(1_000),
        collateral_definition_account(),
        protocol_parameters_account(false),
    );
}

#[test]
#[should_panic(expected = "Position vault account must be uninitialized")]
fn open_position_rejects_initialized_vault() {
    let vault = init_vault_account();
    let _ = invoke_open(
        owner_account(),
        uninit_position_account(),
        vault,
        user_holding_account(1_000),
        collateral_definition_account(),
        protocol_parameters_account(false),
    );
}

#[test]
#[should_panic(expected = "Position account ID does not match expected derivation")]
fn open_position_rejects_wrong_position_address() {
    let bad_position = AccountWithMetadata {
        account: Account::default(),
        is_authorized: false,
        account_id: AccountId::new([0xFFu8; 32]),
    };
    let _ = invoke_open(
        owner_account(),
        bad_position,
        uninit_vault_account(),
        user_holding_account(1_000),
        collateral_definition_account(),
        protocol_parameters_account(false),
    );
}

#[test]
#[should_panic(expected = "Position vault account ID does not match expected derivation")]
fn open_position_rejects_wrong_vault_address() {
    let bad_vault = AccountWithMetadata {
        account: Account::default(),
        is_authorized: false,
        account_id: AccountId::new([0xEEu8; 32]),
    };
    let _ = invoke_open(
        owner_account(),
        uninit_position_account(),
        bad_vault,
        user_holding_account(1_000),
        collateral_definition_account(),
        protocol_parameters_account(false),
    );
}

Run cargo test -p stablecoin_program tests::open_position_. Expected: 6 PASS (the happy-path test + 6 new panic tests = 7 — recheck count).

  • Step 7: Add the new ProtocolParameters-specific tests

Append:

#[test]
#[should_panic(expected = "ProtocolParameters account must be initialized")]
fn open_position_rejects_uninitialized_protocol_parameters() {
    let pp = AccountWithMetadata {
        account: Account::default(),
        is_authorized: false,
        account_id: protocol_parameters_id(),
    };
    let _ = invoke_open(
        owner_account(),
        uninit_position_account(),
        uninit_vault_account(),
        user_holding_account(1_000),
        collateral_definition_account(),
        pp,
    );
}

#[test]
#[should_panic(expected = "Protocol is frozen")]
fn open_position_rejects_when_frozen() {
    let _ = invoke_open(
        owner_account(),
        uninit_position_account(),
        uninit_vault_account(),
        user_holding_account(1_000),
        collateral_definition_account(),
        protocol_parameters_account(true),
    );
}

#[test]
#[should_panic(
    expected = "Collateral definition does not match ProtocolParameters.collateral_definition_id"
)]
fn open_position_rejects_mismatched_collateral_definition() {
    // Build a "rogue" collateral definition account at a different id.
    let rogue = AccountWithMetadata {
        account: Account {
            program_owner: TOKEN_PROGRAM_ID,
            balance: 0,
            data: Data::from(&TokenDefinition::Fungible {
                name: "OTHER".to_owned(),
                total_supply: 1,
                metadata_id: None,
            }),
            nonce: Nonce(0),
        },
        is_authorized: false,
        account_id: AccountId::new([0x99u8; 32]),
    };
    // user_holding still points at the real collateral definition, so the address
    // mismatch trips before the user-holding-shape check.
    let _ = invoke_open(
        owner_account(),
        uninit_position_account(),
        uninit_vault_account(),
        user_holding_account(1_000),
        rogue,
        protocol_parameters_account(false),
    );
}

#[test]
#[should_panic(expected = "User collateral holding does not match the collateral definition")]
fn open_position_rejects_user_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,
    });
    let _ = invoke_open(
        owner_account(),
        uninit_position_account(),
        uninit_vault_account(),
        holding,
        collateral_definition_account(),
        protocol_parameters_account(false),
    );
}

#[test]
#[should_panic(
    expected = "Collateral token definition is not owned by the user holding's Token Program"
)]
fn open_position_rejects_definition_with_wrong_token_program() {
    let mut definition = collateral_definition_account();
    definition.account.program_owner = [9u32; 8];
    let _ = invoke_open(
        owner_account(),
        uninit_position_account(),
        uninit_vault_account(),
        user_holding_account(1_000),
        definition,
        protocol_parameters_account(false),
    );
}

Run cargo test -p stablecoin_program tests::open_position_. Expected: 12 PASS total (1 happy + 11 panic tests).

  • Step 8: Patch the integration test fixture so it still compiles

Open programs/integration_tests/tests/stablecoin.rs. Add the protocol-parameters account to the Ids / Accounts / state-builder helpers (issue 08 will replace this section entirely; for now we just make CI green).

In Ids, add:

    fn protocol_parameters() -> AccountId {
        stablecoin_core::compute_protocol_parameters_pda(Self::stablecoin_program())
    }

In Accounts, add:

    fn protocol_parameters_init() -> Account {
        Account {
            program_owner: Ids::stablecoin_program(),
            balance: 0,
            data: Data::from(&stablecoin_core::ProtocolParameters {
                admin_account_id: AccountId::new([0xA0; 32]),
                freeze_authority_account_id: AccountId::new([0xFE; 32]),
                stablecoin_definition_id: Ids::stablecoin_definition(),
                collateral_definition_id: Ids::collateral_definition(),
                market_price_oracle_id: AccountId::new([0xB0; 32]),
                stability_fee_per_millisecond: stablecoin_core::math::FIXED_POINT_ONE,
                controller_proportional_gain: 0,
                controller_integral_gain: 0,
                minimum_collateralization_ratio: stablecoin_core::math::FIXED_POINT_ONE * 3 / 2,
                minimum_milliseconds_between_rate_updates: 1,
                maximum_oracle_price_age_milliseconds: 86_400,
                is_frozen: false,
            }),
            nonce: Nonce(0),
        }
    }

In state_for_stablecoin_tests, after the existing force_insert_account lines, add:

    state.force_insert_account(Ids::protocol_parameters(), Accounts::protocol_parameters_init());

In the open-then-withdraw test, update the account list passed to Instruction::OpenPosition to add Ids::protocol_parameters() between Ids::collateral_definition() and the end, and update the instruction payload to:

    let open = stablecoin_core::Instruction::OpenPosition {
        position_nonce: 0,
        initial_collateral_amount: Balances::collateral_deposit(),
    };

The account list becomes:

        vec![
            Ids::owner(),
            Ids::position(),
            Ids::vault(),
            Ids::user_holding(),
            Ids::collateral_definition(),
            Ids::protocol_parameters(),
        ],

Update Ids::position() to use the same nonce:

    fn position() -> AccountId {
        compute_position_pda(Self::stablecoin_program(), Self::owner(), 0)
    }

Run RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin. Expected: the open+withdraw test passes; the repay test still relies on Plan 1's scaffold repay_debt, which compiles because Position::normalized_debt_amount replaces debt_amount. If the repay test fails because the new Position shape broke the fixture, that's expected — fix in issue 06 of this plan.

  • Step 9: Lint + sweep
make clippy
RISC0_DEV_MODE=1 cargo test --workspace
make idl   # regenerates artifacts/stablecoin-idl.json

Expected: green.

  • Step 10: Commit
cargo +nightly fmt --all
git add programs/stablecoin/src/open_position.rs \
        programs/stablecoin/src/tests.rs \
        programs/stablecoin/core/src/lib.rs \
        programs/stablecoin/methods/guest/src/bin/stablecoin.rs \
        programs/integration_tests/tests/stablecoin.rs \
        artifacts/stablecoin-idl.json
git commit -m "feat(stablecoin): rebuild open_position with full fee-aware flow

Adopts the spec §10.4 signature: takes position_nonce + initial_collateral_amount,
reads ProtocolParameters for the global collateral definition id and the freeze
flag, populates Position.opened_at from ctx.now, and emits the same two chained
Token calls (InitializeAccount, Transfer) as the Plan 1 scaffold.

The instruction now panics with 'Protocol is frozen' when ProtocolParameters.is_frozen
is true, and asserts that the passed collateral_definition matches
ProtocolParameters.collateral_definition_id (the spec's 'single collateral' invariant).

Initial debt is intentionally NOT a parameter — spec §10.4 omits it.
Borrowing now happens through generate_debt (#TBD in Plan 3).

12 host-function tests cover happy path (1), authorization (2), uninitialized
position/vault (2), PDA mismatch (2), uninitialized ProtocolParameters (1),
frozen-state rejection (1), collateral-definition mismatch (1), user-holding
mismatch (1), and Token-Program-owner mismatch (1)."

Notes for reviewer

  • The Token::InitializeAccount chained call's first input is still the collateral definition — it's not a per-position concern, but the Token program requires the definition account to be present in the chained call's account list so it can read the definition's program_owner and validate the holding it's about to create.
  • The protocol_parameters post-state is plain AccountPostState::new(...), NOT a re-claim. The runtime preserves the original program_owner. No fields are modified.
  • We deliberately don't validate protocol_parameters.account_id == compute_protocol_parameters_pda(...). Reason: the position-side instructions don't need to — Plan 1's initialize_program is the only place that creates the global, and the PDA address is the only address the runtime will accept at chained-Token-call time anyway. Adding the assertion here would be defensive paranoia for negligible safety gain. Pattern matches withdraw_collateral / repay_debt in this plan.
  • Token::Transfer is NOT given PDA seeds because the user holding is authorized by the user (signed in the public transaction), not by a PDA. The Plan 1 scaffold already does this correctly; we preserve the pattern.
  • The integration-test fixture patch is intentionally minimal — issue 08 of this plan replaces it with the full lifecycle scenario. We only need the existing two tests to keep passing CI.

Dependencies

Depends on: Plan 1 (#156, whole plan); #174.

Blocks: #177, #178, #179, #180, #181.

Metadata

Metadata

Assignees

No one assigned

    Labels

    breakingBreaking change to on-chain shape or signatureskind/instructionInstruction / host function implementationprogram: stablecoinstablecoin-plan-3Stablecoin Plan 3 — Position Lifecycle

    Type

    Fields

    No fields configured for Task.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions