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:
- 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.
- It writes
opened_at = 0 (the Plan 1 TODO(Plan 3) marker).
- 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.rs — open_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
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.
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).
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.)
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.
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.
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).
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).
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.
make clippy
RISC0_DEV_MODE=1 cargo test --workspace
make idl # regenerates artifacts/stablecoin-idl.json
Expected: green.
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.
Issue 02 —
open_positionrebuildPlan: Stablecoin Plan 3 — Position Lifecycle
Depends on: Plan 1 (whole plan —
ProtocolParameters, migratedPositionstruct, math); Plan 3 issue 01 (collateralization helper — not used here directly, but landing it first keepsprograms/stablecoin/src/lib.rsfrom 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_positionwith the full spec §10.4 version: takes aposition_nonceandinitial_collateral_amount, readsProtocolParametersto look up the collateral definition id and checkis_frozen, populatesopened_atfromctx.now, and emits the same two chained Token calls. NO initial debt parameter — spec §10.4 explicitly omits one.Spec reference
§10.4 open_position— the full account list, output state, chained calls, and panic conditions.§4.4 Position— populated fields.§7 invariant 8—is_frozen = trueblocksopen_position.Why
The Plan 1 scaffold satisfies the type system but skips three things:
collateral_definitionas an explicit argument rather than looking it up viaProtocolParameters.collateral_definition_id. This means the scaffold can't actually enforce the spec's invariant that all positions share the single global collateral definition.opened_at = 0(the Plan 1TODO(Plan 3)marker).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:
The 5-account
Instruction::OpenPositionvariant instablecoin_coreaddsposition_nonceas the second on-the-wire parameter. (Plan 1 issue 05 already changedopen_position'sInstructionarg list to{ collateral_amount, position_nonce }— if so, this issue just reuses that. If Plan 1 hasn't changed it yet, this issue adds theposition_noncefield.)The guest entrypoint adds
protocol_parametersto its account list, takesposition_noncefrom the instruction params, and passesctx.nowthrough.The integration test in
programs/integration_tests/tests/stablecoin.rsneeds to seed a real (initialized)ProtocolParametersaccount so the instruction can readcollateral_definition_idandis_frozen. The easiest path is to callinitialize_programfirst (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
programs/stablecoin/core/src/lib.rs— addposition_noncetoInstruction::OpenPositionif not already present from Plan 1.programs/stablecoin/src/open_position.rs— full rewrite to the §10.4 signature.programs/stablecoin/methods/guest/src/bin/stablecoin.rs—open_positionentrypoint takesprotocol_parameters+position_nonce, passesctx.now.programs/stablecoin/src/tests.rs— fixtures + tests against the new signature.programs/integration_tests/tests/stablecoin.rs— seed aProtocolParametersaccount 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 stablecoinpasses.make clippyis green.make idlregeneratesartifacts/stablecoin-idl.jsonwith the newOpenPosition { position_nonce, initial_collateral_amount }parameter list.Token::InitializeAccount+Token::Transfer)."Protocol is frozen".opened_at == now(passed in by the test).Implementation steps
Instruction::OpenPositionhasposition_nonceOpen
programs/stablecoin/core/src/lib.rsand find theOpenPositionvariant. After Plan 1 issue 05, it should look like:If Plan 1 didn't add this, edit it now. Field order matters for the serde JSON instruction payload —
position_noncefirst,initial_collateral_amountsecond (the spec convention is "PDA-seed args before non-PDA args").Run
cargo check -p stablecoin_core. Expected: clean.Replace the entire contents of
programs/stablecoin/src/open_position.rswith:Run
cargo check -p stablecoin_program. Expected: clean (the existing test file inprograms/stablecoin/src/tests.rswill FAIL to compile — that's expected; Step 7 rewrites the fixtures).Edit
programs/stablecoin/methods/guest/src/bin/stablecoin.rs. Replace the existingopen_position#[instruction]block with:(The guest already imports
nssa_core::account::AccountWithMetadataandspel_framework::context::ProgramContext; no new imports needed.)ProtocolParametersfixture helper to the test fileOpen
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.)(The existing
position_id()helper intests.rsis 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 oldopen_positionsignature — that's fine for now. The new helpers compile.In
programs/stablecoin/src/tests.rs, replace the existingopen_position_claims_pda_and_emits_chained_callstest (and any otheropen_position_*tests below it) with:Run
cargo test -p stablecoin_program tests::open_position_claims_pda_and_emits_two_chained_calls. Expected: 1 PASS.In
programs/stablecoin/src/tests.rs, replace the previousopen_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, andopen_position_rejects_definition_with_wrong_token_programtests with the new-signature versions:Run
cargo test -p stablecoin_program tests::open_position_. Expected: 6 PASS (the happy-path test + 6 new panic tests = 7 — recheck count).Append:
Run
cargo test -p stablecoin_program tests::open_position_. Expected: 12 PASS total (1 happy + 11 panic tests).Open
programs/integration_tests/tests/stablecoin.rs. Add the protocol-parameters account to theIds/Accounts/ state-builder helpers (issue 08 will replace this section entirely; for now we just make CI green).In
Ids, add:In
Accounts, add:In
state_for_stablecoin_tests, after the existingforce_insert_accountlines, add:In the open-then-withdraw test, update the account list passed to
Instruction::OpenPositionto addIds::protocol_parameters()betweenIds::collateral_definition()and the end, and update the instruction payload to:The account list becomes:
Update
Ids::position()to use the same nonce: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 scaffoldrepay_debt, which compiles becausePosition::normalized_debt_amountreplacesdebt_amount. If the repay test fails because the new Position shape broke the fixture, that's expected — fix in issue 06 of this plan.Expected: green.
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
Token::InitializeAccountchained 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'sprogram_ownerand validate the holding it's about to create.protocol_parameterspost-state is plainAccountPostState::new(...), NOT a re-claim. The runtime preserves the originalprogram_owner. No fields are modified.protocol_parameters.account_id == compute_protocol_parameters_pda(...). Reason: the position-side instructions don't need to — Plan 1'sinitialize_programis 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 matcheswithdraw_collateral/repay_debtin this plan.Token::Transferis 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.Dependencies
Depends on: Plan 1 (#156, whole plan); #174.
Blocks: #177, #178, #179, #180, #181.