diff --git a/stablecoin/docs/README.md b/stablecoin/docs/README.md index 2ff4452..5a0d1fd 100644 --- a/stablecoin/docs/README.md +++ b/stablecoin/docs/README.md @@ -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
= P + new_integral"] IClamp --> RAdj RAdj --> RClamp[clamp ±RATE_DELTA_CLAMP] - RClamp --> NewRate[redemption_rate_per_second
= FIXED_POINT_ONE + rate_adjustment] + RClamp --> NewRate[redemption_rate_per_millisecond
= FIXED_POINT_ONE + rate_adjustment] NewRate -.persist.-> StateNew[(RedemptionPriceState
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 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. | -| `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^6–10^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^3–10^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. 1–7 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
auth] ~~~ a2[protocol_parameters
read] ~~~ a3[stability_fee_accumulator
write] ~~~ a4[redemption_price_state
write] ~~~ a5[market_price_oracle
read] + end + In --> Ins((refresh_globals
best-effort)) + Ins -->|fee interval due| F[accrue — as §10.2] + Ins -->|rate interval due
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.