Issue 08 — Position lifecycle integration test
Plan: Stablecoin Plan 3 — Position Lifecycle
Depends on: All of Plan 3 (issues 01–07); Plan 1 (whole plan, especially initialize_program from issue 08); Plan 2 (accrue_stability_fee from Plan 2 — see "Modes" below).
Blocks: —
Goal: Add a single end-to-end integration test through the zkVM that exercises the full position lifecycle: deploy programs → initialize_program → open_position → deposit_collateral → generate_debt → (advance time + accrue_stability_fee) → repay_debt → withdraw_collateral → close_position. Asserts every interim state.
This is the canonical "does Plan 3 actually work end-to-end" test. Previous host-function tests cover individual instructions; this test catches account-flow integration bugs (PDA wiring, chained-call composition, post-state shape inconsistencies).
Spec reference
Why
Each Plan 3 instruction has thorough host-function unit tests, but those tests use synthetic AccountWithMetadata fixtures — they don't validate that the runtime actually accepts the post-state shapes, processes the chained calls correctly, or maintains the cross-instruction invariants. An integration test through V03State::transition_from_public_transaction catches all three.
Previous individual tests in programs/integration_tests/tests/stablecoin.rs (open-then-withdraw, standalone repay) carried Plan 1's scaffold patterns. With the full lifecycle landing, they're partially superseded — but rather than delete them, this issue adds a new stablecoin_full_lifecycle test that's the authoritative end-to-end coverage.
Architecture
The integration test consumes the SPEL framework primitives in nssa (the same ones the existing AMM / token integration tests use). For each instruction, the test:
- Builds the message (
public_transaction::Message::try_new) with the right ProgramId, account list, current nonces, and instruction payload.
- Signs it (
public_transaction::WitnessSet::for_message with the required private keys).
- Submits via
state.transition_from_public_transaction(&tx, now, block_number) — using the test-controlled now to advance time.
- Asserts the resulting on-chain state matches the spec.
The crucial new element vs. Plan 1's test is the now argument to transition_from_public_transaction. The integration test advances now between calls to exercise stability-fee accrual.
Modes
-
Mode A (preferred): Plan 2's accrue_stability_fee has landed. Between generate_debt and repay_debt, the test calls accrue_stability_fee to roll the global accumulator forward, then asserts the new accumulator > FIXED_POINT_ONE. repay_debt will then exercise the round-DOWN math at a non-trivial accumulator value.
-
Mode B (fallback): Plan 2 hasn't landed. The test skips the accrue_stability_fee call with a // TODO(Plan 2) comment. The accumulator stays at FIXED_POINT_ONE for the entire test, so the round-DOWN math collapses to a no-op (delta == amount). Coverage of the round-DOWN behavior comes from the Plan 3 issue 06 unit tests — Mode B still exercises every other end-to-end behavior.
This issue's commit message should call out which mode was used.
Files
- Modify:
programs/integration_tests/tests/stablecoin.rs — add the new stablecoin_full_lifecycle test. Keep the existing two tests but mark them as superseded with a comment (or delete if they fully duplicate this one — see Step 11).
- Possibly modify:
programs/integration_tests/Cargo.toml — add twap_oracle_methods dep if it's not already there (for the oracle binary deploy).
Acceptance criteria
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- stablecoin_full_lifecycle passes.
make clippy is green.
- The test asserts every interim state per the spec's "Sample scenarios" section.
- At the end of
close_position, the test asserts the Position account is Account::default() and the vault account still exists with balance = 0.
Implementation steps
In programs/integration_tests/tests/stablecoin.rs, update deploy_programs to also deploy the twap oracle program. This is necessary because initialize_program validates the oracle account's program_owner shape (well — only the data shape; but the test wants a real oracle account at a real PDA owned by the twap_oracle program for realism).
fn deploy_programs(state: &mut V03State) {
let token_message =
program_deployment_transaction::Message::new(token_methods::TOKEN_ELF.to_vec());
state
.transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new(
token_message,
))
.expect("token program deployment must succeed");
let oracle_message =
program_deployment_transaction::Message::new(twap_oracle_methods::TWAP_ORACLE_ELF.to_vec());
state
.transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new(
oracle_message,
))
.expect("twap oracle program deployment must succeed");
let stablecoin_message =
program_deployment_transaction::Message::new(stablecoin_methods::STABLECOIN_ELF.to_vec());
state
.transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new(
stablecoin_message,
))
.expect("stablecoin program deployment must succeed");
}
If twap_oracle_methods is not a dependency, add it to programs/integration_tests/Cargo.toml under [dev-dependencies]:
twap_oracle_methods = { path = "../twap_oracle/methods" }
twap_oracle_core = { path = "../twap_oracle/core" }
In Keys, add an admin key:
fn admin() -> PrivateKey {
PrivateKey::try_new([40; 32]).expect("valid private key")
}
In Ids, add:
fn admin() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::admin()))
}
fn protocol_parameters() -> AccountId {
stablecoin_core::compute_protocol_parameters_pda(Self::stablecoin_program())
}
fn stability_fee_accumulator() -> AccountId {
stablecoin_core::compute_stability_fee_accumulator_pda(Self::stablecoin_program())
}
fn redemption_price_state() -> AccountId {
stablecoin_core::compute_redemption_price_state_pda(Self::stablecoin_program())
}
fn stablecoin_master_holding() -> AccountId {
stablecoin_core::compute_stablecoin_master_holding_pda(Self::stablecoin_program())
}
fn freeze_authority() -> AccountId {
AccountId::new([0xFE; 32])
}
fn oracle_program() -> nssa_core::program::ProgramId {
twap_oracle_methods::TWAP_ORACLE_ID
}
fn market_price_oracle() -> AccountId {
// Use a generic oracle PDA. The twap_oracle program lets the deployer pick any
// (base, quote, source) PDA seed; spec only requires the account to decode as
// OraclePriceAccount with the right base/quote.
twap_oracle_core::compute_oracle_pda(
Self::oracle_program(),
Self::stablecoin_definition(),
Self::collateral_definition(),
"twap",
)
}
(If twap_oracle_core::compute_oracle_pda has a different name in your tree, adapt — read programs/twap_oracle/core/src/lib.rs for the exact helper.)
impl Balances {
fn user_holding_init() -> u128 {
10_000_000
}
fn collateral_open() -> u128 {
1_000_000
}
fn collateral_deposit_top_up() -> u128 {
500_000
}
fn debt_generate() -> u128 {
100_000
}
fn debt_repay() -> u128 {
100_000 // full repay (with accumulator = 1.0 in Mode B; over-repay-tolerated in Mode A)
}
fn collateral_withdraw_all() -> u128 {
1_500_000
}
fn initial_redemption_price() -> u128 {
stablecoin_core::math::FIXED_POINT_ONE / 2
}
fn initial_stability_fee_per_millisecond() -> u128 {
// 1.0 + ε, a small per-millisecond multiplier (illustrative magnitude;
// the real value is locked in the §15 tuning pass).
stablecoin_core::math::FIXED_POINT_ONE + 1_000_000_000_000
}
}
Add an oracle_init Accounts helper. The oracle's timestamp must stay within maximum_oracle_price_age_milliseconds of every block-time we use:
impl Accounts {
fn oracle_init(timestamp: u64) -> Account {
Account {
program_owner: Ids::oracle_program(),
balance: 0,
data: Data::from(&twap_oracle_core::OraclePriceAccount {
base_asset: Ids::stablecoin_definition(),
quote_asset: Ids::collateral_definition(),
price: stablecoin_core::math::FIXED_POINT_ONE / 2,
timestamp,
source_id: "twap".to_owned(),
confidence_interval: 0,
}),
nonce: Nonce(0),
}
}
}
fn state_for_lifecycle() -> V03State {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
deploy_programs(&mut state);
state.force_insert_account(Ids::collateral_definition(), Accounts::collateral_definition_init());
state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init());
state.force_insert_account(Ids::market_price_oracle(), Accounts::oracle_init(0));
state
}
Note: NO force_insert_account for Ids::protocol_parameters(), Ids::stability_fee_accumulator(), Ids::redemption_price_state(), Ids::stablecoin_definition(), Ids::stablecoin_master_holding() — initialize_program will create them.
#[test]
fn stablecoin_full_lifecycle() {
let mut state = state_for_lifecycle();
// ---- Phase 1: initialize_program at now = 0 ----
let init = stablecoin_core::Instruction::InitializeProgram {
freeze_authority_account_id: Ids::freeze_authority(),
initial_stability_fee_per_millisecond: Balances::initial_stability_fee_per_millisecond(),
initial_controller_proportional_gain: 0,
initial_controller_integral_gain: 0,
initial_minimum_collateralization_ratio: stablecoin_core::math::FIXED_POINT_ONE * 3 / 2,
minimum_milliseconds_between_rate_updates: 1,
maximum_oracle_price_age_milliseconds: 86_400,
initial_redemption_price: Balances::initial_redemption_price(),
stablecoin_name: "TestDAI".to_owned(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::admin(),
Ids::protocol_parameters(),
Ids::stability_fee_accumulator(),
Ids::redemption_price_state(),
Ids::stablecoin_definition(),
Ids::stablecoin_master_holding(),
Ids::collateral_definition(),
Ids::market_price_oracle(),
],
vec![current_nonce(&state, Ids::admin())],
init,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::admin()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 0, 0)
.expect("initialize_program must succeed");
// Assertions: ProtocolParameters / accumulators are claimed and have spec-correct initial values.
let params = stablecoin_core::ProtocolParameters::try_from(
&state.get_account_by_id(Ids::protocol_parameters()).data,
)
.expect("ProtocolParameters decodes");
assert!(!params.is_frozen);
assert_eq!(params.collateral_definition_id, Ids::collateral_definition());
assert_eq!(params.stablecoin_definition_id, Ids::stablecoin_definition());
let acc = stablecoin_core::StabilityFeeAccumulator::try_from(
&state.get_account_by_id(Ids::stability_fee_accumulator()).data,
)
.expect("StabilityFeeAccumulator decodes");
assert_eq!(acc.accumulated_rate_at_last_accrual, stablecoin_core::math::FIXED_POINT_ONE);
assert_eq!(acc.last_accrued_at, 0);
}
Append inside the test body:
// ---- Phase 2: open_position at now = 100 ----
let open = stablecoin_core::Instruction::OpenPosition {
position_nonce: 0,
initial_collateral_amount: Balances::collateral_open(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::user_holding(),
Ids::collateral_definition(),
Ids::protocol_parameters(),
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_holding()),
],
open,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::owner(), &Keys::user_holding()],
);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 100, 1)
.expect("open_position must succeed");
let position = Position::try_from(&state.get_account_by_id(Ids::position()).data)
.expect("Position decodes");
assert_eq!(position.collateral_amount, Balances::collateral_open());
assert_eq!(position.normalized_debt_amount, 0);
assert_eq!(position.opened_at, 100);
assert_fungible_balance(&state, Ids::vault(), Balances::collateral_open());
assert_fungible_balance(
&state,
Ids::user_holding(),
Balances::user_holding_init() - Balances::collateral_open(),
);
Append:
// ---- Phase 3: deposit_collateral at now = 200 ----
let deposit = stablecoin_core::Instruction::DepositCollateral {
amount: Balances::collateral_deposit_top_up(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::user_holding(),
Ids::protocol_parameters(),
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_holding()),
],
deposit,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::owner(), &Keys::user_holding()],
);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 200, 2)
.expect("deposit_collateral must succeed");
let expected_collateral = Balances::collateral_open() + Balances::collateral_deposit_top_up();
let position = Position::try_from(&state.get_account_by_id(Ids::position()).data).unwrap();
assert_eq!(position.collateral_amount, expected_collateral);
assert_fungible_balance(&state, Ids::vault(), expected_collateral);
Append:
// ---- Phase 4: generate_debt at now = 300 ----
let generate = stablecoin_core::Instruction::GenerateDebt {
amount: Balances::debt_generate(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::stablecoin_definition(),
Ids::user_stablecoin_holding(),
Ids::stability_fee_accumulator(),
Ids::redemption_price_state(),
Ids::market_price_oracle(),
Ids::protocol_parameters(),
],
vec![current_nonce(&state, Ids::owner())],
generate,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 300, 3)
.expect("generate_debt must succeed");
let position = Position::try_from(&state.get_account_by_id(Ids::position()).data).unwrap();
// At now = 300, accumulator has accrued for 300 milliseconds at the stability fee. The exact
// value depends on compound_rate; we just assert it's > 0 and <= debt_generate (since the
// ceil division of amount / accumulator with accumulator >= 1.0 is <= amount).
assert!(position.normalized_debt_amount > 0);
assert!(position.normalized_debt_amount <= Balances::debt_generate());
// User's stablecoin holding now has the minted amount.
let user_stablecoin_balance = match TokenHolding::try_from(
&state.get_account_by_id(Ids::user_stablecoin_holding()).data,
)
.expect("decodes")
{
TokenHolding::Fungible { balance, .. } => balance,
_ => panic!("expected Fungible"),
};
assert_eq!(user_stablecoin_balance, Balances::debt_generate());
// Stablecoin definition total_supply == amount minted.
let stablecoin_def = TokenDefinition::try_from(
&state.get_account_by_id(Ids::stablecoin_definition()).data,
)
.expect("decodes");
match stablecoin_def {
TokenDefinition::Fungible { total_supply, .. } => {
assert_eq!(total_supply, Balances::debt_generate());
}
_ => panic!("expected Fungible"),
}
Test setup note: the user_stablecoin_holding account must exist before generate_debt runs. The Token Program's Mint expects an initialized destination holding. Insert it into the state with force_insert_account in state_for_lifecycle:
state.force_insert_account(
Ids::user_stablecoin_holding(),
Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::stablecoin_definition(),
balance: 0,
}),
nonce: Nonce(0),
},
);
The holding's definition_id is Ids::stablecoin_definition(), which is the PDA — even though initialize_program hasn't run yet at insertion time, the address is deterministic from the program id, so we can reference it.
Mode A (Plan 2 has landed):
// ---- Phase 5a: accrue_stability_fee at now = 1000 (Plan 2) ----
let accrue = stablecoin_core::Instruction::AccrueStabilityFee;
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(), // caller — any authorized account
Ids::stability_fee_accumulator(),
Ids::protocol_parameters(),
],
vec![current_nonce(&state, Ids::owner())],
accrue,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 1000, 4)
.expect("accrue_stability_fee must succeed");
let acc = stablecoin_core::StabilityFeeAccumulator::try_from(
&state.get_account_by_id(Ids::stability_fee_accumulator()).data,
)
.unwrap();
assert_eq!(acc.last_accrued_at, 1000);
assert!(acc.accumulated_rate_at_last_accrual > stablecoin_core::math::FIXED_POINT_ONE);
(Account list, instruction name, and exact Instruction::AccrueStabilityFee shape come from Plan 2 — adapt to whatever names Plan 2 lands.)
Mode B (Plan 2 hasn't landed):
// ---- Phase 5b: TODO(Plan 2) — accrue_stability_fee here ----
// Plan 2 hasn't landed; the accumulator stays at FIXED_POINT_ONE for the rest of the test.
// The round-DOWN math in repay_debt collapses to a no-op (delta == amount).
Append:
// ---- Phase 6: repay_debt at now = 1100 ----
// We must burn enough stablecoin to drive normalized_debt back to 0 so close_position
// succeeds. Read the current normalized_debt and back-derive the amount:
let current_position = Position::try_from(&state.get_account_by_id(Ids::position()).data).unwrap();
let normalized_debt_before_repay = current_position.normalized_debt_amount;
// With Mode A: burning `Balances::debt_generate()` may leave a 1-unit residue (round-DOWN
// dust per spec §6.3); a second tiny repay can clean it up. In Mode B (accumulator = 1.0),
// burning `Balances::debt_generate()` drives it exactly to zero.
//
// We'll structure this as one repay; if there's residue, the test asserts > 0 then runs a
// second cleanup repay.
let repay = stablecoin_core::Instruction::RepayDebt {
amount: Balances::debt_repay(),
};
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::stablecoin_definition(),
Ids::user_stablecoin_holding(),
Ids::stability_fee_accumulator(),
Ids::protocol_parameters(),
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_stablecoin_holding()),
],
repay,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::owner(), &Keys::user_stablecoin_holding()],
);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 1100, 5)
.expect("repay_debt must succeed");
let position = Position::try_from(&state.get_account_by_id(Ids::position()).data).unwrap();
// Either zero (Mode B) or up to 1 unit residue (Mode A round-DOWN dust). We don't assert
// the exact value — the next phase handles the residue case.
assert!(position.normalized_debt_amount <= 1);
If position.normalized_debt_amount > 0 after the first repay (Mode A only), do a second tiny repay. To keep the test deterministic across modes, just emit a Mode-A cleanup repay conditionally:
if position.normalized_debt_amount > 0 {
// Mode A round-DOWN dust: a 1-unit repay clears it.
// Mint or fund the user's stablecoin holding first if needed (the user already has
// enough — they didn't burn the full amount in the first repay round, so balance > 0).
let cleanup_repay = stablecoin_core::Instruction::RepayDebt { amount: 1 };
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::stablecoin_definition(),
Ids::user_stablecoin_holding(),
Ids::stability_fee_accumulator(),
Ids::protocol_parameters(),
],
vec![
current_nonce(&state, Ids::owner()),
current_nonce(&state, Ids::user_stablecoin_holding()),
],
cleanup_repay,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::owner(), &Keys::user_stablecoin_holding()],
);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 1100, 6)
.expect("cleanup repay_debt must succeed");
let position = Position::try_from(&state.get_account_by_id(Ids::position()).data).unwrap();
assert_eq!(position.normalized_debt_amount, 0);
}
Append:
// ---- Phase 7: withdraw_collateral drains the vault at now = 1200 ----
let current_position = Position::try_from(&state.get_account_by_id(Ids::position()).data).unwrap();
let withdraw_amount = current_position.collateral_amount;
let withdraw = stablecoin_core::Instruction::WithdrawCollateral { amount: withdraw_amount };
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::user_holding(),
Ids::stability_fee_accumulator(),
Ids::redemption_price_state(),
Ids::protocol_parameters(),
],
vec![current_nonce(&state, Ids::owner())],
withdraw,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 1200, 7)
.expect("withdraw_collateral must succeed");
let position = Position::try_from(&state.get_account_by_id(Ids::position()).data).unwrap();
assert_eq!(position.collateral_amount, 0);
assert_eq!(position.normalized_debt_amount, 0);
assert_fungible_balance(&state, Ids::vault(), 0);
Append:
// ---- Phase 8: close_position at now = 1300 ----
let close = stablecoin_core::Instruction::ClosePosition;
let message = public_transaction::Message::try_new(
Ids::stablecoin_program(),
vec![
Ids::owner(),
Ids::position(),
Ids::vault(),
Ids::protocol_parameters(),
],
vec![current_nonce(&state, Ids::owner())],
close,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]);
let tx = PublicTransaction::new(message, witness_set);
state
.transition_from_public_transaction(&tx, 1300, 8)
.expect("close_position must succeed");
// Position is cleared back to default — PDA is released.
assert_eq!(
state.get_account_by_id(Ids::position()),
Account::default(),
"Position should be cleared"
);
// Vault is still on-chain with balance = 0 (Token Program has no CloseHolding).
let lingering_vault = state.get_account_by_id(Ids::vault());
assert_ne!(lingering_vault, Account::default(), "Vault should still exist");
assert_fungible_balance(&state, Ids::vault(), 0);
}
The Plan 1 / earlier stablecoin_open_position_then_withdraw_collateral and stablecoin_repay_debt_burns_stablecoins_and_decreases_debt tests are now subsumed by the lifecycle test. Two options:
Keep: mark with a // Superseded by stablecoin_full_lifecycle; kept for minimal coverage in absence of Plan 2. comment and leave them.
Remove: delete them. The full-lifecycle test covers every behavior the old tests asserted, plus more.
Recommendation: remove if Plan 2 has shipped (Mode A); keep with a comment if running in Mode B (the old standalone tests give targeted CI signal for whichever instruction is breaking).
cargo +nightly fmt --all
make clippy
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin
Expected: green; lifecycle test passes.
git add programs/integration_tests/tests/stablecoin.rs \
programs/integration_tests/Cargo.toml
git commit -m "test(stablecoin): full position lifecycle through the zkVM
Adds stablecoin_full_lifecycle integration test: deploys token + twap_oracle +
stablecoin programs, runs initialize_program, then drives a single position
through the spec §16 reference scenario:
open_position -> deposit_collateral -> generate_debt -> [accrue_stability_fee
if Plan 2 landed] -> repay_debt (+ optional 1-unit dust cleanup in Mode A)
-> withdraw_collateral -> close_position
Asserts every interim state: Position fields, vault balance, user holding
balance, stablecoin total_supply, accumulator advance (Mode A only).
At the end of close_position, asserts the Position account is back to
Account::default() (PDA released) and the vault account lingers with
balance = 0 (spec §10.9 + §12).
Mode A / Mode B selection is described in the test's top-of-function
comment. This commit runs in Mode [A|B] — Plan 2 [has|has not] landed
at commit time."
(Replace the bracketed placeholders before committing.)
Notes for reviewer
- The test deliberately uses
state.transition_from_public_transaction(&tx, now, block_number) with manually-chosen now values that advance between phases. This is the only way to exercise the time-dependent code paths (oracle staleness, opened_at, accumulator projection).
- The
block_number argument is incremented per phase but the protocol doesn't read it; it's there for the runtime's bookkeeping.
- The oracle account's
timestamp = 0 from the genesis fixture stays valid for the entire lifecycle because maximum_oracle_price_age_milliseconds = 86_400 and we never advance past now = 1300. In a longer test you'd refresh the oracle between phases (which would require a twap_oracle instruction call — currently twap_oracle/src/lib.rs only has noop for account initialization, so for now we rely on the genesis-seeded value).
- The Mode A "round-DOWN dust" cleanup repay is the test's acknowledgement of the spec §6.3 / §11 dust UX edge case. In Mode B the residue is exactly zero, so the cleanup block is a dead branch — the
if position.normalized_debt_amount > 0 guard handles both modes uniformly without forking the test code.
- The
user_stablecoin_holding is genesis-seeded with an empty fungible holding because Token::Mint (chained from generate_debt) requires the destination to already exist. There's no Token::CreateHolding (or it lives in a different surface); using force_insert_account is the integration-test idiom for "this account is owned by another protocol but I want to pre-stage it."
- If Plan 2 lands AFTER this test goes in (i.e., test commits in Mode B first, then Plan 2 lands), the
// TODO(Plan 2) comment should be the marker for the Plan 2 implementer to upgrade the test to Mode A. Add accrue_stability_fee to the test then.
- The test does NOT exercise
update_redemption_rate. That's Plan 2 territory; the redemption price stays at FIXED_POINT_ONE / 2 (the initial value) throughout. A future expansion of this test can call update_redemption_rate between phases to verify the redemption-price drift integration.
Dependencies
Depends on: all of Plan 3 (#174–#180); Plan 1 (#156, esp. initialize_program #164); Plan 2 (#165, accrue_stability_fee #169).
Blocks: —
Issue 08 — Position lifecycle integration test
Plan: Stablecoin Plan 3 — Position Lifecycle
Depends on: All of Plan 3 (issues 01–07); Plan 1 (whole plan, especially
initialize_programfrom issue 08); Plan 2 (accrue_stability_feefrom Plan 2 — see "Modes" below).Blocks: —
Goal: Add a single end-to-end integration test through the zkVM that exercises the full position lifecycle: deploy programs →
initialize_program→open_position→deposit_collateral→generate_debt→ (advance time +accrue_stability_fee) →repay_debt→withdraw_collateral→close_position. Asserts every interim state.This is the canonical "does Plan 3 actually work end-to-end" test. Previous host-function tests cover individual instructions; this test catches account-flow integration bugs (PDA wiring, chained-call composition, post-state shape inconsistencies).
Spec reference
§3 Account topology— full account list.§7 Cross-instruction invariants— the properties we assert at each step.§10— per-instruction behavior the test verifies.§16 Sample scenarios— the spec's reference lifecycle scenario; this test mirrors it.Why
Each Plan 3 instruction has thorough host-function unit tests, but those tests use synthetic
AccountWithMetadatafixtures — they don't validate that the runtime actually accepts the post-state shapes, processes the chained calls correctly, or maintains the cross-instruction invariants. An integration test throughV03State::transition_from_public_transactioncatches all three.Previous individual tests in
programs/integration_tests/tests/stablecoin.rs(open-then-withdraw, standalone repay) carried Plan 1's scaffold patterns. With the full lifecycle landing, they're partially superseded — but rather than delete them, this issue adds a newstablecoin_full_lifecycletest that's the authoritative end-to-end coverage.Architecture
The integration test consumes the SPEL framework primitives in
nssa(the same ones the existing AMM / token integration tests use). For each instruction, the test:public_transaction::Message::try_new) with the right ProgramId, account list, current nonces, and instruction payload.public_transaction::WitnessSet::for_messagewith the required private keys).state.transition_from_public_transaction(&tx, now, block_number)— using the test-controllednowto advance time.The crucial new element vs. Plan 1's test is the
nowargument totransition_from_public_transaction. The integration test advancesnowbetween calls to exercise stability-fee accrual.Modes
Mode A (preferred): Plan 2's
accrue_stability_feehas landed. Betweengenerate_debtandrepay_debt, the test callsaccrue_stability_feeto roll the global accumulator forward, then asserts the new accumulator >FIXED_POINT_ONE.repay_debtwill then exercise the round-DOWN math at a non-trivial accumulator value.Mode B (fallback): Plan 2 hasn't landed. The test skips the
accrue_stability_feecall with a// TODO(Plan 2)comment. The accumulator stays atFIXED_POINT_ONEfor the entire test, so the round-DOWN math collapses to a no-op (delta == amount). Coverage of the round-DOWN behavior comes from the Plan 3 issue 06 unit tests — Mode B still exercises every other end-to-end behavior.This issue's commit message should call out which mode was used.
Files
programs/integration_tests/tests/stablecoin.rs— add the newstablecoin_full_lifecycletest. Keep the existing two tests but mark them as superseded with a comment (or delete if they fully duplicate this one — see Step 11).programs/integration_tests/Cargo.toml— addtwap_oracle_methodsdep if it's not already there (for the oracle binary deploy).Acceptance criteria
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- stablecoin_full_lifecyclepasses.make clippyis green.close_position, the test asserts the Position account isAccount::default()and the vault account still exists withbalance = 0.Implementation steps
In
programs/integration_tests/tests/stablecoin.rs, updatedeploy_programsto also deploy the twap oracle program. This is necessary becauseinitialize_programvalidates the oracle account's program_owner shape (well — only the data shape; but the test wants a real oracle account at a real PDA owned by the twap_oracle program for realism).If
twap_oracle_methodsis not a dependency, add it toprograms/integration_tests/Cargo.tomlunder[dev-dependencies]:In
Keys, add an admin key:In
Ids, add:(If
twap_oracle_core::compute_oracle_pdahas a different name in your tree, adapt — readprograms/twap_oracle/core/src/lib.rsfor the exact helper.)Balanceswith lifecycle-appropriate amountsAdd an
oracle_initAccounts helper. The oracle's timestamp must stay withinmaximum_oracle_price_age_millisecondsof every block-time we use:Note: NO
force_insert_accountforIds::protocol_parameters(),Ids::stability_fee_accumulator(),Ids::redemption_price_state(),Ids::stablecoin_definition(),Ids::stablecoin_master_holding()—initialize_programwill create them.open_positionat now = 100Append inside the test body:
deposit_collateralat now = 200Append:
generate_debtat now = 300Append:
Mode A (Plan 2 has landed):
(Account list, instruction name, and exact
Instruction::AccrueStabilityFeeshape come from Plan 2 — adapt to whatever names Plan 2 lands.)Mode B (Plan 2 hasn't landed):
repay_debtat now = 1100Append:
If
position.normalized_debt_amount > 0after the first repay (Mode A only), do a second tiny repay. To keep the test deterministic across modes, just emit a Mode-A cleanup repay conditionally:withdraw_collateralat now = 1200Append:
close_positionat now = 1300Append:
The Plan 1 / earlier
stablecoin_open_position_then_withdraw_collateralandstablecoin_repay_debt_burns_stablecoins_and_decreases_debttests are now subsumed by the lifecycle test. Two options:Keep: mark with a
// Superseded by stablecoin_full_lifecycle; kept for minimal coverage in absence of Plan 2.comment and leave them.Remove: delete them. The full-lifecycle test covers every behavior the old tests asserted, plus more.
Recommendation: remove if Plan 2 has shipped (Mode A); keep with a comment if running in Mode B (the old standalone tests give targeted CI signal for whichever instruction is breaking).
cargo +nightly fmt --all make clippy RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoinExpected: green; lifecycle test passes.
git add programs/integration_tests/tests/stablecoin.rs \ programs/integration_tests/Cargo.toml git commit -m "test(stablecoin): full position lifecycle through the zkVM Adds stablecoin_full_lifecycle integration test: deploys token + twap_oracle + stablecoin programs, runs initialize_program, then drives a single position through the spec §16 reference scenario: open_position -> deposit_collateral -> generate_debt -> [accrue_stability_fee if Plan 2 landed] -> repay_debt (+ optional 1-unit dust cleanup in Mode A) -> withdraw_collateral -> close_position Asserts every interim state: Position fields, vault balance, user holding balance, stablecoin total_supply, accumulator advance (Mode A only). At the end of close_position, asserts the Position account is back to Account::default() (PDA released) and the vault account lingers with balance = 0 (spec §10.9 + §12). Mode A / Mode B selection is described in the test's top-of-function comment. This commit runs in Mode [A|B] — Plan 2 [has|has not] landed at commit time."(Replace the bracketed placeholders before committing.)
Notes for reviewer
state.transition_from_public_transaction(&tx, now, block_number)with manually-chosennowvalues that advance between phases. This is the only way to exercise the time-dependent code paths (oracle staleness,opened_at, accumulator projection).block_numberargument is incremented per phase but the protocol doesn't read it; it's there for the runtime's bookkeeping.timestamp = 0from the genesis fixture stays valid for the entire lifecycle becausemaximum_oracle_price_age_milliseconds = 86_400and we never advance pastnow = 1300. In a longer test you'd refresh the oracle between phases (which would require atwap_oracleinstruction call — currentlytwap_oracle/src/lib.rsonly hasnoopfor account initialization, so for now we rely on the genesis-seeded value).if position.normalized_debt_amount > 0guard handles both modes uniformly without forking the test code.user_stablecoin_holdingis genesis-seeded with an empty fungible holding becauseToken::Mint(chained fromgenerate_debt) requires the destination to already exist. There's noToken::CreateHolding(or it lives in a different surface); usingforce_insert_accountis the integration-test idiom for "this account is owned by another protocol but I want to pre-stage it."// TODO(Plan 2)comment should be the marker for the Plan 2 implementer to upgrade the test to Mode A. Addaccrue_stability_feeto the test then.update_redemption_rate. That's Plan 2 territory; the redemption price stays atFIXED_POINT_ONE / 2(the initial value) throughout. A future expansion of this test can callupdate_redemption_ratebetween phases to verify the redemption-price drift integration.Dependencies
Depends on: all of Plan 3 (#174–#180); Plan 1 (#156, esp.
initialize_program#164); Plan 2 (#165,accrue_stability_fee#169).Blocks: —