Skip to content

[Plan 3 · 08] Position lifecycle integration test #181

Description

@gravityblast

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_programopen_positiondeposit_collateralgenerate_debt → (advance time + accrue_stability_fee) → repay_debtwithdraw_collateralclose_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:

  1. Builds the message (public_transaction::Message::try_new) with the right ProgramId, account list, current nonces, and instruction payload.
  2. Signs it (public_transaction::WitnessSet::for_message with the required private keys).
  3. Submits via state.transition_from_public_transaction(&tx, now, block_number) — using the test-controlled now to advance time.
  4. 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

  • Step 1: Deploy the TWAP oracle alongside the other programs

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" }
  • Step 2: Add lifecycle-specific Ids / Keys helpers

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

  • Step 3: Replace Balances with lifecycle-appropriate amounts
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
    }
}
  • Step 4: Seed the oracle

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),
        }
    }
}
  • Step 5: Build the new lifecycle state-builder
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.

  • Step 6: Write the lifecycle test — Phase 1: initialize
#[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);
}
  • Step 7: Phase 2 — open_position at now = 100

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(),
    );
  • Step 8: Phase 3 — deposit_collateral at now = 200

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);
  • Step 9: Phase 4 — generate_debt at now = 300

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.

  • Step 10: Phase 5 — Mode A or Mode B accrual at now = 1000

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).
  • Step 11: Phase 6 — repay_debt at now = 1100

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);
    }
  • Step 12: Phase 7 — withdraw_collateral at now = 1200

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);
  • Step 13: Phase 8 — close_position at now = 1300

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);
}
  • Step 14: Decide on the old tests

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

  • Step 15: Lint + final sweep
cargo +nightly fmt --all
make clippy
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin

Expected: green; lifecycle test passes.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Task.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions