Skip to content

docs(stablecoin): add stablecoin design docs#141

Open
gravityblast wants to merge 2 commits into
mainfrom
stablecoin-design
Open

docs(stablecoin): add stablecoin design docs#141
gravityblast wants to merge 2 commits into
mainfrom
stablecoin-design

Conversation

@gravityblast

Copy link
Copy Markdown
Collaborator

No description provided.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread stablecoin/docs/README.md Outdated
Comment thread stablecoin/docs/README.md Outdated
- **`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.
Comment thread stablecoin/docs/README.md Outdated

## 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.
Comment thread stablecoin/docs/README.md Outdated
- **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.
Comment thread stablecoin/docs/README.md Outdated
**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).
Comment thread stablecoin/docs/README.md Outdated
Comment thread stablecoin/docs/README.md Outdated
**Outputs:**

- `position` ← `Account::default()` (cleared; PDA released).
- `vault` unchanged — lingers with `balance = 0` (Token Program has no `CloseHolding`; § 12).
Comment thread stablecoin/docs/README.md Outdated
Comment thread stablecoin/docs/README.md
- `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.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread stablecoin/docs/README.md

// 4. Compute rate adjustment.
proportional_term = (params.controller_proportional_gain as i256) * error / FIXED_POINT_ONE
rate_adjustment = -(proportional_term + new_integral)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread stablecoin/docs/README.md
// 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread stablecoin/docs/README.md

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread stablecoin/docs/README.md

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says four target PDAs, but initialize_program describes five claimed accounts (line 121, 146)

Comment thread stablecoin/docs/README.md

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_program can be front-run and we don't have initializeAndCall() 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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@schouhy Do you think we can have constructor-like capabilities for prod?

Comment thread stablecoin/docs/README.md
- `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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can have a convenience update_rates_and_fees function that does both of these.

Comment thread stablecoin/docs/README.md

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread stablecoin/docs/README.md
- `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.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Define what the constraints are to distinguish between a used and unused vault with the same nonce

Comment thread stablecoin/docs/README.md

**`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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in terms of metadata, we should at least have a name here, no?

Comment thread stablecoin/docs/README.md
/// 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is another such property that, in theory, could be ommitted.

Comment thread stablecoin/docs/README.md
/// 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ms

Comment thread stablecoin/docs/README.md
///
/// 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be using ms too?

Comment thread stablecoin/docs/README.md

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we decide at what values we're clamping?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been answered later in the document.

Comment thread stablecoin/docs/README.md

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the idea of this exactly?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants