diff --git a/stablecoin/docs/README.md b/stablecoin/docs/README.md
new file mode 100644
index 0000000..3269988
--- /dev/null
+++ b/stablecoin/docs/README.md
@@ -0,0 +1,1269 @@
+# Stablecoin Program — RFP-013 design
+
+**RFP:** [RFP-013 Reflexive Stablecoin Protocol](https://github.com/logos-co/rfp/blob/master/RFPs/RFP-013-reflexive-stablecoin-protocol.md)
+**Date:** 2026-06-03
+**Status:** Draft — design under review
+
+## Table of contents
+
+1. [Overview](#1-overview)
+2. [Architectural decisions](#2-architectural-decisions)
+3. [Account topology](#3-account-topology)
+ - [How it all works](#how-it-all-works)
+ - [3.1 Program-owned global singletons](#31-program-owned-global-singletons-created-by-initialize_program)
+ - [3.2 Per-position accounts](#32-per-position-accounts-one-set-per-open-position)
+ - [3.3 External accounts](#33-external-accounts-read-only-bound-or-configured)
+4. [Data structures](#4-data-structures)
+ - [4.1 `ProtocolParameters`](#41-protocolparameters)
+ - [4.2 `StabilityFeeAccumulator`](#42-stabilityfeeaccumulator)
+ - [4.3 `RedemptionPriceState`](#43-redemptionpricestate)
+ - [4.4 `Position`](#44-position)
+ - [4.5 External account shapes](#45-external-account-shapes-read-only-reused)
+5. [Constants and conventions](#5-constants-and-conventions)
+ - [5.1 Fixed point](#51-fixed-point)
+ - [5.2 `compound_rate`](#52-compound_rate)
+ - [5.3 Current value projections](#53-current-value-projections-read-on-the-hot-path)
+6. [Math](#6-math)
+ - [6.1 Nominal debt](#61-nominal-debt)
+ - [6.2 Collateralization invariant](#62-collateralization-invariant)
+ - [6.3 Normalized-debt deltas with directional rounding](#63-normalized-debt-deltas-with-directional-rounding)
+ - [6.4 PI controller](#64-pi-controller-update_redemption_rate)
+7. [Cross-instruction invariants](#7-cross-instruction-invariants)
+8. [Bound choices](#8-bound-choices)
+9. [Instruction set](#9-instruction-set)
+ - [9.1 Bootstrap](#91-bootstrap)
+ - [9.2 Permissionless pokes](#92-permissionless-pokes)
+ - [9.3 Position lifecycle](#93-position-lifecycle)
+ - [9.4 Admin parameter updates](#94-admin-parameter-updates)
+ - [9.5 Emergency](#95-emergency)
+10. [Per-instruction details](#10-per-instruction-details)
+ - [10.1 `initialize_program`](#101-initialize_program)
+ - [10.2 `accrue_stability_fee`](#102-accrue_stability_fee)
+ - [10.3 `update_redemption_rate`](#103-update_redemption_rate)
+ - [10.4 `open_position`](#104-open_position)
+ - [10.5 `deposit_collateral`](#105-deposit_collateral)
+ - [10.6 `withdraw_collateral`](#106-withdraw_collateral)
+ - [10.7 `generate_debt`](#107-generate_debt)
+ - [10.8 `repay_debt`](#108-repay_debt)
+ - [10.9 `close_position`](#109-close_position)
+ - [10.10–10.16 Admin parameter updates](#1010-1016-admin-parameter-updates)
+ - [10.17–10.18 `freeze` / `unfreeze`](#1017-1018-freeze--unfreeze)
+11. [Edge cases](#11-edge-cases)
+12. [Out of scope](#12-out-of-scope-per-rfp)
+13. [Forward integration](#13-forward-integration)
+14. [Open follow-ups](#14-open-follow-ups-not-blocking-this-design)
+15. [Implementation plan handoff](#15-implementation-plan-handoff)
+16. [Sample scenarios](#16-sample-scenarios)
+ - [16.1 Alice's full lifecycle](#161-alices-full-lifecycle)
+ - [16.2 Emergency freeze](#162-emergency-freeze)
+
+---
+
+## 1. Overview
+
+RFP-013 asks for a non-pegged, reflexive stablecoin on LEZ modelled on RAI / Reflexer. The protocol mints stablecoin against collateralized debt positions ("SAFEs" in RAI's vocabulary), tracks debt via a normalized-debt + accumulator pattern so a single global update applies interest to every position without per-position writes, and continuously drifts a redemption price via a PI feedback controller fed by the deviation between the stablecoin's market price and the protocol's redemption price.
+
+This document is the design for the **on-chain program**. The CLI, mini-app, and SDK (RFP-013's other deliverables) get their own specs once this is locked.
+
+## 2. Architectural decisions
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Spec scope | Full on-chain program end-state | Every instruction, account type, controller math, oracle interface, admin/freeze hooks. CLI/mini-app/SDK get separate specs. |
+| Instance model | Single deployment = one stablecoin + one collateral | Matches RFP's RAI framing and singular phrasing. Adding a second collateral means deploying the program again as a separate instance with its own distinct stablecoin. |
+| Stablecoin ownership | Program-owned PDA, created by `initialize_program` | Mint/Burn chained calls authorize via the stablecoin program PDA seed. Single source of truth. |
+| Position addressing | PDA seed = `(owner_account_id, position_nonce)` | Multiple positions per owner. Matches RAI / DAI / Liquity. |
+| Price model | Single oracle quoting stablecoin in collateral units; redemption price in collateral-per-stablecoin | One oracle read on the hot path. Closed-loop unit system; no external reference asset. Single point of failure mitigated by staleness gate + freeze authority. |
+| Fee accrual | Pure RAI virtual accrual — no surplus mint | Fees grow only as `accumulated_rate` multiplier. Nominal debt eventually exceeds supply; users acquire stablecoin from market to repay (the controller drives that demand). |
+| Rate plumbing | Lazy globals + permissionless pokes | Position ops read but don't write globals → no contention. Concurrent permissionless `accrue_stability_fee` and `update_redemption_rate` don't conflict. |
+| Authority model | Inline `admin_account_id` and `freeze_authority_account_id` in `ProtocolParameters` | Adapts trivially to RFP-001 / RFP-002 when they land. |
+| Lifecycle granularity | Granular per-op + `close_position` | Six position-facing instructions, each with a single state transition. |
+| Oracle producer | Decoupled — struct only (`OraclePriceAccount`), not program | Producer is configurable via `set_market_price_oracle`; we don't pin `program_owner`. Multi-oracle redundancy (RFP soft requirement) achievable later by pointing at an aggregator that itself produces an `OraclePriceAccount`. |
+
+## 3. Account topology
+
+```mermaid
+flowchart TB
+ subgraph SP["Stablecoin Program"]
+ direction LR
+ subgraph SPG["Global PDAs (one each per deployment)"]
+ PP[ProtocolParameters]
+ SFA[StabilityFeeAccumulator]
+ RPS[RedemptionPriceState]
+ end
+ subgraph SPT["PDAs derived here, owned by Token Program"]
+ SD[StablecoinDefinition]
+ SMH[StablecoinMasterHolding
artifact, balance always 0]
+ end
+ subgraph SPP["Per-position PDAs (one set per (owner, nonce))"]
+ Pos[Position]
+ Vault[PositionVault
owned by Token Program]
+ end
+ end
+
+ subgraph Ext["External read-only"]
+ Oracle[OraclePriceAccount
any producer]
+ CollDef[Collateral TokenDefinition
bound at init, immutable]
+ UserHolds[User TokenHoldings
collateral + stablecoin]
+ end
+
+ PP -. id ref .-> SD
+ PP -. id ref .-> CollDef
+ PP -. id ref .-> Oracle
+ Pos -. id ref .-> Vault
+ Vault -. holds .-> CollDef
+```
+
+### How it all works
+
+A walkthrough of who calls what and what changes when. Detailed mechanics are in §10; this is the overall flow.
+
+**Day 0 — setup.** The deployer publishes the program binary, then calls `initialize_program` (§10.1). That one call creates all five program-owned accounts: the stablecoin `TokenDefinition` (via a chained `Token::NewFungibleDefinition`), its required paired master holding (empty), the `ProtocolParameters` account, and the two state-tracking accounts (`StabilityFeeAccumulator` and `RedemptionPriceState`). The same call sets which collateral and oracle the protocol uses, picks the admin and freeze-authority accounts, and stores the initial fee rate, controller gains, safety ratio, and timing parameters. Once it returns, the protocol exists and users can start interacting.
+
+**Running the protocol — keepers advance the globals.** Two instructions any account can call keep the protocol's globals current:
+
+- `accrue_stability_fee` (§10.2) advances the global fee multiplier. Nothing moves; the multiplier grows so every open position accrues interest at once.
+- `update_redemption_rate` (§10.3) reads the market-price oracle, runs the PI controller (§6.4), and updates the redemption price and its drift rate. The redemption price then keeps drifting toward the market price until the next call.
+
+These two are usually run by keeper bots that batch them into one transaction. They cost gas but earn no reward — anyone with money in the protocol wants the globals fresh before they act.
+
+**A user opens a position and borrows.** A user picks a `position_nonce` (any integer — 0, 7, whatever) and calls `open_position` (§10.4) with some collateral. Two accounts get created: their `Position` (at `hash(owner, nonce)`, owned by the stablecoin program, starting with zero debt) and a `PositionVault` (at `hash(position)`, owned by the Token Program) that holds the collateral tokens. They can then call `generate_debt` (§10.7) to mint stablecoin into their own holding. The mint increases the position's normalized debt; the collateralization check prevents over-borrowing; the call fails if the oracle is stale or the protocol is frozen.
+
+**Time passes; debt grows; users adjust.** As keepers advance the fee multiplier, every position's debt grows even though no per-position write happens (the RAI accumulator approach, §6.1). Users can:
+
+- `deposit_collateral` (§10.5) to add more collateral and improve the ratio.
+- `withdraw_collateral` (§10.6) to take some back — only allowed if the position still meets the safety ratio afterwards.
+- `repay_debt` (§10.8) to burn stablecoin and lower the debt. To pay off accrued interest, users may need to buy extra stablecoin on the market.
+
+**Closing out.** When a position's debt and collateral are both zero, `close_position` (§10.9) clears the `Position` account so the user can later open a new position with the same nonce. (The vault account stays — see §14.)
+
+**Admin tunes parameters.** The admin can change the stability fee (which auto-applies any pending interest first so the new rate doesn't apply retroactively), tighten or loosen the collateralization ratio, change the controller gains, point at a different oracle, adjust the timing intervals, or rotate the admin / freeze-authority handles. None of these touch any position directly. The admin CANNOT touch a position, mint or burn stablecoin, reset the redemption price, or change the stablecoin / collateral definitions — those are fixed at setup.
+
+**Emergency.** If something goes wrong (a broken oracle, an exploit in progress), the freeze authority calls `freeze` (§10.17). That sets `is_frozen = true` in `ProtocolParameters`. After that, operations that increase risk (`open_position`, `generate_debt`, `withdraw_collateral`) fail; operations that reduce risk (`deposit_collateral`, `repay_debt`, `close_position`), keeper updates, and admin changes still work so users can pay down debt and operators can fix the problem. The admin may point at a clean oracle via `set_market_price_oracle`, and the freeze authority calls `unfreeze` to resume normal operation.
+
+### 3.1 Program-owned global singletons (created by `initialize_program`)
+
+Five single-instance accounts hold all the globally-shared state. Each is a PDA derived from the stablecoin program id and a constant seed string, so addresses are deterministic per deployment.
+
+**`ProtocolParameters`** — PDA seed `"PROTOCOL_PARAMETERS"`, owned by the stablecoin program.
+
+*Holds:* admin handle, freeze-authority handle, stability fee, controller gains, minimum collateralization ratio, polling-interval parameters, frozen flag, oracle id, and the bound stablecoin / collateral definition ids.
+
+*Why:* single source of truth for protocol configuration. Kept separate from the dynamic state (`StabilityFeeAccumulator`, `RedemptionPriceState`) so that admin parameter changes don't contend with permissionless pokes — different writers touch different accounts.
+
+**`StabilityFeeAccumulator`** — PDA seed `"STABILITY_FEE_ACCUMULATOR"`, owned by the stablecoin program.
+
+*Holds:* the compounded stability-fee rate at the last accrual, plus the wall-clock timestamp of that accrual.
+
+*Why:* implements the RAI accumulator trick (§6.1) — instead of writing interest into every position on every accrual, we maintain one global multiplier. Each position's nominal debt is then `normalized_debt × accumulator`, computed lazily at read time. Read by every debt-touching op; written only by `accrue_stability_fee` and the auto-accrue path in `set_stability_fee_per_second`.
+
+**`RedemptionPriceState`** — PDA seed `"REDEMPTION_PRICE_STATE"`, owned by the stablecoin program.
+
+*Holds:* the redemption price anchor (at the last update), the per-second drift rate, the PI controller's persisted integral term, and the wall-clock timestamp of the last update.
+
+*Why:* the redemption price is the protocol's target value (in collateral-per-stablecoin); the controller continuously drifts it based on the deviation between market price and target. Storing as `(anchor, rate, last_updated_at)` lets the current value be projected on the fly without writing every block — only `update_redemption_rate` re-anchors it.
+
+**`StablecoinDefinition`** — PDA seed `"STABLECOIN_DEFINITION"`, owned by the **Token Program** (PDA-derived under the stablecoin program).
+
+*Holds:* the stablecoin's `TokenDefinition::Fungible` — name, current `total_supply`, no metadata.
+
+*Why:* the stablecoin is a normal fungible token on LEZ — it composes with wallets, AMMs, ATAs, anything else that understands the Token Program. Putting the definition at a stablecoin-program-derived PDA means the **stablecoin program's PDA seed authorizes every chained `Token::Mint` / `Token::Burn`** issued from `generate_debt` / `repay_debt`. No off-band coordination needed; the program is structurally the only mintable authority.
+
+**`StablecoinMasterHolding`** — PDA seed `"STABLECOIN_MASTER_HOLDING"`, owned by the **Token Program** (PDA-derived under the stablecoin program).
+
+*Holds:* a `TokenHolding::Fungible` for the stablecoin with `balance = 0` permanently.
+
+*Why this exists at all:* a Token-Program-API artifact. `Token::NewFungibleDefinition` always creates a definition AND a paired holding in the same call, and mints `total_supply` units into that holding. There's no variant that creates a definition alone. We pass `total_supply = 0` — our stablecoin starts with **zero** supply because every coin in existence must come from a user calling `generate_debt` against real collateral (a non-zero initial supply would be free, unbacked money). The master holding is therefore born empty and never touched again. Modeling it as a deterministic stablecoin-program PDA contains the artifact at a known, addressable location instead of leaking it into someone's user account.
+
+A future Token Program extension (`Token::NewFungibleDefinitionWithoutHolding`, tracked in §14 follow-ups) would let `initialize_program` drop this account entirely.
+
+**Why split the globals?** Each one has exactly one writer family:
+
+- `ProtocolParameters` ← `initialize_program`, admin `set_*`, `freeze` / `unfreeze`.
+- `StabilityFeeAccumulator` ← `initialize_program`, `accrue_stability_fee`, `set_stability_fee_per_second` (auto-accrues inline).
+- `RedemptionPriceState` ← `initialize_program`, `update_redemption_rate`.
+- `StablecoinDefinition` + `StablecoinMasterHolding` ← `initialize_program` via chained `Token::NewFungibleDefinition`; afterwards the definition's `total_supply` is incremented / decremented by `Token::Mint` / `Token::Burn` from `generate_debt` / `repay_debt`. The master holding is never touched again.
+
+That strict writer separation means a keeper calling `accrue_stability_fee`, another keeper calling `update_redemption_rate`, and a user calling `withdraw_collateral` in the same block don't contend on any account.
+
+### 3.2 Per-position accounts (one set per open position)
+
+Created on demand by `open_position` and cleared by `close_position`. Both accounts' addresses are deterministic from the owner and a nonce, so clients can discover positions without an off-chain index.
+
+**`Position`** — PDA seed `hash(owner_account_id, position_nonce)`, owned by the stablecoin program.
+
+*Holds:* owner id, position nonce, the vault PDA address, current collateral amount, normalized debt amount, and the opened-at timestamp.
+
+*Why:* the canonical state for one CDP. Owner authorization is checked against `owner_account_id` on every op. Storing the vault id explicitly is redundant (it's derivable) but saves the derivation cost on every read. The whole struct is owned by the stablecoin program — that's how we restrict who can write to it (only this program's instructions).
+
+**`PositionVault`** — PDA seed `hash(position_id)`, owned by the **Token Program** (PDA-derived under the stablecoin program).
+
+*Holds:* a `TokenHolding::Fungible` for the collateral token, with `balance` mirroring `Position.collateral_amount` after every op.
+
+*Why:* the actual custody account for the collateral. Each open position has its own vault so that `Token::Transfer`s in (deposit / open) and out (withdraw / future liquidation) target a single isolated holding. PDA-derived under the stablecoin program means we authorize transfers OUT of the vault via the vault's PDA seed in chained `Token::Transfer` calls — no per-position private key, the program's derivation IS the authorization.
+
+### 3.3 External accounts (read-only, bound or configured)
+
+These already exist on chain before any stablecoin interaction; the program just references them.
+
+**`OraclePriceAccount`** (market price) — referenced via `ProtocolParameters.market_price_oracle_id`. Owned by whichever oracle program produced it (typically `twap_oracle`, but any producer that emits this struct shape is acceptable).
+
+*Holds:* the standard `OraclePriceAccount` shape from `twap_oracle_core` — base/quote asset ids, the price, an observation timestamp, source id, and confidence interval.
+
+*Why:* the protocol's only source of market-side truth — what is one stablecoin actually worth in collateral, right now. Read by `update_redemption_rate` (the controller's feedback signal) and as a staleness gate on `generate_debt` (RFP R3). The producer is rotated by the admin via `set_market_price_oracle`; we don't pin its `program_owner`, so future multi-oracle aggregators slot in unchanged.
+
+**Collateral `TokenDefinition`** — referenced via `ProtocolParameters.collateral_definition_id` (set at init, IMMUTABLE). Owned by the Token Program.
+
+*Holds:* the collateral asset's `TokenDefinition::Fungible` (name, total supply).
+
+*Why:* defines the only collateral the protocol accepts. Read by `open_position` (to set up the vault as a holding for this definition) and by every op that validates a user's collateral holding's `definition_id`. Bound at init because changing it would orphan every existing vault (still custodying the old collateral) while users believed positions were backed by the new one — instead, deploy a fresh program instance with a different collateral.
+
+**User `TokenHolding`s** — passed per call by the caller. Owned by the Token Program.
+
+*Holds:* `TokenHolding::Fungible` either of the collateral (used as source for `open_position` / `deposit_collateral`, destination for `withdraw_collateral`) or of the stablecoin (destination for `generate_debt`, source for `repay_debt`).
+
+*Why:* the user's actual money. The program never owns or moves these directly — it always goes through chained `Token::Transfer`/`Mint`/`Burn`, authorized by the user's signature on the outer transaction (for source holdings) or by a stablecoin-program PDA seed (for vault sources / definition mints).
+
+## 4. Data structures
+
+Constants and conventions appear first (§ 5); these types reference them.
+
+### 4.1 `ProtocolParameters`
+
+```rust
+#[account_type]
+pub struct ProtocolParameters {
+ /// Authority required for every parameter-update and admin-rotation instruction.
+ pub admin_account_id: AccountId,
+ /// Authority required for `freeze` / `unfreeze`.
+ pub freeze_authority_account_id: AccountId,
+ /// The stablecoin's `TokenDefinition` PDA. Set at init; IMMUTABLE — changing it would
+ /// break supply accounting against the existing on-chain stablecoin float.
+ pub stablecoin_definition_id: AccountId,
+ /// The single accepted collateral's `TokenDefinition`. Bound at init; IMMUTABLE —
+ /// changing it would orphan every position vault.
+ pub collateral_definition_id: AccountId,
+ /// `OraclePriceAccount` producing stablecoin-in-collateral market price.
+ /// Updatable by admin via `set_market_price_oracle` (oracle rotation).
+ pub market_price_oracle_id: AccountId,
+ /// Per-second multiplicative stability fee. Stored as `(1 + r_per_second) * FIXED_POINT_ONE`.
+ /// Updatable by admin via `set_stability_fee_per_second` (auto-accrues first).
+ pub stability_fee_per_second: u128,
+ /// PI controller Kp. Signed.
+ pub controller_proportional_gain: i128,
+ /// PI controller Ki. Signed.
+ pub controller_integral_gain: i128,
+ /// Minimum collateralization ratio in fixed-point. e.g. `1.5 * FIXED_POINT_ONE` = 150%.
+ pub minimum_collateralization_ratio: u128,
+ /// Min seconds between successful `accrue_stability_fee` calls (spam guard).
+ pub minimum_seconds_between_fee_accruals: u64,
+ /// Min seconds between successful `update_redemption_rate` calls (RFP F2).
+ pub minimum_seconds_between_rate_updates: u64,
+ /// Reject oracle observations older than this (RFP R3 staleness gate).
+ pub maximum_oracle_price_age_seconds: u64,
+ /// `true` blocks `open_position`, `generate_debt`, `withdraw_collateral`.
+ /// `deposit_collateral` / `repay_debt` / `close_position` / pokes / admin / freeze ops
+ /// remain available so users can deleverage out and operators can re-tune.
+ pub is_frozen: bool,
+}
+```
+
+### 4.2 `StabilityFeeAccumulator`
+
+```rust
+#[account_type]
+pub struct StabilityFeeAccumulator {
+ /// Accumulator at `last_accrued_at`. Initialized to `FIXED_POINT_ONE`.
+ /// Updated on accrual via `anchor * compound_rate(stability_fee_per_second, Δt)`.
+ pub accumulated_rate_at_last_accrual: u128,
+ /// Unix seconds of the last accrue. Current accumulator on the read side =
+ /// `anchor * compound_rate(rate, now - last_accrued_at) / FIXED_POINT_ONE`.
+ pub last_accrued_at: u64,
+}
+```
+
+### 4.3 `RedemptionPriceState`
+
+```rust
+#[account_type]
+pub struct RedemptionPriceState {
+ /// Redemption price at `last_updated_at`, in collateral per stablecoin, fixed-point.
+ pub redemption_price_at_last_update: u128,
+ /// Per-second drift multiplier, stored as `(1 + r) * FIXED_POINT_ONE`.
+ /// Below `FIXED_POINT_ONE` = decay. Output of the PI controller.
+ pub redemption_rate_per_second: u128,
+ /// Persisted integral state of the PI controller. Clamped on every update for
+ /// anti-windup (§ 6.4).
+ pub controller_integral_term: i128,
+ /// Unix seconds of the last update. Read-side current price =
+ /// `anchor * compound_rate(rate, now - last_updated_at) / FIXED_POINT_ONE`.
+ pub last_updated_at: u64,
+}
+```
+
+### 4.4 `Position`
+
+```rust
+#[account_type]
+pub struct Position {
+ /// Owner of the position. Required to be `is_authorized` for every position op.
+ /// Also stored for client discovery (PDA seed isn't reversible).
+ pub owner_account_id: AccountId,
+ /// User-chosen on `open_position`. Together with owner forms the PDA seed.
+ pub position_nonce: u64,
+ /// Collateral vault PDA. Stored explicitly for op-time efficiency.
+ pub vault_account_id: AccountId,
+ /// Collateral atomic units. INVARIANT: equals `vault_holding.balance` after every op.
+ pub collateral_amount: u128,
+ /// Stablecoin atomic units divided by the accumulator at mint time.
+ /// **Nominal debt at time T** = `normalized_debt_amount * accumulated_rate(T) / FIXED_POINT_ONE`.
+ /// Storing normalized lets one global accumulator update apply interest to every position.
+ pub normalized_debt_amount: u128,
+ /// Unix seconds when the position was first opened. UX/analytics; not used in protocol logic.
+ pub opened_at: u64,
+}
+```
+
+### 4.5 External account shapes (read-only, reused)
+
+- `OraclePriceAccount` from `twap_oracle_core` — required at oracle reads: `base_asset = stablecoin_definition_id`, `quote_asset = collateral_definition_id`, `price > 0`, `now − timestamp ≤ maximum_oracle_price_age_seconds`.
+- `TokenDefinition::Fungible` from `token_core` — stablecoin (PDA-derived under us; owned by Token Program) and collateral (externally created).
+- `TokenHolding::Fungible` from `token_core` — vault and user holdings.
+
+## 5. Constants and conventions
+
+### 5.1 Fixed point
+
+```rust
+/// The value 1.0 in our 27-decimal fixed-point representation.
+/// Every rate, ratio, and price-multiplier field stores `actual_value * FIXED_POINT_ONE`.
+pub const FIXED_POINT_ONE: u128 = 10u128.pow(27);
+```
+
+The 27-decimal choice matches MakerDAO / RAI's `RAY` precision and gives enough headroom for rate compounding over years without underflow.
+
+- All multiplications of fixed-point values use `u256` (`i256` for signed) intermediates to avoid overflow; results are reduced back to `u128` / `i128` after dividing by `FIXED_POINT_ONE`.
+- Rounding direction is chosen per use site to favour the protocol (§ 6.3).
+
+### 5.2 `compound_rate`
+
+```rust
+/// Returns `per_second_rate ^ seconds_elapsed` in fixed point (where `1.0 == FIXED_POINT_ONE`).
+///
+/// O(log seconds_elapsed) — exponentiation by squaring. Uses u256 intermediates.
+/// Same algorithm as MakerDAO / RAI's `rpow`.
+pub fn compound_rate(per_second_rate: u128, seconds_elapsed: u64) -> u128;
+```
+
+Edge cases:
+
+- `seconds_elapsed = 0` → returns `FIXED_POINT_ONE` (identity element).
+- `per_second_rate = FIXED_POINT_ONE` → returns `FIXED_POINT_ONE` regardless of `seconds_elapsed`.
+- `per_second_rate < FIXED_POINT_ONE` → result < `FIXED_POINT_ONE` (compounding decay).
+- Overflow guard: cap intermediate results; on overflow, panic (this should be impossible given parameter bounds in § 8).
+
+**In plain English:** this is just `rate ^ seconds_elapsed` — what a per-second multiplier becomes after that many seconds. The naive way is `seconds_elapsed` separate multiplications. Exponentiation-by-squaring does it in `log₂(seconds_elapsed)` multiplications instead — for a year's worth of seconds (~31.5M), that's ~25 muls instead of 31.5M.
+
+### 5.3 Current value projections (read on the hot path)
+
+```rust
+// Used in every op that needs the up-to-date accumulator / redemption price.
+fn current_accumulated_rate(state: StabilityFeeAccumulator, params: ProtocolParameters, now: u64) -> u128 {
+ let dt = now.saturating_sub(state.last_accrued_at);
+ let factor = compound_rate(params.stability_fee_per_second, dt);
+ mul_div(state.accumulated_rate_at_last_accrual, factor, FIXED_POINT_ONE)
+}
+
+fn current_redemption_price(state: RedemptionPriceState, now: u64) -> u128 {
+ let dt = now.saturating_sub(state.last_updated_at);
+ let factor = compound_rate(state.redemption_rate_per_second, dt);
+ mul_div(state.redemption_price_at_last_update, factor, FIXED_POINT_ONE)
+}
+```
+
+Where `mul_div(a, b, c) = (a * b) / c` computed via u256 to avoid intermediate overflow.
+
+**In plain English:** instead of writing "current_accumulator = X" to disk on every block (which would require touching every position's account on every fee tick), we store an **anchor** plus the **per-second rate**, and compute the current value on the fly by rolling the anchor forward via `compound_rate`. Reads are slightly more expensive (one `compound_rate` call); writes happen only on real anchor updates (the pokes in §10.2 / §10.3).
+
+## 6. Math
+
+### 6.1 Nominal debt
+
+Always derived; never stored.
+
+```
+nominal_debt(position, now) = position.normalized_debt_amount * current_accumulated_rate(state, params, now) / FIXED_POINT_ONE
+```
+
+**In plain English:** this is the "RAI trick" for accruing fees without rewriting every position on every fee tick. Think of `normalized_debt_amount` as **shares in a debt pool whose "value per share" is the accumulator**. Mint 100 stablecoins when the accumulator is `1.0` → you hold 100 "shares". When the accumulator later grows to `1.05` (5% accrued), you owe 105 — same shares, higher value per share, **no write to your position ever happened**. One global accumulator update applies interest to every position at once. The formula just unwinds shares back to the real number.
+
+### 6.2 Collateralization invariant
+
+For every modifying op that could decrease collateral or increase debt, enforced post-op:
+
+```
+collateral_value_in_stablecoin = position.collateral_amount * FIXED_POINT_ONE / current_redemption_price(now)
+required_collateral_in_stablecoin = nominal_debt * minimum_collateralization_ratio / FIXED_POINT_ONE
+assert collateral_value_in_stablecoin >= required_collateral_in_stablecoin
+```
+
+Equivalent (and the form actually checked, to keep one fewer fixed-point divisions):
+
+```
+assert position.collateral_amount * FIXED_POINT_ONE^2
+ >= nominal_debt * current_redemption_price * minimum_collateralization_ratio
+```
+
+Both sides computed in u256 to avoid intermediate overflow.
+
+Applied in: `withdraw_collateral` (post-decrement), `generate_debt` (post-mint).
+NOT applied in: `deposit_collateral` (strictly improves it), `repay_debt` (strictly improves it).
+
+**In plain English:** a position is healthy if your collateral is worth enough to cover your debt PLUS a safety buffer. To compare apples to apples, we convert your stablecoin debt into collateral units using the current redemption price (`redemption_price` is "collateral per stablecoin"). Then we require: `collateral ≥ debt-in-collateral-units × safety_ratio`. With ratio = 1.5, you need 1.5× the collateral-value of your debt. Going below 1.0× would mean immediate insolvency (no buffer left); liquidation lives in RFP-014.
+
+### 6.3 Normalized-debt deltas with directional rounding
+
+```rust
+// generate_debt: increase normalized_debt by amount-divided-by-accumulator,
+// ROUND UP — the borrower received exactly `amount` stablecoins, and we want their
+// nominal_debt (= normalized × accumulator) to grow by AT LEAST `amount`. Rounding
+// up the normalized increment guarantees that. Protocol stays whole.
+let delta = mul_div_ceil(amount, FIXED_POINT_ONE, current_accumulator);
+position.normalized_debt_amount = position.normalized_debt_amount.checked_add(delta)?;
+
+// repay_debt: decrease normalized_debt by amount-divided-by-accumulator,
+// ROUND DOWN — the borrower burned exactly `amount` stablecoins, and we want their
+// nominal_debt to shrink by AT MOST `amount`. Rounding the decrement down means
+// their debt drops slightly less than they paid; the rounding remainder becomes
+// extra fee credit for the protocol.
+let delta = mul_div_floor(amount, FIXED_POINT_ONE, current_accumulator);
+position.normalized_debt_amount = position.normalized_debt_amount.checked_sub(delta)?;
+// `checked_sub` panicking covers overrepay.
+```
+
+**In plain English:** integer math has to drop fractions somewhere. We always drop them in the direction that keeps the protocol whole.
+
+- **`generate_debt` rounds UP.** User gets exactly `amount` stablecoins; their nominal debt grows by `≥ amount`. They owe slightly more than they walked away with → protocol's total debt ≥ total supply.
+- **`repay_debt` rounds DOWN.** User burns exactly `amount` stablecoins; their nominal debt shrinks by `≤ amount`. They paid `amount` but debt only dropped by ≤ that → protocol keeps the rounding remainder as fee credit.
+
+Net effect: the protocol's "implicit fee credit" (`Σ nominal_debt − total_supply`, see §7 invariant 7) can only grow over time, never shrink. The integer dust always sticks to the protocol's side.
+
+**Dust trade-off on full repay.** Because we round the decrement down, fully clearing a position can require burning slightly more than the nominal debt (≤ one accumulator unit of overpayment). v1 accepts this. UX-side, the SDK can either show "exact" + "with dust buffer" amounts, or expose a `repay_all` helper that picks the right number off-chain.
+
+### 6.4 PI controller (`update_redemption_rate`)
+
+```
+// 1. Project current redemption price from the anchor.
+dt = now - state.last_updated_at
+current_redemption_price = state.redemption_price_at_last_update
+ * compound_rate(state.redemption_rate_per_second, dt)
+ / FIXED_POINT_ONE
+
+// 2. Compute signed error.
+// error > 0 when redemption_price > market_price (protocol's target above market valuation).
+error: i256 = (current_redemption_price as i256) - (oracle.price as i256)
+
+// 3. Update integral state (clamped for anti-windup).
+integral_delta = (params.controller_integral_gain as i256) * error * (dt as i256) / FIXED_POINT_ONE
+new_integral = state.controller_integral_term + integral_delta
+new_integral = clamp(new_integral, -INTEGRAL_CLAMP, INTEGRAL_CLAMP)
+
+// 4. Compute rate adjustment.
+proportional_term = (params.controller_proportional_gain as i256) * error / FIXED_POINT_ONE
+rate_adjustment = -(proportional_term + new_integral)
+// Negative sign: when redemption > market (error > 0), drive rate DOWN so the
+// redemption price drifts toward the market price; vice versa.
+
+// 5. Clamp the per-second adjustment (rate-explosion guard, RFP R2).
+rate_adjustment = clamp(rate_adjustment, -RATE_DELTA_CLAMP, RATE_DELTA_CLAMP)
+
+// 6. Persist.
+state.redemption_price_at_last_update = current_redemption_price
+state.redemption_rate_per_second = (FIXED_POINT_ONE as i256 + rate_adjustment) as u128
+state.controller_integral_term = new_integral
+state.last_updated_at = now
+```
+
+The signs work out so that **positive gains drive the system toward stability**. Operators tune gain magnitude; sign is conventional and embedded in the controller, not the gain.
+
+`INTEGRAL_CLAMP` and `RATE_DELTA_CLAMP` are constants in v1 (§ 8). Promoting them to `ProtocolParameters` admin-tunable fields is a future revision (no on-chain migration needed — additive).
+
+**In plain English:** this is the protocol's "thermostat". The redemption price is the target; the market price is what's actually observed. The bigger the gap (the **error**), the harder the protocol pushes back via the **redemption rate** — which then drifts the redemption price toward the market. The **proportional term** reacts to the CURRENT gap; the **integral term** remembers the gap's HISTORY, so persistent errors get a stronger correction over time. The two clamps prevent the two classic feedback-loop failure modes: anti-windup keeps the integral from growing unbounded during long imbalances, and the rate clamp keeps single-update jumps from exploding.
+
+```mermaid
+flowchart LR
+ Oracle[(MarketPriceOracle
oracle.price)]
+ StateOld[(RedemptionPriceState
at last update)]
+
+ StateOld -->|project forward via
compound_rate| CurRP[current_redemption_price]
+ CurRP --> Err{{error = current_redemption_price − oracle.price}}
+ Oracle --> Err
+
+ Err --> P[proportional_term
= Kp × error / FIXED_POINT_ONE]
+ Err --> IDelta[integral_delta
= Ki × error × Δt / FIXED_POINT_ONE]
+
+ StateOld -->|prev integral| IClamp
+ IDelta --> IClamp[new_integral
clamp ±INTEGRAL_CLAMP]
+
+ P --> RAdj["rate_adjustment
= − P − new_integral"]
+ IClamp --> RAdj
+ RAdj --> RClamp[clamp ±RATE_DELTA_CLAMP]
+ RClamp --> NewRate[redemption_rate_per_second
= FIXED_POINT_ONE + rate_adjustment]
+
+ NewRate -.persist.-> StateNew[(RedemptionPriceState
updated)]
+ CurRP -.new anchor.-> StateNew
+ IClamp -.persist.-> StateNew
+ StateNew -. drift then re-read on next update .-> CurRP
+```
+
+## 7. Cross-instruction invariants
+
+These are properties the protocol maintains across every state-changing instruction. Violations indicate a bug.
+
+1. **Vault-position consistency.** After every op, `position.collateral_amount == position.vault.balance` (the `TokenHolding::Fungible.balance` of the vault account).
+
+2. **Stability fee accumulator monotonicity.** `accumulated_rate_at_last_accrual` is monotonically non-decreasing while `stability_fee_per_second ≥ FIXED_POINT_ONE` (enforced by `set_stability_fee_per_second`'s bound check).
+
+3. **Redemption price positivity.** `redemption_price_at_last_update > 0` at all times. Initial value at init must be > 0; the controller's `rate_adjustment` clamp keeps `redemption_rate_per_second > 0`, so subsequent projections stay positive.
+
+4. **Position addressing.** For every `Position` account `P` owned by the stablecoin program, `P.account_id == compute_position_pda(stablecoin_program_id, P.owner_account_id, P.position_nonce)`.
+
+5. **Vault addressing.** For every `Position` account `P`, `P.vault_account_id == compute_vault_pda(stablecoin_program_id, P.account_id)`.
+
+6. **Single collateral.** For every vault account `V`, `V.data` decodes as `TokenHolding::Fungible { definition_id: protocol_parameters.collateral_definition_id, .. }`.
+
+7. **Supply ≤ total open principal.** `stablecoin_definition.total_supply` is the sum of `(stablecoin minted to users) − (stablecoin burned by users)` integrated over `generate_debt` / `repay_debt`. This equals the **mint-side** debt across positions, NOT the nominal debt (which includes accrued fees). The gap `Σ(nominal_debt) − total_supply` is the protocol's accumulated fee credit (RAI's "system surplus" in concept; not materialized on-chain in this design).
+
+8. **Frozen ⇒ debt-extending ops blocked.** `is_frozen = true` implies `open_position`, `generate_debt`, `withdraw_collateral` all panic. Other ops continue to work.
+
+## 8. Bound choices
+
+| Constant / parameter | Bound | Rationale |
+|---|---|---|
+| `FIXED_POINT_ONE` | `10^27` | RAY precision; standard. |
+| `stability_fee_per_second` | `FIXED_POINT_ONE ≤ x ≤ FIXED_POINT_ONE * 2` | Lower bound = no decay (RFP "fees accrue continuously" implies positive rate). Upper bound is a wildly impossible value (≈100% per second) for anti-typo. Real values are `1 + ε` where `ε ≈ 10^16` for ~5% annual. |
+| `minimum_collateralization_ratio` | `FIXED_POINT_ONE * 1.1 ≤ x ≤ FIXED_POINT_ONE * 10` | Lower bound = 110% (any less is liquidation-immediate); upper bound = 1000% (sanity cap). Real values are 130–200%. |
+| `controller_proportional_gain` magnitude | `|x| ≤ FIXED_POINT_ONE * 10^6` | Practical upper bound for rate-explosion guard (RFP R2). Real values are tiny (≈10^9–10^15 raw) because they scale price-error × rate-output. |
+| `controller_integral_gain` magnitude | Same as proportional | Same rationale. |
+| `INTEGRAL_CLAMP` | `± FIXED_POINT_ONE * 10^9` | Anti-windup. Constant in v1; future-promotable to `ProtocolParameters`. |
+| `RATE_DELTA_CLAMP` | `± FIXED_POINT_ONE / 100` | Max single-update rate adjustment: ±1% per call. Constant in v1. |
+| `minimum_seconds_between_fee_accruals` | `1 ≤ x ≤ 86400` | Min 1s (no zero-spam), max 1 day (RFP "rate updates within a small number of blocks"). |
+| `minimum_seconds_between_rate_updates` | Same | Same. |
+| `maximum_oracle_price_age_seconds` | `1 ≤ x ≤ 86400` | Stale beyond a day is obviously bad; aggressive freshness is a tuning parameter. |
+| `initial_redemption_price` | `> 0` | Must be positive; admin chooses an initial value reflecting the launch peg target. |
+
+All bounds enforced in `initialize_program` and the corresponding `set_*` instruction.
+
+## 9. Instruction set
+
+18 instructions in 5 groups. Full per-instruction details in § 10.
+
+### 9.1 Bootstrap
+
+| # | Instruction | Caller | One-line |
+|---|---|---|---|
+| 1 | `initialize_program` | admin (one-shot) | Create all four global PDAs (three native + stablecoin definition via chained `Token::NewFungibleDefinition`), bind collateral + oracle + initial params, set initial redemption price. |
+
+### 9.2 Permissionless pokes
+
+| # | Instruction | Caller | One-line |
+|---|---|---|---|
+| 2 | `accrue_stability_fee` | anyone | Roll `StabilityFeeAccumulator` forward to `now` if min interval elapsed. |
+| 3 | `update_redemption_rate` | anyone | Read oracle, run PI controller, re-anchor `RedemptionPriceState`. |
+
+### 9.3 Position lifecycle
+
+| # | Instruction | Caller | Frozen | One-line |
+|---|---|---|---|---|
+| 4 | `open_position` | owner | blocked | Claim Position PDA + vault PDA, deposit initial collateral. |
+| 5 | `deposit_collateral` | owner | ok | Add collateral to an existing position. |
+| 6 | `withdraw_collateral` | owner | blocked | Remove collateral, subject to collateralization. |
+| 7 | `generate_debt` | owner | blocked | Mint stablecoin, increase normalized debt, subject to collateralization. Oracle staleness gate. |
+| 8 | `repay_debt` | owner | ok | Burn stablecoin, decrease normalized debt. |
+| 9 | `close_position` | owner | ok | Clear Position PDA when debt = 0 and collateral = 0. Vault lingers (see § 14). |
+
+### 9.4 Admin parameter updates
+
+| # | Instruction | Caller | One-line |
+|---|---|---|---|
+| 10 | `set_stability_fee_per_second` | admin | Auto-accrues first; then updates the rate. |
+| 11 | `set_minimum_collateralization_ratio` | admin | Update the safety ratio. |
+| 12 | `set_controller_gains` | admin | Update Kp + Ki atomically. |
+| 13 | `set_market_price_oracle` | admin | Rotate oracle id; validate new oracle's base/quote. |
+| 14 | `set_timing_parameters` | admin | Update the three timing fields atomically. |
+| 15 | `set_admin` | admin | One-step admin rotation. |
+| 16 | `set_freeze_authority` | admin | One-step freeze authority rotation. |
+
+### 9.5 Emergency
+
+| # | Instruction | Caller | One-line |
+|---|---|---|---|
+| 17 | `freeze` | freeze authority | Sets `is_frozen = true`. |
+| 18 | `unfreeze` | freeze authority | Sets `is_frozen = false`. |
+
+## 10. Per-instruction details
+
+For each instruction below: **inputs** (accounts) with pre-state expectations and authorization requirements; **outputs** (the fields modified per account, plus any chained calls); **panics** (validation conditions that abort the instruction).
+
+### 10.1 `initialize_program`
+
+**Signature:**
+
+```rust
+fn initialize_program(
+ freeze_authority_account_id: AccountId,
+ initial_stability_fee_per_second: u128,
+ initial_controller_proportional_gain: i128,
+ initial_controller_integral_gain: i128,
+ initial_minimum_collateralization_ratio: u128,
+ minimum_seconds_between_fee_accruals: u64,
+ minimum_seconds_between_rate_updates: u64,
+ maximum_oracle_price_age_seconds: u64,
+ initial_redemption_price: u128,
+ stablecoin_name: String,
+);
+```
+
+**Inputs (8 accounts):**
+
+1. `admin` — authorized, becomes `ProtocolParameters.admin_account_id`. Pre-state unchanged.
+2. `protocol_parameters` — uninitialized, PDA-to-claim (`hash(program_id, "PROTOCOL_PARAMETERS")`).
+3. `stability_fee_accumulator` — uninitialized, PDA-to-claim.
+4. `redemption_price_state` — uninitialized, PDA-to-claim.
+5. `stablecoin_definition` — uninitialized, PDA-to-claim via chained `Token::NewFungibleDefinition`.
+6. `stablecoin_master_holding` — uninitialized, PDA-to-claim via the same chained call (Token Program API artifact; receives `total_supply = 0`, never used again).
+7. `collateral_definition` — initialized, read-only; persisted into `ProtocolParameters.collateral_definition_id`. Validated as `TokenDefinition::Fungible`.
+8. `market_price_oracle` — initialized, read-only. Validated: `OraclePriceAccount`, `base_asset = stablecoin_definition.account_id` (PDA derivation predicted), `quote_asset = collateral_definition.account_id`.
+
+**Outputs:**
+
+- `protocol_parameters` (claimed PDA): all `ProtocolParameters` fields written from the params (including `admin_account_id = admin.account_id`, `is_frozen = false`).
+- `stability_fee_accumulator` (claimed PDA): `accumulated_rate_at_last_accrual = FIXED_POINT_ONE`, `last_accrued_at = now`.
+- `redemption_price_state` (claimed PDA): `redemption_price_at_last_update = initial_redemption_price`, `redemption_rate_per_second = FIXED_POINT_ONE`, `controller_integral_term = 0`, `last_updated_at = now`.
+- `stablecoin_definition` (claimed via chained): `TokenDefinition::Fungible { name: stablecoin_name, total_supply: 0, metadata_id: None }`, owned by Token Program.
+- `stablecoin_master_holding` (claimed via chained): `TokenHolding::Fungible { definition_id: stablecoin_definition.account_id, balance: 0 }`, owned by Token Program.
+
+**Chained calls:**
+
+1. `Token::NewFungibleDefinition { name: stablecoin_name, total_supply: 0 }` · accounts: `[stablecoin_definition, stablecoin_master_holding]` (both authorized via their respective stablecoin-program PDA seeds).
+
+**Panics if:** `admin.is_authorized = false`; any of the four target PDAs already initialized; `collateral_definition` uninitialized or not `TokenDefinition::Fungible`; `market_price_oracle` base/quote mismatch; any numerical param outside its sane band (§ 8).
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (8)"]
+ a1[admin
auth] ~~~ a2[protocol_parameters
uninit] ~~~ a3[stability_fee_accumulator
uninit] ~~~ a4[redemption_price_state
uninit]
+ a5[stablecoin_definition
uninit] ~~~ a6[stablecoin_master_holding
uninit] ~~~ a7[collateral_definition
init, read] ~~~ a8[market_price_oracle
init, read]
+ end
+ subgraph Post["Post-state"]
+ p1[protocol_parameters
CLAIMED, all fields set] ~~~ p2[stability_fee_accumulator
CLAIMED
rate = FIXED_POINT_ONE] ~~~ p3[redemption_price_state
CLAIMED
price = initial]
+ p4[stablecoin_definition
CLAIMED, total_supply = 0] ~~~ p5[stablecoin_master_holding
CLAIMED, balance = 0]
+ end
+ In --> Ins((initialize_program))
+ Ins --> Post
+ Ins -.chained.-> C1[Token::NewFungibleDefinition
auth via PDA seeds]
+```
+
+### 10.2 `accrue_stability_fee`
+
+**Signature:** `fn accrue_stability_fee();`
+
+**Inputs (3 accounts):**
+
+1. `caller` — authorized; satisfies runtime's ≥1-authorized requirement. Not retained.
+2. `protocol_parameters` — initialized, read-only.
+3. `stability_fee_accumulator` — initialized, writable.
+
+**Output state changes:**
+
+- `stability_fee_accumulator.accumulated_rate_at_last_accrual` ← `anchor × compound_rate(stability_fee_per_second, now − last_accrued_at) / FIXED_POINT_ONE`
+- `stability_fee_accumulator.last_accrued_at` ← `now`
+
+**Chained calls:** none.
+
+**Panics if:** `caller.is_authorized = false`; either global uninitialized; `now − last_accrued_at < minimum_seconds_between_fee_accruals`; overflow in `compound_rate` (impossible under the bounds of § 8).
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (3)"]
+ a1[caller
auth] ~~~ a2[protocol_parameters
read] ~~~ a3[stability_fee_accumulator
write]
+ end
+ subgraph Post["Post-state"]
+ p1[stability_fee_accumulator
anchor x compound_rate
last_accrued_at = now]
+ end
+ In --> Ins((accrue_stability_fee))
+ Ins --> Post
+```
+
+### 10.3 `update_redemption_rate`
+
+**Signature:** `fn update_redemption_rate();`
+
+**Inputs (4 accounts):**
+
+1. `caller` — authorized.
+2. `protocol_parameters` — initialized, read-only.
+3. `redemption_price_state` — initialized, writable.
+4. `market_price_oracle` — initialized, read-only. Must equal `protocol_parameters.market_price_oracle_id`.
+
+**Output state changes (`redemption_price_state` only):** per § 6.4.
+
+**Chained calls:** none.
+
+**Panics if:** `caller.is_authorized = false`; any input uninitialized / wrong owner; oracle id mismatch; `now − oracle.timestamp > maximum_oracle_price_age_seconds`; `oracle.price = 0`; `now − last_updated_at < minimum_seconds_between_rate_updates`.
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (4)"]
+ a1[caller
auth] ~~~ a2[protocol_parameters
read] ~~~ a3[redemption_price_state
write] ~~~ a4[market_price_oracle
read, freshness gate]
+ end
+ subgraph Post["Post-state"]
+ p1[redemption_price_state
new anchor, new rate,
new integral, last_updated_at]
+ end
+ In --> Ins((update_redemption_rate
PI controller, see 6.4))
+ Ins --> Post
+```
+
+### 10.4 `open_position`
+
+**Signature:** `fn open_position(position_nonce: u64, initial_collateral_amount: u128);`
+
+**Inputs (6 accounts):**
+
+1. `owner` — authorized; becomes `position.owner_account_id`.
+2. `position` — uninitialized; PDA `hash(program_id, hash(owner.account_id, position_nonce))`.
+3. `vault` — uninitialized; PDA `hash(program_id, hash(position.account_id))`.
+4. `user_collateral_holding` — authorized, initialized; `TokenHolding::Fungible` with `definition_id = collateral_definition.account_id` and `balance ≥ initial_collateral_amount`.
+5. `collateral_definition` — initialized, read-only; must equal `protocol_parameters.collateral_definition_id`. Required by the chained `Token::InitializeAccount`.
+6. `protocol_parameters` — initialized, read-only. Reads `collateral_definition_id` + `is_frozen`.
+
+**Outputs:**
+
+- `position` (claimed PDA): `owner_account_id = owner.account_id`, `position_nonce = position_nonce`, `vault_account_id = vault.account_id`, `collateral_amount = initial_collateral_amount`, `normalized_debt_amount = 0`, `opened_at = now`.
+- `vault` (claimed via chained `Token::InitializeAccount` then balance updated by chained `Token::Transfer`): final `TokenHolding::Fungible { definition_id: collateral_definition.account_id, balance: initial_collateral_amount }`.
+- `user_collateral_holding`: `balance` decreased by `initial_collateral_amount`.
+
+**Chained calls:**
+
+1. `Token::InitializeAccount` · accounts: `[collateral_definition, vault (auth via vault PDA seed)]` · pda_seeds: `[vault_seed]`.
+2. `Token::Transfer { amount: initial_collateral_amount }` · accounts: `[user_collateral_holding (user-authorized), vault]` · no PDA seeds.
+
+**Panics if:** `owner.is_authorized = false`; `user_collateral_holding.is_authorized = false`; position or vault already initialized; `collateral_definition.account_id ≠ protocol_parameters.collateral_definition_id`; user holding's `definition_id` mismatch or different Token Program; position / vault PDA derivation mismatch; `protocol_parameters.is_frozen = true`.
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (6)"]
+ a1[owner
auth] ~~~ a2[position
uninit] ~~~ a3[vault
uninit]
+ a4[user_collateral_holding
auth + init] ~~~ a5[collateral_definition
read] ~~~ a6[protocol_parameters
read]
+ end
+ subgraph Post["Post-state"]
+ p1[position
CLAIMED PDA
collateral_amount = initial
normalized_debt = 0] ~~~ p2[vault
CLAIMED via chained
balance = initial] ~~~ p3[user_collateral_holding
balance -= initial]
+ end
+ In --> Ins((open_position
nonce, initial))
+ Ins --> Post
+ Ins -.chained.-> C1[Token::InitializeAccount
auth via vault PDA seed]
+ Ins -.chained.-> C2[Token::Transfer initial]
+```
+
+### 10.5 `deposit_collateral`
+
+**Signature:** `fn deposit_collateral(amount: u128);`
+
+**Inputs (5 accounts):**
+
+1. `owner` — authorized.
+2. `position` — initialized, writable; PDA verified against `(owner, position.position_nonce)`.
+3. `vault` — initialized, writable; must equal `position.vault_account_id`.
+4. `user_collateral_holding` — authorized, initialized; `definition_id = protocol_parameters.collateral_definition_id`; same Token Program as the vault.
+5. `protocol_parameters` — initialized, read-only.
+
+**Outputs:**
+
+- `position.collateral_amount` ← `old + amount`.
+- `vault.balance` ← `old + amount` (via chained `Token::Transfer`).
+- `user_collateral_holding.balance` ← `old − amount` (same chained call).
+
+**Chained calls:** `Token::Transfer { amount }` · accounts: `[user_collateral_holding (user-authorized), vault]` · no PDA seeds.
+
+**Panics if:** `owner.is_authorized = false`; `user_collateral_holding.is_authorized = false`; position uninit / wrong owner / PDA mismatch; vault doesn't match `position.vault_account_id`; user holding's `definition_id` mismatch or different Token Program; `position.collateral_amount + amount` overflows. Allowed when frozen.
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (5)"]
+ a1[owner
auth] ~~~ a2[position
write] ~~~ a3[vault
write]
+ a4[user_collateral_holding
auth + init] ~~~ a5[protocol_parameters
read]
+ end
+ subgraph Post["Post-state"]
+ p1[position
collateral_amount += amount] ~~~ p2[vault
balance += amount] ~~~ p3[user_collateral_holding
balance -= amount]
+ end
+ In --> Ins((deposit_collateral
amount))
+ Ins --> Post
+ Ins -.chained.-> C1[Token::Transfer amount]
+```
+
+### 10.6 `withdraw_collateral`
+
+**Signature:** `fn withdraw_collateral(amount: u128);`
+
+**Inputs (7 accounts):**
+
+1. `owner` — authorized.
+2. `position` — initialized, writable; PDA verified.
+3. `vault` — initialized, writable; auth via vault PDA seed in the chained call.
+4. `user_collateral_holding` — initialized (destination); NOT required to be authorized.
+5. `stability_fee_accumulator` — initialized, read-only; for current accumulator → nominal debt.
+6. `redemption_price_state` — initialized, read-only; for current redemption price.
+7. `protocol_parameters` — initialized, read-only.
+
+**Outputs:**
+
+- `position.collateral_amount` ← `old − amount`.
+- `vault.balance` ← `old − amount`.
+- `user_collateral_holding.balance` ← `old + amount`.
+
+**Chained calls:** `Token::Transfer { amount }` · accounts: `[vault (auth via vault PDA seed), user_collateral_holding]` · pda_seeds: `[vault_seed]`.
+
+**Panics if:** `owner.is_authorized = false`; position uninit / wrong owner / PDA mismatch; vault doesn't match `position.vault_account_id`; user holding's `definition_id` mismatch or different Token Program; `protocol_parameters.is_frozen = true`; `amount > position.collateral_amount`; collateralization check (§ 6.2) fails post-decrement.
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (7)"]
+ a1[owner
auth] ~~~ a2[position
write] ~~~ a3[vault
write] ~~~ a4[user_collateral_holding
init, destination]
+ a5[stability_fee_accumulator
read] ~~~ a6[redemption_price_state
read] ~~~ a7[protocol_parameters
read]
+ end
+ subgraph Post["Post-state"]
+ p1[position
collateral_amount -= amount
collateralization check] ~~~ p2[vault
balance -= amount] ~~~ p3[user_collateral_holding
balance += amount]
+ end
+ In --> Ins((withdraw_collateral
amount))
+ Ins --> Post
+ Ins -.chained.-> C1[Token::Transfer amount
auth via vault PDA seed]
+```
+
+### 10.7 `generate_debt`
+
+**Signature:** `fn generate_debt(amount: u128);`
+
+**Inputs (8 accounts):**
+
+1. `owner` — authorized.
+2. `position` — initialized, writable; PDA verified.
+3. `stablecoin_definition` — initialized, writable (chained Mint); must equal `protocol_parameters.stablecoin_definition_id`.
+4. `user_stablecoin_holding` — initialized; `definition_id = stablecoin_definition.account_id`; same Token Program. NOT required to be authorized (Mint destination).
+5. `stability_fee_accumulator` — initialized, read-only.
+6. `redemption_price_state` — initialized, read-only.
+7. `market_price_oracle` — initialized, read-only; for staleness gate only. Must equal `protocol_parameters.market_price_oracle_id`.
+8. `protocol_parameters` — initialized, read-only.
+
+**Outputs:**
+
+- `position.normalized_debt_amount` ← `old + ⌈amount × FIXED_POINT_ONE / current_accumulator⌉` (round UP, § 6.3).
+- `stablecoin_definition.total_supply` ← `old + amount` (chained Mint).
+- `user_stablecoin_holding.balance` ← `old + amount` (chained Mint).
+
+**Chained calls:** `Token::Mint { amount_to_mint: amount }` · accounts: `[stablecoin_definition (auth via stablecoin program PDA seed), user_stablecoin_holding]` · pda_seeds: `[stablecoin_definition_seed]`.
+
+**Panics if:** `owner.is_authorized = false`; position uninit / wrong owner / PDA mismatch; `stablecoin_definition.account_id ≠ protocol_parameters.stablecoin_definition_id`; user holding's `definition_id` mismatch or different Token Program; `market_price_oracle.account_id ≠ protocol_parameters.market_price_oracle_id`; `now − oracle.timestamp > maximum_oracle_price_age_seconds`; `protocol_parameters.is_frozen = true`; collateralization check (§ 6.2) fails post-mint; arithmetic overflow.
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (8)"]
+ a1[owner
auth] ~~~ a2[position
write] ~~~ a3[stablecoin_definition
write, chained] ~~~ a4[user_stablecoin_holding
init]
+ a5[stability_fee_accumulator
read] ~~~ a6[redemption_price_state
read] ~~~ a7[market_price_oracle
read, staleness gate] ~~~ a8[protocol_parameters
read]
+ end
+ subgraph Post["Post-state"]
+ p1[position
normalized_debt += ceil amt/acc
collateralization check] ~~~ p2[stablecoin_definition
total_supply += amount] ~~~ p3[user_stablecoin_holding
balance += amount]
+ end
+ In --> Ins((generate_debt
amount))
+ Ins --> Post
+ Ins -.chained.-> C1[Token::Mint amount
auth via stablecoin PDA seed]
+```
+
+### 10.8 `repay_debt`
+
+**Signature:** `fn repay_debt(amount: u128);`
+
+**Inputs (6 accounts):**
+
+1. `owner` — authorized.
+2. `position` — initialized, writable; PDA verified.
+3. `stablecoin_definition` — initialized, writable (chained Burn); must equal `protocol_parameters.stablecoin_definition_id`.
+4. `user_stablecoin_holding` — authorized, initialized; `definition_id = stablecoin_definition.account_id`; same Token Program.
+5. `stability_fee_accumulator` — initialized, read-only.
+6. `protocol_parameters` — initialized, read-only.
+
+**Outputs:**
+
+- `position.normalized_debt_amount` ← `old − ⌊amount × FIXED_POINT_ONE / current_accumulator⌋` (round DOWN, § 6.3); `checked_sub` panics on overrepay.
+- `stablecoin_definition.total_supply` ← `old − amount`.
+- `user_stablecoin_holding.balance` ← `old − amount`.
+
+**Chained calls:** `Token::Burn { amount_to_burn: amount }` · accounts: `[stablecoin_definition, user_stablecoin_holding (user-authorized)]` · no PDA seeds.
+
+**Panics if:** `owner.is_authorized = false`; `user_stablecoin_holding.is_authorized = false`; position uninit / wrong owner / PDA mismatch; `stablecoin_definition.account_id ≠ protocol_parameters.stablecoin_definition_id`; user holding's `definition_id` mismatch or different Token Program; overrepay (`⌈amount × FIXED_POINT_ONE / current_accumulator⌉ > position.normalized_debt_amount`). Allowed when frozen.
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (6)"]
+ a1[owner
auth] ~~~ a2[position
write] ~~~ a3[stablecoin_definition
write, chained]
+ a4[user_stablecoin_holding
auth + init] ~~~ a5[stability_fee_accumulator
read] ~~~ a6[protocol_parameters
read]
+ end
+ subgraph Post["Post-state"]
+ p1[position
normalized_debt -= floor amt/acc] ~~~ p2[stablecoin_definition
total_supply -= amount] ~~~ p3[user_stablecoin_holding
balance -= amount]
+ end
+ In --> Ins((repay_debt
amount))
+ Ins --> Post
+ Ins -.chained.-> C1[Token::Burn amount
auth via user]
+```
+
+### 10.9 `close_position`
+
+**Signature:** `fn close_position();`
+
+**Inputs (4 accounts):**
+
+1. `owner` — authorized.
+2. `position` — initialized, to-be-cleared; PDA verified.
+3. `vault` — initialized, read-only; must equal `position.vault_account_id`; `balance = 0` asserted.
+4. `protocol_parameters` — initialized, read-only.
+
+**Outputs:**
+
+- `position` ← `Account::default()` (cleared; PDA released).
+- `vault` unchanged — lingers with `balance = 0` (Token Program has no `CloseHolding`; § 14).
+
+**Chained calls:** none.
+
+**Panics if:** `owner.is_authorized = false`; position uninit / wrong owner / PDA mismatch; `position.normalized_debt_amount ≠ 0`; `position.collateral_amount ≠ 0`; vault account_id mismatch; `vault.balance ≠ 0`. Allowed when frozen.
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (4)"]
+ a1[owner
auth] ~~~ a2[position
to clear] ~~~ a3[vault
read, balance must be 0] ~~~ a4[protocol_parameters
read]
+ end
+ subgraph Post["Post-state"]
+ p1[position
CLEARED to default
PDA released] ~~~ p2[vault
UNCHANGED
lingers as artifact]
+ end
+ In --> Ins((close_position))
+ Ins --> Post
+```
+
+### 10.10–10.16 Admin parameter updates
+
+#### Capabilities at a glance
+
+Every settable thing in the protocol, what it starts as, and who/how it can change:
+
+| Field | Init source | Modifiable later? |
+|---|---|---|
+| `admin_account_id` | the `admin` account that signed init | yes — `set_admin` (one-step rotation) |
+| `freeze_authority_account_id` | param to init | yes — `set_freeze_authority` |
+| `stablecoin_definition_id` | PDA claimed at init | **NO — immutable** (changing breaks supply accounting) |
+| `collateral_definition_id` | param to init | **NO — immutable** (changing orphans every vault) |
+| `market_price_oracle_id` | param to init | yes — `set_market_price_oracle` (validates new oracle's base/quote) |
+| `stability_fee_per_second` | param to init | yes — `set_stability_fee_per_second` (auto-accrues at OLD rate first) |
+| `controller_proportional_gain` | param to init | yes — `set_controller_gains` (bundled with Ki) |
+| `controller_integral_gain` | param to init | yes — `set_controller_gains` (bundled with Kp) |
+| `minimum_collateralization_ratio` | param to init | yes — `set_minimum_collateralization_ratio` |
+| `minimum_seconds_between_fee_accruals` | param to init | yes — `set_timing_parameters` (bundled) |
+| `minimum_seconds_between_rate_updates` | param to init | yes — `set_timing_parameters` (bundled) |
+| `maximum_oracle_price_age_seconds` | param to init | yes — `set_timing_parameters` (bundled) |
+| `is_frozen` | always `false` at init | toggled by `freeze` / `unfreeze` (freeze_authority, not admin) |
+| `redemption_price_at_last_update` | param to init (initial price) | **not directly settable by admin** — only drifts via `update_redemption_rate` (controller) |
+| `redemption_rate_per_second` | always `FIXED_POINT_ONE` at init | controller-managed (only `update_redemption_rate` writes it) |
+| `controller_integral_term` | always `0` at init | controller-managed (only `update_redemption_rate` writes it) |
+| `accumulated_rate_at_last_accrual` | always `FIXED_POINT_ONE` at init | grows monotonically via `accrue_stability_fee` and the auto-accrue in `set_stability_fee_per_second` |
+| `stablecoin name` | param to init | **NO — immutable** (lives in `TokenDefinition::Fungible`; no token-program setter) |
+
+**What admin CAN do:**
+
+- Rotate the admin and freeze-authority handles.
+- Tune the stability fee (with auto-accrue: new rate applies only from `now` forward, never retroactively to the elapsed gap).
+- Tune the controller gains (Kp / Ki), without resetting the integral term.
+- Tune the minimum collateralization ratio. Tightening leaves existing positions retroactively under-collateralized — they can `deposit_collateral` or `repay_debt` to recover, but cannot `withdraw_collateral` or `generate_debt` until they're back above the new ratio.
+- Rotate the market-price oracle to a new account (validates the new oracle's base/quote pair, does not pin its `program_owner` — so any producer that emits an `OraclePriceAccount` with the right shape works).
+- Tune the timing parameters (accrual interval, rate-update interval, oracle staleness threshold).
+
+**What admin CANNOT do:**
+
+- Change the stablecoin definition or the collateral definition. Those are locked at init; the rationale is in §4.1 (changing either breaks accounting that's already on-chain).
+- Change the stablecoin name (held inside the immutable `TokenDefinition::Fungible`).
+- Reset the redemption price (no admin override — only the controller drifts it; an admin escape hatch was deliberately rejected since it would let a compromised admin reprice the system arbitrarily).
+- Reset the controller integral term.
+- Mint or burn stablecoin directly. The only minting/burning paths are `generate_debt` / `repay_debt`, which are user-driven and gated by collateralization.
+- Modify any position's fields. Positions are owner-authorized only.
+- Freeze or unfreeze the protocol. That's the freeze authority's job.
+
+**What freeze_authority CAN do:** call `freeze` or `unfreeze`. Nothing else.
+
+**Trust assumption:** an admin (and the freeze authority) is fully trusted within these capabilities. A malicious admin can stop new debt generation by tightening the ratio, drain the protocol's safety by setting a permissive ratio, or front-run users by rotating to a malicious oracle. Mitigations rely on external operational practice (multisig / timelock around the admin handle), the independent freeze authority as a kill switch, and the future RFP-001 / RFP-002 wrappers when they land.
+
+#### Shared skeleton
+
+All seven share the same skeleton:
+
+**Inputs (2 base + 0-1 extras):**
+
+- `admin` — authorized; `admin.account_id == protocol_parameters.admin_account_id`.
+- `protocol_parameters` — initialized, writable.
+
+**Output:** exactly the field(s) listed below are overwritten on `protocol_parameters`; everything else unchanged.
+
+**Panics if:** `admin.is_authorized = false`; admin handle mismatch; protocol_parameters uninit / wrong owner; new value outside its sane band (§ 8).
+
+| # | Instruction | Param(s) | Fields rewritten | Extra accounts | Special note |
+|---|---|---|---|---|---|
+| 10 | `set_stability_fee_per_second` | `new_rate: u128` | `stability_fee_per_second` | `stability_fee_accumulator` (writable) | Auto-accrues forward at the OLD rate up to `now` first. |
+| 11 | `set_minimum_collateralization_ratio` | `new_ratio: u128` | `minimum_collateralization_ratio` | — | Tightening leaves existing positions retroactively under-collateralized; they cannot increase debt or withdraw collateral until back above. No mass-liquidation here (RFP-014 out of scope). |
+| 12 | `set_controller_gains` | `new_proportional_gain: i128, new_integral_gain: i128` | `controller_proportional_gain`, `controller_integral_gain` | — | Does NOT reset `controller_integral_term`. |
+| 13 | `set_market_price_oracle` | (no scalar) | `market_price_oracle_id` | `new_oracle` (read-only) | Validates `OraclePriceAccount` shape, base/quote ids. `program_owner` not pinned. |
+| 14 | `set_timing_parameters` | three `u64`s | `minimum_seconds_between_fee_accruals`, `minimum_seconds_between_rate_updates`, `maximum_oracle_price_age_seconds` | — | Bundled. |
+| 15 | `set_admin` | `new_admin_account_id: AccountId` | `admin_account_id` | — | One-step rotation. |
+| 16 | `set_freeze_authority` | `new_freeze_authority_account_id: AccountId` | `freeze_authority_account_id` | — | One-step rotation. |
+
+```mermaid
+flowchart TD
+ subgraph In["Base inputs (2 + 0..1)"]
+ a1[admin
auth, == admin_account_id] ~~~ a2[protocol_parameters
write] ~~~ ax[stability_fee_accumulator
write — only set_stability_fee] ~~~ ay[new_oracle
read — only set_market_price_oracle]
+ end
+ subgraph Post["Post-state"]
+ p1["protocol_parameters
only the listed fields overwritten"] ~~~ px[stability_fee_accumulator
auto-accrued at OLD rate first
— only set_stability_fee]
+ end
+ In --> Ins((set_*
see §10 table for fields))
+ Ins --> Post
+```
+
+### 10.17–10.18 `freeze` / `unfreeze`
+
+**Inputs (2 accounts):**
+
+- `freeze_authority` — authorized; `freeze_authority.account_id == protocol_parameters.freeze_authority_account_id`.
+- `protocol_parameters` — initialized, writable.
+
+**Output:** `protocol_parameters.is_frozen` ← `true` (freeze) or `false` (unfreeze). Idempotent.
+
+**Panics if:** auth check fails; protocol_parameters uninit / wrong owner.
+
+```mermaid
+flowchart TD
+ subgraph In["Inputs (2)"]
+ a1[freeze_authority
auth, == freeze_authority_account_id] ~~~ a2[protocol_parameters
write]
+ end
+ subgraph Post["Post-state"]
+ p1[protocol_parameters
is_frozen := true / false]
+ end
+ In --> Ins((freeze / unfreeze))
+ Ins --> Post
+```
+
+## 11. Edge cases
+
+- **Empty position** (`collateral_amount = 0`, `normalized_debt_amount = 0`) — valid intermediate state. `withdraw_collateral` with `amount = 0` is a no-op. `close_position` is the only path that clears the account.
+- **Long time since last poke.** `compound_rate` handles large `dt` via exponentiation by squaring (O(log dt)). At extreme `dt` (e.g., 10 years) the result is bounded by the constants of § 8.
+- **Stale oracle but unfrozen.** `update_redemption_rate` panics, so the rate stops drifting at whatever it last was. `generate_debt` also panics (RFP R3). Existing positions, `deposit_collateral`, `repay_debt`, `withdraw_collateral` all continue. Withdrawing requires the collateralization check, which uses the projected redemption price from the OLD rate — operationally fine.
+- **Frozen + stale oracle.** `withdraw_collateral` blocked (frozen). `generate_debt` blocked twice (frozen + stale). `deposit_collateral`, `repay_debt`, `close_position` work. Anyone can still call `accrue_stability_fee` (rate-independent).
+- **Admin tightens `minimum_collateralization_ratio`.** Existing positions with healthy-old-ratio but underwater-new-ratio cannot `generate_debt` or `withdraw_collateral`. They CAN `deposit_collateral` to recover, or `repay_debt` to reduce their debt.
+- **Position re-open at same nonce after close.** Fails — the vault PDA at `hash(position_id)` lingers from before. Workaround: pick a fresh nonce. See § 14.
+- **Overrepay on `repay_debt`.** Panics via `checked_sub`; protects against user error sending more burn than nominal debt at this instant.
+- **Dust on full repay.** Because the decrement is rounded down (§6.3), burning exactly the current nominal debt may leave a tiny `normalized_debt_amount` residue (≤ one accumulator unit). To fully clear, users overpay by a tiny amount (≤ accumulator × 1 raw unit). Off-chain SDK computes the right number; if it picks too small, `repay_debt` still succeeds but the position isn't closeable until another small repay.
+- **Zero-amount instructions.** `deposit_collateral(0)`, `withdraw_collateral(0)`, `generate_debt(0)`, `repay_debt(0)`: all valid no-ops at the protocol level (chained Token call is also a no-op). Saves the caller from having to short-circuit.
+
+## 12. Out of scope (per RFP)
+
+- **Liquidation mechanism** — addressed by RFP-014.
+- **Surplus / debt management auctions** — addressed by RFP-014.
+- **Multi-collateral positions** — single instance, single collateral per deployment.
+- **Privacy-private state positions** — privacy is enforced at the UX layer in this design's downstream specs (mini-app / SDK), not on-chain.
+- **Governance token design** — admin and freeze authority are plain `AccountId` handles in this RFP; governance machinery is its own project.
+- **CLI, mini-app, SDK** — separate specs, downstream of this one.
+
+## 13. Forward integration
+
+- **RFP-014 (Liquidation & Auction Engine).** Adds an external "liquidator" program that, when a position falls under `minimum_collateralization_ratio`, may seize its collateral and clear its debt. Cleanest fit: a new instruction `liquidate_position` callable by the liquidator program (gated by checking the position's collateralization), or by extending the existing instructions to support a `liquidate_only_path`. Either way, this design's `normalized_debt_amount` and `current_accumulator` carry over directly. Surplus accounting (the gap of § 7 invariant 7) materializes here when auctions land.
+- **RFP-001 (Admin Authority).** When RFP-001 ships, `admin_account_id` either points at the admin program's account (and any `set_*` instruction's `admin` input is that account being authorized by that program) or this RFP grows a wrapper. Either way it's a local change: `set_*` instructions become "admin authority program authorizes this account" rather than "this account is `is_authorized`". No data-model migration needed.
+- **RFP-002 (Freeze Authority).** Same pattern for `freeze_authority_account_id`.
+- **Mini-app / CLI / SDK.** Read the on-chain accounts directly to display: position-level collateralization, redemption price drift, projected outcomes (these are SDK functions over the same math in § 6). The deshield-interact-reshield privacy pattern is the SDK's responsibility; the program is unaware of whether callers are ephemeral or persistent.
+
+## 14. Open follow-ups (not blocking this design)
+
+- **`Token::CloseHolding`.** Upstream extension to the token program. Lets `close_position` actually clear the vault account so position-nonce reuse becomes possible. Track as a separate issue against the Token Program.
+- **Two-step admin / freeze rotation.** `set_admin` / `set_freeze_authority` could grow `pending_*` fields + `accept_*` instructions to protect against typo'd `set_admin`. Not in this RFP.
+- **Promote `INTEGRAL_CLAMP` and `RATE_DELTA_CLAMP` to admin-tunable.** Constants for v1 to keep the surface small. Promotion is additive (new `ProtocolParameters` fields + new admin setters); no migration of existing state.
+- **Liquidation-specific instructions.** Tracked under RFP-014.
+- **Surplus extraction.** When RFP-014 lands, the implicit fee credit (§ 7 invariant 7) becomes extractable. May require a one-shot `materialize_surplus` instruction that mints the gap into a designated holding.
+
+## 15. Implementation plan handoff
+
+This design is the input to the writing-plans skill. The plan should:
+
+1. Decompose the 18 instructions into landable issues (per-instruction or small bundles) with clear deps.
+2. Account for the data-model migration from the current scaffold (`Position` field renames + the global PDAs that don't exist yet).
+3. Include the `idl-gen` regeneration step per the `Makefile` `idl` target.
+4. Include integration test coverage for the multi-step flows (open → generate → repay → close, oracle staleness scenarios, frozen scenarios, admin parameter sweeps).
+5. Lock the actual numerical bounds of § 8 before they're hardcoded into the program (review with operations / risk).
+
+## 16. Sample scenarios
+
+Two end-to-end walkthroughs showing how the relevant fields change over time. Numbers are illustrative — scaled-down magnitudes so the arithmetic stays readable. Real deployments would use atomic-unit scales (`u128`).
+
+### 16.1 Alice's full lifecycle
+
+Alice opens a collateralized position, borrows stablecoin, holds for ~1 year, accrues fees, repays everything, withdraws her collateral, and closes the position.
+
+**Setup (t = 0, just after a long-running protocol)**
+
+- `ProtocolParameters`: `stability_fee_per_second` set for ~5%/year, `minimum_collateralization_ratio = 1.5 × FIXED_POINT_ONE`, oracle wired to a TWAP producer.
+- `StabilityFeeAccumulator`: `accumulated_rate_at_last_accrual = 1.0 × FIXED_POINT_ONE`, `last_accrued_at = 0` (assume keepers will catch up).
+- `RedemptionPriceState`: `redemption_price_at_last_update = 0.5 × FIXED_POINT_ONE` (collateral-per-stablecoin), `redemption_rate_per_second = FIXED_POINT_ONE`, `controller_integral_term = 0`.
+- Alice's collateral holding: `balance = 1000`. Alice's stablecoin holding: doesn't exist yet (she'll initialize it before `generate_debt`).
+
+**Step 1 — t = 0s: `open_position(nonce = 7, initial_collateral_amount = 600)`**
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `position` (Alice, 7) | (the whole account) | `Account::default()` | `Position{ owner=Alice, nonce=7, vault=, collateral_amount=600, normalized_debt_amount=0, opened_at=0 }`, program_owner = stablecoin |
+| `vault` (PDA from position) | balance | uninit | 600 |
+| Alice's collateral holding | balance | 1000 | 400 |
+
+No oracle / accumulator / redemption-price reads in this op — opening a fresh position with no debt is "deposit_collateral plus a PDA claim".
+
+**Step 2 — t = 10s: Alice initializes her stablecoin holding (separate Token-program tx, not shown), then calls `generate_debt(amount = 200)`**
+
+State of globals at t=10s:
+- `current_accumulated_rate(t=10) ≈ 1.0 × FIXED_POINT_ONE` (negligible drift in 10 seconds).
+- `current_redemption_price(t=10) ≈ 0.5 × FIXED_POINT_ONE`.
+- Oracle is fresh.
+
+Computation:
+- `delta_normalized = ⌈200 × FIXED_POINT_ONE / current_accumulator⌉ ≈ ⌈200⌉ = 200` (no rounding loss yet).
+- Collateralization check: `collateral_value_in_stablecoin = 600 / 0.5 = 1200`; `required = 200 × 1.5 = 300`; `1200 ≥ 300` ✓.
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `position` | normalized_debt_amount | 0 | 200 |
+| `stablecoin_definition` | total_supply | S | S + 200 |
+| Alice's stablecoin holding | balance | 0 | 200 |
+
+**Step 3 — t = 365 days (one year of permissionless keepers calling `accrue_stability_fee` periodically)**
+
+The accumulator has compounded: at 5%/year stability fee, `accumulated_rate ≈ 1.0513 × FIXED_POINT_ONE` after a year.
+
+Alice's position is **unchanged structurally** — `normalized_debt_amount` still 200, `collateral_amount` still 600. But her **nominal debt** is now:
+
+- `nominal_debt = 200 × 1.0513 ≈ 210.26` stablecoins.
+
+Globals look like (after the year of pokes):
+- `StabilityFeeAccumulator.accumulated_rate_at_last_accrual ≈ 1.0513 × FIXED_POINT_ONE`, `last_accrued_at = 365 days`.
+- `RedemptionPriceState`: the controller has drifted it based on observed market vs target. For this scenario, say it ended at `0.502 × FIXED_POINT_ONE`.
+
+**Step 4 — t = 365 days + 1s: Alice acquires ~10.3 extra stablecoin from the market (e.g., buys from another user who borrowed) and calls `repay_debt(amount = 211)`**
+
+Computation:
+- `current_accumulator ≈ 1.0513 × FIXED_POINT_ONE` (no new accrue in 1s).
+- `delta_normalized = ⌊211 × FIXED_POINT_ONE / 1.0513×FIXED_POINT_ONE⌋ = ⌊200.7⌋ = 200`.
+- `position.normalized_debt_amount := 200 − 200 = 0`. Cleared.
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `position` | normalized_debt_amount | 200 | 0 |
+| `stablecoin_definition` | total_supply | S + 200 | S − 11 (net) |
+| Alice's stablecoin holding | balance | 211 | 0 |
+
+Alice slightly overpaid (211 nominal vs 210.26 owed). The extra 0.74 went to the protocol as fee credit (this is the rounding remainder; in real magnitudes it's negligible).
+
+**Step 5 — t = 365 days + 2s: `withdraw_collateral(amount = 600)`**
+
+- Position has zero nominal debt → collateralization check trivially passes for any withdrawal up to `collateral_amount`.
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `position` | collateral_amount | 600 | 0 |
+| `vault` | balance | 600 | 0 |
+| Alice's collateral holding | balance | 400 | 1000 |
+
+**Step 6 — t = 365 days + 3s: `close_position()`**
+
+- Preconditions: `normalized_debt_amount = 0` ✓, `collateral_amount = 0` ✓, `vault.balance = 0` ✓.
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `position` | (all fields) | `Position{ collateral=0, debt=0, ... }` | `Account::default()` |
+| `vault` | — | TokenHolding with balance=0 | UNCHANGED (lingers as artifact, see §14) |
+
+**Net result over the year:**
+
+- Alice locked 600 collateral, borrowed 200 stablecoin, repaid 211 stablecoin. Net cost: ~11 stablecoin (the protocol's accrued stability fee).
+- The protocol's "accumulated fee credit" gap (§7 invariant 7) grew by ~11 over the year just from Alice's position. Over thousands of positions, this is the fee revenue the protocol implicitly holds. Surplus extraction is a future-RFP capability (§14).
+
+### 16.2 Emergency freeze
+
+A misbehaving oracle and the freeze authority's response.
+
+**Setup (t = T, normal operation)**
+
+- Alice and Bob both have positions with debt. Say Alice has `normalized_debt = 200`, Bob has `normalized_debt = 500`.
+- Current accumulator ≈ `1.05 × FIXED_POINT_ONE`. Current redemption price ≈ `0.5 col/sc`.
+- Market price oracle has been reporting `0.49–0.51 col/sc` for weeks, in-band with the target.
+
+**Step 1 — t = T + 100s: Oracle bug — `oracle.price` reports `0.0001 col/sc`**
+
+A keeper calls `update_redemption_rate()`. Oracle staleness check passes (timestamp fresh).
+
+- `error = current_redemption_price − oracle.price = 0.5 − 0.0001 ≈ 0.5` (in fixed-point).
+- Controller computes massive negative `rate_adjustment`, hits `RATE_DELTA_CLAMP` floor at −1% per call.
+- `RedemptionPriceState`: rate now `0.99 × FIXED_POINT_ONE` (decay), price anchor rolled forward to `~0.5`, integral term grew negatively, last_updated_at = T + 100.
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `RedemptionPriceState` | redemption_rate_per_second | ~`FIXED_POINT_ONE` | `0.99 × FIXED_POINT_ONE` (clamped) |
+| `RedemptionPriceState` | controller_integral_term | ~0 | large negative (clamped to `-INTEGRAL_CLAMP`) |
+| `RedemptionPriceState` | last_updated_at | T | T + 100 |
+
+The redemption price will now drift DOWN at 1%/second from `~0.5`. After 60 seconds, it's roughly `0.5 × 0.99^60 ≈ 0.27`.
+
+**Step 2 — t = T + 200s: Attacker spots the oracle problem and calls `generate_debt(amount = 1_000_000_000)` against a tiny position**
+
+- Oracle staleness check passes (still fresh, that's the problem).
+- `current_redemption_price ≈ 0.5 × 0.99^100 ≈ 0.37`.
+- Attacker's collateral of, say, 100 atomic units appears to cover `100 / 0.37 / 1.5 ≈ 180` stablecoin debt.
+- The 1_000_000_000 generate_debt would fail collateralization. But a more careful attacker with a more meaningful collateral position could mint a lot more than they otherwise could.
+
+Assume attacker mints 500 stablecoin against 100 collateral (would have failed at the real price of 0.5). At the drifted-down price of 0.37 it passes:
+- Required = 500 × 0.37 × 1.5 = 277. Attacker has 100. **Actually this still fails.**
+
+OK let's say the controller had been hammered harder and redemption_price dropped to `0.1`. Then 500 stablecoin debt requires `500 × 0.1 × 1.5 = 75` collateral. Attacker mints with 75 collateral. ✓ This now passes.
+
+The attacker dumps the 500 stablecoin on the market for collateral, walking away with extra. The protocol is left holding an under-collateralized position whose nominal debt is real but whose collateral is dust.
+
+**Step 3 — t = T + 500s: Freeze authority notices and calls `freeze()`**
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `ProtocolParameters` | is_frozen | false | true |
+
+Effect:
+- `generate_debt` — BLOCKED ✓ (no more attacker mints).
+- `withdraw_collateral` — BLOCKED ✓ (attacker can't pull collateral against the bad price).
+- `deposit_collateral` — allowed (Alice and Bob can shore up).
+- `repay_debt` — allowed (Alice and Bob can deleverage).
+- `close_position` — allowed.
+- `accrue_stability_fee`, `update_redemption_rate` — allowed.
+
+**Step 4 — t = T + 1 hour: Admin calls `set_market_price_oracle(new_good_oracle)`**
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `ProtocolParameters` | market_price_oracle_id | bad_oracle | new_good_oracle |
+
+Validates the new oracle's `base_asset` / `quote_asset` match the stablecoin/collateral. The redemption price isn't reset (no admin escape hatch by design).
+
+**Step 5 — t = T + 2 hours: Freeze authority calls `unfreeze()`**
+
+| Account | Field | Before | After |
+|---|---|---|---|
+| `ProtocolParameters` | is_frozen | true | false |
+
+Trading resumes. A keeper calls `update_redemption_rate()`, which now reads the correct oracle price and slowly walks the redemption rate / price back to a stable regime (the integral term is at the windup clamp, so the controller's recovery is metered, not snappy — accepted trade-off).
+
+**What this scenario doesn't fix:**
+
+The attacker still holds the stablecoin they minted during the attack. The protocol's `Σ(nominal_debt) − total_supply` gap got worse (the position's collateral is now far below what it should be backing). Recovery requires the **liquidation engine of RFP-014** — until that lands, the protocol's accumulated fee credit absorbs the loss, and if it's not enough, the protocol is effectively under-collateralized in aggregate. This is the inherent limit of "freeze without liquidation" — the freeze stops the bleeding mid-attack but doesn't undo prior damage.