docs(stablecoin): add refresh_globals and use milliseconds everywhere

This commit is contained in:
Andrea Franz 2026-06-17 13:39:03 +02:00
parent b6c53e096d
commit 5298c43dad

View File

@ -41,6 +41,7 @@
- [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.3a `refresh_globals`](#103a-refresh_globals-combined-poke)
- [10.4 `open_position`](#104-open_position)
- [10.5 `deposit_collateral`](#105-deposit_collateral)
- [10.6 `withdraw_collateral`](#106-withdraw_collateral)
@ -127,7 +128,7 @@ A walkthrough of who calls what and what changes when. Detailed mechanics are in
- `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.
These two are usually run by keeper bots. A LEZ transaction carries a single instruction, so a keeper that wants to advance both globals at once calls the combined `refresh_globals` poke (§10.3a); otherwise it sends `accrue_stability_fee` and `update_redemption_rate` as two separate transactions. 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.
@ -157,17 +158,17 @@ Five single-instance accounts hold all the globally-shared state. Each is a PDA
*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`.
*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_millisecond`.
**`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.
*Holds:* the redemption price anchor (at the last update), the per-millisecond 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.
*Holds:* the stablecoin's `TokenDefinition::Fungible`its `name`, current `total_supply`, and `metadata_id = None`. The `name` is a first-class field on the definition itself; "no metadata" means no separate `TokenMetadata` account is attached (that account would only carry an off-chain `uri` / `creators` / standard — not a name or symbol — and isn't needed for v1).
*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.
@ -182,11 +183,11 @@ A future Token Program extension (`Token::NewFungibleDefinitionWithoutHolding`,
**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`.
- `StabilityFeeAccumulator``initialize_program`, `accrue_stability_fee`, `refresh_globals` (fee half), `set_stability_fee_per_millisecond` (auto-accrues inline).
- `RedemptionPriceState``initialize_program`, `update_redemption_rate`, `refresh_globals` (redemption half).
- `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.
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. (The combined `refresh_globals` poke (§10.3a) is the deliberate exception — it writes *both* global accounts — but it's a single-keeper convenience; you wouldn't run it concurrently with the individual pokes.)
### 3.2 Per-position accounts (one set per open position)
@ -248,21 +249,21 @@ pub struct ProtocolParameters {
/// `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,
/// Per-millisecond multiplicative stability fee. Stored as `(1 + r_per_millisecond) * FIXED_POINT_ONE`.
/// Updatable by admin via `set_stability_fee_per_millisecond` (auto-accrues first).
pub stability_fee_per_millisecond: 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,
/// Min milliseconds between successful `accrue_stability_fee` calls (spam guard).
pub minimum_milliseconds_between_fee_accruals: u64,
/// Min milliseconds between successful `update_redemption_rate` calls (RFP F2).
pub minimum_milliseconds_between_rate_updates: u64,
/// Reject oracle observations older than this (RFP R3 staleness gate).
pub maximum_oracle_price_age_seconds: u64,
pub maximum_oracle_price_age_milliseconds: 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.
@ -276,9 +277,9 @@ pub struct ProtocolParameters {
#[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)`.
/// Updated on accrual via `anchor * compound_rate(stability_fee_per_millisecond, Δt)`.
pub accumulated_rate_at_last_accrual: u128,
/// Unix seconds of the last accrue. Current accumulator on the read side =
/// Unix milliseconds 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,
}
@ -291,13 +292,13 @@ pub struct StabilityFeeAccumulator {
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`.
/// Per-millisecond 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,
pub redemption_rate_per_millisecond: 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 =
/// Unix milliseconds of the last update. Read-side current price =
/// `anchor * compound_rate(rate, now - last_updated_at) / FIXED_POINT_ONE`.
pub last_updated_at: u64,
}
@ -321,14 +322,14 @@ pub struct Position {
/// **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.
/// Unix milliseconds 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`.
- `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_milliseconds`.
- `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.
@ -350,21 +351,21 @@ The 27-decimal choice matches MakerDAO / RAI's `RAY` precision and gives enough
### 5.2 `compound_rate`
```rust
/// Returns `per_second_rate ^ seconds_elapsed` in fixed point (where `1.0 == FIXED_POINT_ONE`).
/// Returns `per_millisecond_rate ^ milliseconds_elapsed` in fixed point (where `1.0 == FIXED_POINT_ONE`).
///
/// O(log seconds_elapsed) — exponentiation by squaring. Uses u256 intermediates.
/// O(log milliseconds_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;
pub fn compound_rate(per_millisecond_rate: u128, milliseconds_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: callers clamp the elapsed window to `MAXIMUM_COMPOUNDING_WINDOW_SECONDS` before calling (§5.3). This is load-bearing, not decorative: over an *unbounded* window, any `per_second_rate > FIXED_POINT_ONE` eventually overflows `rate ^ seconds_elapsed` regardless of how close to `1.0` it is — the §8 rate bound alone does **not** prevent it.
- `milliseconds_elapsed = 0` → returns `FIXED_POINT_ONE` (identity element).
- `per_millisecond_rate = FIXED_POINT_ONE` → returns `FIXED_POINT_ONE` regardless of `milliseconds_elapsed`.
- `per_millisecond_rate < FIXED_POINT_ONE` → result < `FIXED_POINT_ONE` (compounding decay).
- Overflow guard: callers clamp the elapsed window to `MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS` before calling (§5.3). This is load-bearing, not decorative: over an *unbounded* window, any `per_millisecond_rate > FIXED_POINT_ONE` eventually overflows `rate ^ milliseconds_elapsed` regardless of how close to `1.0` it is — the §8 rate bound alone does **not** prevent it.
**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.
**In plain English:** this is just `rate ^ milliseconds_elapsed` — what a per-millisecond multiplier becomes after that many milliseconds. The naive way is `milliseconds_elapsed` separate multiplications. Exponentiation-by-squaring does it in `log₂(milliseconds_elapsed)` multiplications instead — for a year's worth of milliseconds (~31.5 billion), that's ~35 muls instead of 31.5 billion.
### 5.3 Current value projections (read on the hot path)
@ -375,22 +376,22 @@ fn current_accumulated_rate(state: StabilityFeeAccumulator, params: ProtocolPara
// compound_rate (§5.2, §8). The cap bounds the worst case structurally, independent of
// the rate value. Trade-off: fees stop accruing past the window if NO keeper pokes for
// that long — permissionless, cheap accrue makes this a catastrophic-neglect-only case.
let dt = now.saturating_sub(state.last_accrued_at).min(MAXIMUM_COMPOUNDING_WINDOW_SECONDS);
let factor = compound_rate(params.stability_fee_per_second, dt);
let dt = now.saturating_sub(state.last_accrued_at).min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS);
let factor = compound_rate(params.stability_fee_per_millisecond, dt);
mul_div(state.accumulated_rate_at_last_accrual, factor, FIXED_POINT_ONE)
}
fn current_redemption_price(state: RedemptionPriceState, now: u64) -> u128 {
// Same clamp rationale as above — redemption_rate_per_second can also exceed 1.0.
let dt = now.saturating_sub(state.last_updated_at).min(MAXIMUM_COMPOUNDING_WINDOW_SECONDS);
let factor = compound_rate(state.redemption_rate_per_second, dt);
// Same clamp rationale as above — redemption_rate_per_millisecond can also exceed 1.0.
let dt = now.saturating_sub(state.last_updated_at).min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS);
let factor = compound_rate(state.redemption_rate_per_millisecond, 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).
**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-millisecond 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
@ -463,7 +464,7 @@ Net effect: the protocol's "implicit fee credit" (`Σ nominal_debt total_sup
// 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)
* compound_rate(state.redemption_rate_per_millisecond, dt)
/ FIXED_POINT_ONE
// 2. Compute signed error.
@ -485,12 +486,12 @@ rate_adjustment = proportional_term + new_integral
// Vice versa when above. Matches RAI's PIRawPerSecondCalculator, whose final step is
// redemptionRate = RAY + (Kp·error + Ki·∫) with error = redemptionPrice marketPrice.
// 5. Clamp the per-second adjustment (rate-explosion guard, RFP R2).
// 5. Clamp the per-update rate 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.redemption_rate_per_millisecond = (FIXED_POINT_ONE as i256 + rate_adjustment) as u128
state.controller_integral_term = new_integral
state.last_updated_at = now
```
@ -501,7 +502,7 @@ The signs work out so that **positive gains drive the system toward stability**
**Anti-windup — clamp vs leak (deliberate divergence from RAI).** v1 bounds the integral with a hard clamp (`clamp(new_integral, ±INTEGRAL_CLAMP)`) — conditional integration / saturation. RAI instead uses a *leaky* integrator: it scales the accumulated deviation by a per-second decay factor (`rpow(perSecondCumulativeLeak, Δt)`) before adding the new term, so stale error fades exponentially rather than sitting pinned at a bound. Both are valid anti-windup. v1 picks the clamp because it's simpler, deterministic, and adds no extra parameter; the trade-off is *windup-on-release* — under a long sustained deviation the clamped integral saturates and then unwinds with some lag when the error reverses, whereas the leak never fully saturates. This is a conscious choice, not an oversight; adopting RAI's leak is the planned upgrade once the controller is tuned against simulation (§14, §15).
**In plain English.** The controller steers one number — the **redemption price**, the protocol's official value for the coin — so that the **market price** (from the oracle) is pulled toward it. It never moves the price directly; it sets the *speed and direction* the redemption price changes, the `redemption_rate_per_second` (above `1.0` ⇒ price goes up over time, below `1.0` ⇒ down, exactly `1.0` ⇒ held).
**In plain English.** The controller steers one number — the **redemption price**, the protocol's official value for the coin — so that the **market price** (from the oracle) is pulled toward it. It never moves the price directly; it sets the *speed and direction* the redemption price changes, the `redemption_rate_per_millisecond` (above `1.0` ⇒ price goes up over time, below `1.0` ⇒ down, exactly `1.0` ⇒ held).
It works from the gap, `error = redemption_price market_price` (positive ⇒ coin trading below target, i.e. too cheap), and combines two reactions:
@ -530,7 +531,7 @@ flowchart LR
P --> RAdj["rate_adjustment<br/>= P + new_integral"]
IClamp --> RAdj
RAdj --> RClamp[clamp ±RATE_DELTA_CLAMP]
RClamp --> NewRate[redemption_rate_per_second<br/>= FIXED_POINT_ONE + rate_adjustment]
RClamp --> NewRate[redemption_rate_per_millisecond<br/>= FIXED_POINT_ONE + rate_adjustment]
NewRate -.persist.-> StateNew[(RedemptionPriceState<br/>updated)]
CurRP -.new anchor.-> StateNew
@ -546,26 +547,26 @@ The same few names for the fee/debt machinery recur across §4, §5, and §6. Co
- **`normalized_debt_amount`** — a position's debt with interest stripped out: its "shares." Lives in `Position` (one per `(owner, position_nonce)`). Set on `generate_debt` / `repay_debt`, otherwise unchanged — it is never rewritten as time passes. Units: shares.
- **`accumulated_rate_at_last_accrual`** — the stablecoin-per-share multiplier as of the last accrual (the "anchor"). Lives in the global `StabilityFeeAccumulator`. Starts at `FIXED_POINT_ONE`; only ever grows. Units: stablecoin / share.
- **`last_accrued_at`** — unix-seconds timestamp the anchor was last refreshed. Also in `StabilityFeeAccumulator`. Pairs with the anchor so the live value can be projected.
- **`stability_fee_per_second`** — the per-second growth multiplier applied to the accumulator. Lives in `ProtocolParameters`, stored as `(1 + r) × FIXED_POINT_ONE` (just above 1.0). This is what gets compounded.
- **`last_accrued_at`** — unix-milliseconds timestamp the anchor was last refreshed. Also in `StabilityFeeAccumulator`. Pairs with the anchor so the live value can be projected.
- **`stability_fee_per_millisecond`** — the per-millisecond growth multiplier applied to the accumulator. Lives in `ProtocolParameters`, stored as `(1 + r) × FIXED_POINT_ONE` (just above 1.0). This is what gets compounded.
**Computed on the fly** (never stored; recomputed on read):
- **`current_accumulated_rate`** — the live accumulator now: `accumulated_rate_at_last_accrual × compound_rate(stability_fee_per_second, now last_accrued_at) / FIXED_POINT_ONE` (§5.3). Units: stablecoin / share.
- **`current_accumulated_rate`** — the live accumulator now: `accumulated_rate_at_last_accrual × compound_rate(stability_fee_per_millisecond, now last_accrued_at) / FIXED_POINT_ONE` (§5.3). Units: stablecoin / share.
- **nominal debt** — what a position actually owes now, in stablecoin: `normalized_debt_amount × current_accumulated_rate / FIXED_POINT_ONE` (§6.1). Frozen shares × the live (growing) accumulator, so it grows over time even though the shares don't. Never stored.
**Operations:**
- **`compound_rate(rate, seconds)`** — `rate ^ seconds` in fixed point, via exponentiation-by-squaring (§5.2).
- **`accrue_stability_fee`** — the permissionless "poke" that refreshes the accumulator anchor: rolls the live value into `accumulated_rate_at_last_accrual` and sets `last_accrued_at = now` (§10.2). The only writer of the accumulator besides init and the auto-accrue in `set_stability_fee_per_second`.
- **`compound_rate(rate, millis)`** — `rate ^ millis` in fixed point, via exponentiation-by-squaring (§5.2).
- **`accrue_stability_fee`** — the permissionless "poke" that refreshes the accumulator anchor: rolls the live value into `accumulated_rate_at_last_accrual` and sets `last_accrued_at = now` (§10.2). The only writer of the accumulator besides init and the auto-accrue in `set_stability_fee_per_millisecond`.
- **`FIXED_POINT_ONE`** — the value `1.0` in this system (`10^27`). Every rate / multiplier / price is stored as `actual_value × FIXED_POINT_ONE` (§5.1).
**The redemption-price side mirrors this exactly** — same anchor + per-second-rate + timestamp pattern, projected the same way (§5.3), re-anchored only by its own poke. Different fields:
**The redemption-price side mirrors this exactly** — same anchor + per-millisecond-rate + timestamp pattern, projected the same way (§5.3), re-anchored only by its own poke. Different fields:
| Role | Debt / fee side | Redemption-price side |
|---|---|---|
| anchor | `accumulated_rate_at_last_accrual` | `redemption_price_at_last_update` |
| per-second rate | `stability_fee_per_second` | `redemption_rate_per_second` |
| per-millisecond rate | `stability_fee_per_millisecond` | `redemption_rate_per_millisecond` |
| last-touched timestamp | `last_accrued_at` | `last_updated_at` |
| live projection | `current_accumulated_rate` | `current_redemption_price` |
| re-anchored by | `accrue_stability_fee` (§10.2) | `update_redemption_rate` (§10.3) |
@ -576,9 +577,9 @@ These are properties the protocol maintains across every state-changing instruct
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).
2. **Stability fee accumulator monotonicity.** `accumulated_rate_at_last_accrual` is monotonically non-decreasing while `stability_fee_per_millisecond ≥ FIXED_POINT_ONE` (enforced by `set_stability_fee_per_millisecond`'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.
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_millisecond > 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)`.
@ -595,23 +596,25 @@ These are properties the protocol maintains across every state-changing instruct
| 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 an anti-typo sanity cap (≈100%/s) — it does **not** by itself prevent `compound_rate` overflow; that is handled by clamping the elapsed window (`MAXIMUM_COMPOUNDING_WINDOW_SECONDS`, below). Real values are `1 + ε` where `ε ≈ 10^16` for ~5% annual. |
| `stability_fee_per_millisecond` | `FIXED_POINT_ONE ≤ x ≤ FIXED_POINT_ONE * 2` | Lower bound = no decay (RFP "fees accrue continuously" implies positive rate). Upper bound is an anti-typo sanity cap (≈100%/ms) — it does **not** by itself prevent `compound_rate` overflow; that is handled by clamping the elapsed window (`MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS`, below). Real values are `1 + ε` where `ε ≈ 1.5×10^15` 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 130200%. |
| `controller_proportional_gain` magnitude | `|x| ≤ FIXED_POINT_ONE * 10^6` | Practical upper bound for rate-explosion guard (RFP R2). Real values are tiny (≈10^910^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. |
| `MAXIMUM_COMPOUNDING_WINDOW_SECONDS` | TBD — `≥` the expected worst-case keeper-poke gap (candidate range `86400``604800`) | Hard cap on the `dt` passed to `compound_rate` (§5.3), bounding worst-case `rate ^ dt` regardless of the rate value. Necessary because no rate bound alone makes overflow impossible over an unbounded window. Trade-off: fee accrual and redemption drift pause beyond the window under total keeper neglect. Exact value pending tuning. |
| `controller_proportional_gain` magnitude | `|x| ≤ FIXED_POINT_ONE * 10^3` | Practical upper bound for rate-explosion guard (RFP R2). Real values are tiny (≈10^610^12 raw) because they scale price-error × per-ms rate-output. Rescaled `÷10^3` from the per-second formulation. |
| `controller_integral_gain` magnitude | `|x| ≤ FIXED_POINT_ONE` | As proportional, but rescaled `÷10^6` (it also multiplies `Δt`, now in ms): real values ≈10^310^9 raw. |
| `INTEGRAL_CLAMP` | `± FIXED_POINT_ONE * 10^6` | Anti-windup; rescaled `÷10^3` to ms. Constant in v1; future-promotable to `ProtocolParameters`. |
| `RATE_DELTA_CLAMP` | `± FIXED_POINT_ONE / 100_000` | Max single-update per-ms rate adjustment (≈±0.001%); rescaled `÷10^3` to ms so a ~1/sec keeper cadence still caps near ±1%/s. Constant in v1. |
| `minimum_milliseconds_between_fee_accruals` | `1 ≤ x ≤ 86_400_000` | Min 1 ms (no zero-spam), max 1 day (RFP "rate updates within a small number of blocks"). |
| `minimum_milliseconds_between_rate_updates` | Same | Same. |
| `maximum_oracle_price_age_milliseconds` | `1 ≤ x ≤ 86_400_000` | Stale beyond a day is obviously bad; aggressive freshness is a tuning parameter. |
| `MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS` | TBD — `≥` the expected worst-case keeper-poke gap (candidate range `86_400_000``604_800_000`, i.e. 17 days) | Hard cap on the `dt` passed to `compound_rate` (§5.3), bounding worst-case `rate ^ dt` regardless of the rate value. Necessary because no rate bound alone makes overflow impossible over an unbounded window. Trade-off: fee accrual and redemption drift pause beyond the window under total keeper neglect. Exact value pending tuning. |
| `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.
**Time-unit note (milliseconds).** The runtime clock is in **milliseconds**, so every timestamp (`opened_at`, `last_accrued_at`, `last_updated_at`), the interval bounds, the per-millisecond rates, and the gain/clamp magnitudes above are all ms-denominated. The gains and clamps were **rescaled directly to per-millisecond terms** by deterministic factors — `Kp ÷10^3`, `Ki ÷10^6` (it also multiplies `Δt`, now 1000× larger), `INTEGRAL_CLAMP ÷10^3`, `RATE_DELTA_CLAMP ÷10^3` — which exactly preserves the per-second behavior they encoded. This is a units rescale, **not** validation: those magnitudes were always v1 estimates, so the final values are locked against simulation in the §15 tuning pass — a step that exists independently of the time unit. The interval bounds are exact.
## 9. Instruction set
18 instructions in 5 groups. Full per-instruction details in § 10.
19 instructions in 5 groups (the combined poke `refresh_globals` is numbered 3a, grouped with the other two pokes). Full per-instruction details in § 10.
### 9.1 Bootstrap
@ -625,6 +628,7 @@ All bounds enforced in `initialize_program` and the corresponding `set_*` instru
|---|---|---|---|
| 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`. |
| 3a | `refresh_globals` | anyone | Best-effort combined poke: do #2 and #3, each only if its interval is due (and the oracle fresh for #3); skip rather than panic. Advances both globals in one transaction (§10.3a). |
### 9.3 Position lifecycle
@ -641,7 +645,7 @@ All bounds enforced in `initialize_program` and the corresponding `set_*` instru
| # | Instruction | Caller | One-line |
|---|---|---|---|
| 10 | `set_stability_fee_per_second` | admin | Auto-accrues first; then updates the rate. |
| 10 | `set_stability_fee_per_millisecond` | 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. |
@ -667,13 +671,13 @@ For each instruction below: **inputs** (accounts) with pre-state expectations an
```rust
fn initialize_program(
freeze_authority_account_id: AccountId,
initial_stability_fee_per_second: u128,
initial_stability_fee_per_millisecond: 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,
minimum_milliseconds_between_fee_accruals: u64,
minimum_milliseconds_between_rate_updates: u64,
maximum_oracle_price_age_milliseconds: u64,
initial_redemption_price: u128,
stablecoin_name: String,
);
@ -694,7 +698,7 @@ fn initialize_program(
- `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`.
- `redemption_price_state` (claimed PDA): `redemption_price_at_last_update = initial_redemption_price`, `redemption_rate_per_millisecond = 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.
@ -731,12 +735,12 @@ flowchart TD
**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.accumulated_rate_at_last_accrual``anchor × compound_rate(stability_fee_per_millisecond, 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` (prevented by the `MAXIMUM_COMPOUNDING_WINDOW_SECONDS` clamp of §5.3 — without it a within-bounds-but-high rate over a long `dt` would overflow).
**Panics if:** `caller.is_authorized = false`; either global uninitialized; `now last_accrued_at < minimum_milliseconds_between_fee_accruals`; overflow in `compound_rate` (prevented by the `MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS` clamp of §5.3 — without it a within-bounds-but-high rate over a long `dt` would overflow).
```mermaid
flowchart TD
@ -765,7 +769,7 @@ flowchart TD
**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`.
**Panics if:** `caller.is_authorized = false`; any input uninitialized / wrong owner; oracle id mismatch; `now oracle.timestamp > maximum_oracle_price_age_milliseconds`; `oracle.price = 0`; `now last_updated_at < minimum_milliseconds_between_rate_updates`.
```mermaid
flowchart TD
@ -779,6 +783,47 @@ flowchart TD
Ins --> Post
```
### 10.3a `refresh_globals` (combined poke)
**Signature:** `fn refresh_globals();`
A convenience poke that advances **both** globals in one instruction. Because a LEZ transaction carries a single instruction, this is the only way to refresh the fee accumulator and the redemption rate in one transaction. It is **best-effort**: it performs each half only if that half is due, and **skips rather than panics** when a half isn't due (or the oracle is stale). The standalone `accrue_stability_fee` (§10.2) and `update_redemption_rate` (§10.3) remain for callers that want a single global or strict (loud-failing) semantics — see "Why keep all three" below.
**Inputs (5 accounts)** — the union of §10.2 and §10.3:
1. `caller` — authorized.
2. `protocol_parameters` — initialized, read-only.
3. `stability_fee_accumulator` — initialized, writable.
4. `redemption_price_state` — initialized, writable.
5. `market_price_oracle` — initialized, read-only. Must equal `protocol_parameters.market_price_oracle_id`.
**Behavior:**
- **Fee half** — if `now stability_fee_accumulator.last_accrued_at ≥ minimum_milliseconds_between_fee_accruals`: roll the accumulator forward exactly as §10.2. Else: skip (no write). Never depends on the oracle.
- **Redemption half** — if `now redemption_price_state.last_updated_at ≥ minimum_milliseconds_between_rate_updates` **and** the oracle is fresh (`now oracle.timestamp ≤ maximum_oracle_price_age_milliseconds`) **and** `oracle.price > 0`: run the PI controller and re-anchor exactly as §10.3. Else: skip (no write).
- If both halves skip, the call is a successful no-op.
**Chained calls:** none.
**Panics if:** `caller.is_authorized = false`; any of `protocol_parameters` / `stability_fee_accumulator` / `redemption_price_state` uninitialized or wrong owner; `market_price_oracle.account_id ≠ protocol_parameters.market_price_oracle_id`. It does **NOT** panic on "interval not yet elapsed" or "oracle stale / zero" — those conditions skip the corresponding half. Allowed when frozen (like the individual pokes).
**Why keep all three (independent need):**
- **`accrue_stability_fee` alone** — takes a *smaller* account set (no oracle at all) and is guaranteed oracle-independent: the canonical path during an oracle outage. It is also strict — it panics if called before its interval, so a keeper can detect "too early."
- **`update_redemption_rate` alone** — updates the redemption rate *without* touching the fee accumulator, and is strict: it panics on a stale / zero oracle, which a keeper may *want* as an explicit failure signal rather than a silent skip.
- **`refresh_globals`** — the common-case convenience: one transaction advances both, best-effort so it never fails just because one half isn't due. Trades strictness for "always make whatever progress is available."
```mermaid
flowchart TD
subgraph In["Inputs (5)"]
a1[caller<br/>auth] ~~~ a2[protocol_parameters<br/>read] ~~~ a3[stability_fee_accumulator<br/>write] ~~~ a4[redemption_price_state<br/>write] ~~~ a5[market_price_oracle<br/>read]
end
In --> Ins((refresh_globals<br/>best-effort))
Ins -->|fee interval due| F[accrue — as §10.2]
Ins -->|rate interval due<br/>AND oracle fresh| R[update redemption — as §10.3]
Ins -.->|neither due| N[successful no-op]
```
### 10.4 `open_position`
**Signature:** `fn open_position(position_nonce: u64, initial_collateral_amount: u128);`
@ -917,7 +962,7 @@ flowchart TD
**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.
**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_milliseconds`; `protocol_parameters.is_frozen = true`; collateralization check (§ 6.2) fails post-mint; arithmetic overflow.
```mermaid
flowchart TD
@ -1015,18 +1060,18 @@ Every settable thing in the protocol, what it starts as, and who/how it can chan
| `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) |
| `stability_fee_per_millisecond` | param to init | yes — `set_stability_fee_per_millisecond` (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) |
| `minimum_milliseconds_between_fee_accruals` | param to init | yes — `set_timing_parameters` (bundled) |
| `minimum_milliseconds_between_rate_updates` | param to init | yes — `set_timing_parameters` (bundled) |
| `maximum_oracle_price_age_milliseconds` | 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) |
| `redemption_rate_per_millisecond` | 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` |
| `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_millisecond` |
| `stablecoin name` | param to init | **NO — immutable** (lives in `TokenDefinition::Fungible`; no token-program setter) |
**What admin CAN do:**
@ -1067,11 +1112,11 @@ All seven share the same skeleton:
| # | 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. |
| 10 | `set_stability_fee_per_millisecond` | `new_rate: u128` | `stability_fee_per_millisecond` | `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. |
| 14 | `set_timing_parameters` | three `u64`s | `minimum_milliseconds_between_fee_accruals`, `minimum_milliseconds_between_rate_updates`, `maximum_oracle_price_age_milliseconds` | — | 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. |
@ -1113,11 +1158,11 @@ flowchart TD
## 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)), but the result is NOT self-bounding: at extreme `dt` any rate > `FIXED_POINT_ONE` overflows. Callers therefore clamp `dt` to `MAXIMUM_COMPOUNDING_WINDOW_SECONDS` (§5.3). The trade-off is that fee accrual and redemption drift pause beyond the window if no one pokes for that long; permissionless, cheap pokes make this a catastrophic-neglect-only scenario.
- **Long time since last poke.** `compound_rate` handles large `dt` via exponentiation by squaring (O(log dt)), but the result is NOT self-bounding: at extreme `dt` any rate > `FIXED_POINT_ONE` overflows. Callers therefore clamp `dt` to `MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS` (§5.3). The trade-off is that fee accrual and redemption drift pause beyond the window if no one pokes for that long; permissionless, cheap pokes make this a catastrophic-neglect-only scenario.
- **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.
- **Position re-open at same nonce after close.** Fails — the vault PDA at `hash(position_id)` lingers from before. By design — pick a fresh nonce (intentional limitation). 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.
@ -1140,7 +1185,7 @@ flowchart TD
## 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.
- **Reusing a position nonce after close (accepted limitation — not planned).** `close_position` clears the `Position` but can't clear the vault: the Token Program has no `Token::CloseHolding`, so the empty vault `TokenHolding` lingers at `hash(position_id)` and blocks reopening at the same `(owner, position_nonce)`. Accepted answer: **pick a fresh nonce** (the space is `u64`). A `Token::CloseHolding` primitive would let `close_position` clear the vault and make reuse possible, but nonce reuse alone doesn't justify a shared Token-Program change, and the only other cost — empty accounts accumulating on-chain — only matters if LEZ ever charges for account storage or locks a creation deposit. Revisit only if that becomes a real problem; not tracked as active work.
- **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.
- **Leaky integrator (RAI parity).** v1 uses a hard `INTEGRAL_CLAMP` for anti-windup (§6.4); RAI uses a per-second integral *leak* (`perSecondCumulativeLeak`) that exponentially decays stale deviation. Switching to the leak — better dynamics under sustained deviations, at the cost of a new tunable parameter (with its own §8 bound) and a change to the §6.4 integral math — is deferred to the controller-tuning pass (§15). Deliberate divergence, not an oversight.
@ -1159,7 +1204,7 @@ This design is the input to the writing-plans skill. The plan should:
## 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`).
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`). All times are in **milliseconds** (the runtime clock unit): where a step is labelled "`t = 10s`" or "one year", that's elapsed wall-clock, and the underlying `*_at` fields store the equivalent millisecond count (e.g. `t = 10s` → timestamp `10_000`).
### 16.1 Alice's full lifecycle
@ -1167,9 +1212,9 @@ Alice opens a collateralized position, borrows stablecoin, holds for ~1 year, ac
**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.
- `ProtocolParameters`: `stability_fee_per_millisecond` 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`.
- `RedemptionPriceState`: `redemption_price_at_last_update = 0.5 × FIXED_POINT_ONE` (collateral-per-stablecoin), `redemption_rate_per_millisecond = 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)`**
@ -1270,11 +1315,11 @@ A keeper calls `update_redemption_rate()`. Oracle staleness check passes (timest
| Account | Field | Before | After |
|---|---|---|---|
| `RedemptionPriceState` | redemption_rate_per_second | ~`FIXED_POINT_ONE` | `0.99 × FIXED_POINT_ONE` (clamped) |
| `RedemptionPriceState` | redemption_rate_per_millisecond | ~`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`.
The redemption price will now drift DOWN at 1%/ms from `~0.5`. After 60 ms, 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**
@ -1326,20 +1371,20 @@ The attacker still holds the stablecoin they minted during the attack. The proto
### 16.3 Redemption price & controller walkthrough
One `update_redemption_rate` computing a new rate from the price gap (§6.4), then how `current_redemption_price` is projected at later times (§5.3), then the next update re-anchoring. As in §16's preamble, magnitudes are illustrative; here the per-second rate is **deliberately exaggerated** (real values are `1.0000001`/s) so the arithmetic is legible. On-chain every value is also × `FIXED_POINT_ONE`.
One `update_redemption_rate` computing a new rate from the price gap (§6.4), then how `current_redemption_price` is projected at later times (§5.3), then the next update re-anchoring. As in §16's preamble, magnitudes are illustrative; here the per-millisecond rate is **deliberately exaggerated** (real values are far closer to `1.0`) so the arithmetic is legible. On-chain every value is also × `FIXED_POINT_ONE`.
**Constants (illustrative):** `Kp = 0.4`, `Ki = 0.0001`.
**Start (t = 1000s):**
**Start (t = 1000 ms):**
| Field | Value |
|---|---|
| `redemption_price_at_last_update` | `0.50` |
| `redemption_rate_per_second` | `1.00` (held) |
| `redemption_rate_per_millisecond` | `1.00` (held) |
| `last_updated_at` | `1000` |
| `controller_integral_term` | `0` (memory starts at 0) |
**Step 1 — a keeper calls `update_redemption_rate()` at t = 2000s**
**Step 1 — a keeper calls `update_redemption_rate()` at t = 2000 ms**
1. Project the current price from the OLD rate: `Δt = 2000 1000 = 1000`; `current_redemption_price = 0.50 × compound_rate(1.00, 1000) = 0.50 × 1.00 = 0.50` (rate was 1.0, so no change).
2. Read the oracle: market price = `0.48`.
@ -1349,13 +1394,13 @@ One `update_redemption_rate` computing a new rate from the price gap (§6.4), th
- `integral_delta = Ki × error × Δt = 0.0001 × 0.02 × 1000 = 0.002`
- `new_integral = 0 + 0.002 = 0.002` (within `INTEGRAL_CLAMP`)
- `rate_adjustment = P + new_integral = 0.008 + 0.002 = 0.010`
5. New rate: `redemption_rate_per_second = 1.0 + 0.010 = 1.01`.
5. New rate: `redemption_rate_per_millisecond = 1.0 + 0.010 = 1.01`.
6. Persist (the anchor, rate, and memory all change here, and only here):
| Field | Before | After |
|---|---|---|
| `redemption_price_at_last_update` | `0.50` | `0.50` (the value from step 1) |
| `redemption_rate_per_second` | `1.00` | `1.01` |
| `redemption_rate_per_millisecond` | `1.00` | `1.01` |
| `controller_integral_term` | `0` | `0.002` |
| `last_updated_at` | `1000` | `2000` |
@ -1375,7 +1420,7 @@ Coin was too cheap ⇒ rate set above `1.0` ⇒ redemption price will rise, pull
None of these are saved — all are computed from the same stored `(0.50, 1.01, 2000)`. `compound_rate` does `1.01^10` in ~4 multiplications via exponentiation-by-squaring: `1.01^2 = 1.0201`, `1.01^4 = 1.040604`, `1.01^8 = 1.082857`, `1.01^10 = 1.01^8 × 1.01^2 ≈ 1.104622`.
**Step 3 — the next `update_redemption_rate()` at t = 2010s** (re-anchors at the live value, carries the memory forward):
**Step 3 — the next `update_redemption_rate()` at t = 2010 ms** (re-anchors at the live value, carries the memory forward):
1. Current price from the current rate: `0.50 × 1.01^10 = 0.552311`.
2. Oracle (market has responded): `0.545` — the gap shrank from `0.48` to `0.545`.
@ -1390,4 +1435,4 @@ None of these are saved — all are computed from the same stored `(0.50, 1.01,
Two things to notice: the current gap is now small, so `P` dropped sharply (`0.008 → 0.0029`); but the **memory still holds `0.002`** from the earlier sustained gap, keeping part of the correction alive even though the current gap is nearly closed — that is exactly the integral's job. As the market reaches the target, `error → 0`, both terms stop adding, the rate settles to `1.0`, and the redemption price holds.
**One-line summary:** `current_redemption_price` at any time = `redemption_price_at_last_update × redemption_rate_per_second ^ (now last_updated_at)`. Those three saved numbers change only when `update_redemption_rate` runs (which also updates `controller_integral_term`, starting at `0` and remembering past gaps). `Kp` / `Ki` are fixed dials. Between updates, raise the rate to the elapsed seconds and multiply by the anchor.
**One-line summary:** `current_redemption_price` at any time = `redemption_price_at_last_update × redemption_rate_per_millisecond ^ (now last_updated_at)`. Those three saved numbers change only when `update_redemption_rate` runs (which also updates `controller_integral_term`, starting at `0` and remembering past gaps). `Kp` / `Ki` are fixed dials. Between updates, raise the rate to the elapsed milliseconds and multiply by the anchor.