docs(stablecoin): add stablecoin design docs#141
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a comprehensive design document for the stablecoin on-chain program implementing RFP-013, intended to serve as the authoritative specification for accounts, math, invariants, and the full instruction set prior to implementation.
Changes:
- Introduces a full stablecoin program design spec (architecture, account topology, data structures, math, invariants).
- Documents the complete instruction set with per-instruction inputs/outputs/panics and diagrams.
- Captures operational behaviors (keepers, admin/freeze authority, edge cases) plus forward-integration notes.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - **`generate_debt` rounds UP.** User gets exactly `amount` stablecoins; their nominal debt grows by `≥ amount`. They owe slightly more than they walked away with → protocol's total debt ≥ total supply. | ||
| - **`repay_debt` rounds DOWN.** User burns exactly `amount` stablecoins; their nominal debt shrinks by `≤ amount`. They paid `amount` but debt only dropped by ≤ that → protocol keeps the rounding remainder as fee credit. | ||
|
|
||
| Net effect: the protocol's "implicit fee credit" (`Σ nominal_debt − total_supply`, see §7.7) can only grow over time, never shrink. The integer dust always sticks to the protocol's side. |
|
|
||
| ## 13. Forward integration | ||
|
|
||
| - **RFP-014 (Liquidation & Auction Engine).** Adds an external "liquidator" program that, when a position falls under `minimum_collateralization_ratio`, may seize its collateral and clear its debt. Cleanest fit: a new instruction `liquidate_position` callable by the liquidator program (gated by checking the position's collateralization), or by extending the existing instructions to support a `liquidate_only_path`. Either way, this design's `normalized_debt_amount` and `current_accumulator` carry over directly. Surplus accounting (the gap of § 7.7) materializes here when auctions land. |
| - **Two-step admin / freeze rotation.** `set_admin` / `set_freeze_authority` could grow `pending_*` fields + `accept_*` instructions to protect against typo'd `set_admin`. Not in this RFP. | ||
| - **Promote `INTEGRAL_CLAMP` and `RATE_DELTA_CLAMP` to admin-tunable.** Constants for v1 to keep the surface small. Promotion is additive (new `ProtocolParameters` fields + new admin setters); no migration of existing state. | ||
| - **Liquidation-specific instructions.** Tracked under RFP-014. | ||
| - **Surplus extraction.** When RFP-014 lands, the implicit fee credit (§ 7.7) becomes extractable. May require a one-shot `materialize_surplus` instruction that mints the gap into a designated holding. |
| **Net result over the year:** | ||
|
|
||
| - Alice locked 600 collateral, borrowed 200 stablecoin, repaid 211 stablecoin. Net cost: ~11 stablecoin (the protocol's accrued stability fee). | ||
| - The protocol's "accumulated fee credit" gap (§7.7) grew by ~11 over the year just from Alice's position. Over thousands of positions, this is the fee revenue the protocol implicitly holds. Surplus extraction is a future-RFP capability (§14). |
| **Outputs:** | ||
|
|
||
| - `position` ← `Account::default()` (cleared; PDA released). | ||
| - `vault` unchanged — lingers with `balance = 0` (Token Program has no `CloseHolding`; § 12). |
| - `withdraw_collateral` (§10.6) to take some back — only allowed if the position still meets the safety ratio afterwards. | ||
| - `repay_debt` (§10.8) to burn stablecoin and lower the debt. To pay off accrued interest, users may need to buy extra stablecoin on the market. | ||
|
|
||
| **Closing out.** When a position's debt and collateral are both zero, `close_position` (§10.9) clears the `Position` account so the user can later open a new position with the same nonce. (The vault account stays — see §14.) |
There was a problem hiding this comment.
Please, clarify about nonce reuse. Later 11. edge-case sections says same-nonce reopen fails because the vault PDA lingers. Close position would always free the nonce, or there are some specific casess where it dosent?
|
|
||
| // 4. Compute rate adjustment. | ||
| proportional_term = (params.controller_proportional_gain as i256) * error / FIXED_POINT_ONE | ||
| rate_adjustment = -(proportional_term + new_integral) |
There was a problem hiding this comment.
This whole math should be reviewed, its very confusing.
Line 464 error = current_redemption_price - oracle.price // error > 0 when redemption_price > market_price (protocol's target above market valuation).
467: integral_delta carry this signal as the difference of past integral term;
468: new_integral sums the integral delta
472: proportional_term carry error signal (so its negative when below market valuation, positive when above)
473 rate_adjustment sum prortional_term and the new integral, than inverts signal`
Inverting the signal at rate_adjustment does not make sense for me, because: proportional_term was already on the correct signall; and new_integral is the whole value.
Please verify this logic.
| // Used in every op that needs the up-to-date accumulator / redemption price. | ||
| fn current_accumulated_rate(state: StabilityFeeAccumulator, params: ProtocolParameters, now: u64) -> u128 { | ||
| let dt = now.saturating_sub(state.last_accrued_at); | ||
| let factor = compound_rate(params.stability_fee_per_second, dt); |
There was a problem hiding this comment.
If stability_fee_per_second = FIXED_POINT_ONE * 2, and a keeper asks compound_rate to compute 2^86400, which cannot fit in u128.
Should dt need a max elapsed window? Or some other design?
|
|
||
| **Chained calls:** `Token::Burn { amount_to_burn: amount }` · accounts: `[stablecoin_definition, user_stablecoin_holding (user-authorized)]` · no PDA seeds. | ||
|
|
||
| **Panics if:** `owner.is_authorized = false`; `user_stablecoin_holding.is_authorized = false`; position uninit / wrong owner / PDA mismatch; `stablecoin_definition.account_id ≠ protocol_parameters.stablecoin_definition_id`; user holding's `definition_id` mismatch or different Token Program; overrepay (`⌈amount × FIXED_POINT_ONE / current_accumulator⌉ > position.normalized_debt_amount`). Allowed when frozen. |
There was a problem hiding this comment.
overrepay (⌈amount × FIXED_POINT_ONE / current_accumulator⌉ > position.normalized_debt_amount
On line 896 it uses ⌊, and in here is uses ⌈. Which is the correct formula?
|
|
||
| | # | Instruction | Caller | One-line | | ||
| |---|---|---|---| | ||
| | 1 | `initialize_program` | admin (one-shot) | Create all four global PDAs (three native + stablecoin definition via chained `Token::NewFungibleDefinition`), bind collateral + oracle + initial params, set initial redemption price. | |
There was a problem hiding this comment.
This says four target PDAs, but initialize_program describes five claimed accounts (line 121, 146)
|
|
||
| A walkthrough of who calls what and what changes when. Detailed mechanics are in §10; this is the overall flow. | ||
|
|
||
| **Day 0 — setup.** The deployer publishes the program binary, then calls `initialize_program` (§10.1). That one call creates all five program-owned accounts: the stablecoin `TokenDefinition` (via a chained `Token::NewFungibleDefinition`), its required paired master holding (empty), the `ProtocolParameters` account, and the two state-tracking accounts (`StabilityFeeAccumulator` and `RedemptionPriceState`). The same call sets which collateral and oracle the protocol uses, picks the admin and freeze-authority accounts, and stores the initial fee rate, controller gains, safety ratio, and timing parameters. Once it returns, the protocol exists and users can start interacting. |
There was a problem hiding this comment.
The deployer publishes the program binary, then calls
initialize_program
Leaving this here to make sure we add this to the doc in case it's missing:
initialize_programcan be front-run and we don't haveinitializeAndCall()equivalents (from EVM)- Hence, we'll have to hardcode an authority account that is permitted to initialize the program
- That account cannot be changed (as we don't have an upgrade solution yet) (or, maybe one other solution to this would be to also have a primary and secondary authority account. The secondary is the hardcoded one and will be used when the primary is not set. The primary one will be set by the secondary one, allowing for changing the authority later on.
@3esmit @gravityblast what do you guys think?
There was a problem hiding this comment.
@0x-r4bbit I think a constructor or initialize function is needed in LEZ like in any other smart contract chain. it's even easier then initializeAndCall cause we are still not talking about upgradeability but just initialization on deployment. For this reason I would avoid hardcoding things, I would just expect to be able to call initialize in testnet without front running problems and have initialization built-in in production
There was a problem hiding this comment.
@schouhy Do you think we can have constructor-like capabilities for prod?
| - `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. |
There was a problem hiding this comment.
Maybe we can have a convenience update_rates_and_fees function that does both of these.
|
|
||
| These two are usually run by keeper bots that batch them into one transaction. They cost gas but earn no reward — anyone with money in the protocol wants the globals fresh before they act. | ||
|
|
||
| **A user opens a position and borrows.** A user picks a `position_nonce` (any integer — 0, 7, whatever) and calls `open_position` (§10.4) with some collateral. Two accounts get created: their `Position` (at `hash(owner, nonce)`, owned by the stablecoin program, starting with zero debt) and a `PositionVault` (at `hash(position)`, owned by the Token Program) that holds the collateral tokens. They can then call `generate_debt` (§10.7) to mint stablecoin into their own holding. The mint increases the position's normalized debt; the collateralization check prevents over-borrowing; the call fails if the oracle is stale or the protocol is frozen. |
There was a problem hiding this comment.
A user picks a
position_nonce(any integer — 0, 7, whatever)
This is likely to allow for multiple positions per account (and maybe even becomes part of the PDA).
This paragraph states that it's user controlled and I think that's generally fine as long as there's constraints that an existing nonce hasn't already been used.
However, something has to keep track of the user's nonces somewhere. Maybe we can think of approaches/recommendations on where/how these are tracked.
| - `withdraw_collateral` (§10.6) to take some back — only allowed if the position still meets the safety ratio afterwards. | ||
| - `repay_debt` (§10.8) to burn stablecoin and lower the debt. To pay off accrued interest, users may need to buy extra stablecoin on the market. | ||
|
|
||
| **Closing out.** When a position's debt and collateral are both zero, `close_position` (§10.9) clears the `Position` account so the user can later open a new position with the same nonce. (The vault account stays — see §14.) |
There was a problem hiding this comment.
Note: Define what the constraints are to distinguish between a used and unused vault with the same nonce
|
|
||
| **`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. |
There was a problem hiding this comment.
I think in terms of metadata, we should at least have a name here, no?
| /// Also stored for client discovery (PDA seed isn't reversible). | ||
| pub owner_account_id: AccountId, | ||
| /// User-chosen on `open_position`. Together with owner forms the PDA seed. | ||
| pub position_nonce: u64, |
There was a problem hiding this comment.
This is another such property that, in theory, could be ommitted.
| /// Storing normalized lets one global accumulator update apply interest to every position. | ||
| pub normalized_debt_amount: u128, | ||
| /// Unix seconds when the position was first opened. UX/analytics; not used in protocol logic. | ||
| pub opened_at: u64, |
| /// | ||
| /// O(log seconds_elapsed) — exponentiation by squaring. Uses u256 intermediates. | ||
| /// Same algorithm as MakerDAO / RAI's `rpow`. | ||
| pub fn compound_rate(per_second_rate: u128, seconds_elapsed: u64) -> u128; |
There was a problem hiding this comment.
Should be using ms too?
|
|
||
| The signs work out so that **positive gains drive the system toward stability**. Operators tune gain magnitude; sign is conventional and embedded in the controller, not the gain. | ||
|
|
||
| `INTEGRAL_CLAMP` and `RATE_DELTA_CLAMP` are constants in v1 (§ 8). Promoting them to `ProtocolParameters` admin-tunable fields is a future revision (no on-chain migration needed — additive). |
There was a problem hiding this comment.
How do we decide at what values we're clamping?
There was a problem hiding this comment.
This has been answered later in the document.
|
|
||
| ## 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. |
There was a problem hiding this comment.
What's the idea of this exactly?
No description provided.