# Hipo Architecture This document explains how the Hipo contracts work together and which invariants any change must preserve. It complements the message-flow diagrams in `graphs/` (one per flow, start with `00-legend.dot`) and the TL-B schemas in `contracts/schema.tlb` and `docs/integration.md`. Terminology: GRAM is the network coin (rebranded from TON), hGRAM is Hipo's jetton (rebranded from hTON). The network itself is still called the TON blockchain. ## The big picture Stakers deposit GRAM into the **treasury** and receive **hGRAM** jettons. The treasury lends the pooled GRAM to **borrowers** (validator node operators) each validation round through per-round **loan** contracts on the masterchain. Borrowers stake the loans in the Elector, earn validation rewards, and the rewards are split between the borrower, the protocol (governance fee), and the pool. Stakers' rewards are not paid out directly: they accrue in the exchange rate, because rewards increase `total_coins` while `total_tokens` stays fixed. The exchange rate is the core accounting identity: - Minting a deposit: `tokens = coins * total_tokens / total_coins` - Burning on unstake: `coins = tokens * total_coins / total_tokens` Coins in flight are tracked separately (`total_staking` for pending deposits, `total_unstaking` for pending withdrawals) and only enter `total_coins`/`total_tokens` at the moment tokens are actually minted or burned. **Dead shares** keep both totals permanently positive: the treasury starts with `total_coins = total_tokens = 10 GRAM` of shares owned by no wallet (on mainnet they were minted at the current rate by the dead-shares migration — see `scripts/upgrade_treasury.md`). Because burns can only originate from wallet-held tokens, the dead shares can never be burned, so no rate computation needs a zero-guard and there is no "last staker" special case — rounding dust on the final unstake stays in the pool. The backing 10 GRAM is aliased with the storage buffer (`fee::treasury_storage`): the `available_ton` formulas subtract the buffer, which keeps the dead backing unlendable and unpayable, while `calculate_min_coins` counts it through `total_coins` (not separately), so the governor cannot withdraw it as surplus. Dead shares also close the first-depositor inflation attack: donating via `gift_coins` accrues pro-rata to the dead shares, so inflating the rate strictly loses money (see `docs/specs/2026-07-18-mint-dead-shares.md`). The parent's jetton supply counts only wallet-held tokens and therefore stays below the treasury's `total_tokens` by the dead amount. ## Contracts | Contract | Chain | Instances | Role | | --- | --- | --- | --- | | `treasury.fc` | basechain | 1 | Pool accounting, loan lifecycle, round participation, governance | | `parent.fc` | basechain | 1 (upgradable via `old_parents`) | Jetton master; proxies all wallet ↔ treasury traffic | | `wallet.fc` | basechain | 1 per user | TEP-74-style jetton wallet with extra fields for staking/unstaking in progress | | `loan.fc` | masterchain | 1 per borrower per round | Holds a loan; can only send stakes to the Elector, so borrowers cannot withdraw loans | | `collection.fc` | basechain | 1 per round | NFT collection of that round's bills; fans out `burn_all` at round end | | `bill.fc` | basechain | 1 per deferred operation | SBT (non-transferable NFT) recording a pending deposit or unstake: amount, owner, and direction | | `librarian.fc` | masterchain | 1 | Deploys and pays storage for shared library cells used by wallet/loan/bill code | Code cells for loan, collection, and bill are versioned per round in the treasury (`loan_codes`, `collection_codes`, `bill_codes` dictionaries keyed by `round_since`), so old rounds keep resolving their historical addresses after a code upgrade. ## Validation rounds and the participation state machine TON validation rounds are consecutive, but their lifecycles overlap: while round *N* is validating, the election for round *N+1* runs, and round *N*'s stakes stay frozen in the Elector for `stake_held_for` after round *N* ends. The treasury therefore keeps up to three `participations` at once, keyed by `round_since` (the unix time the round's validator set takes effect) — informally the "odd" and "even" chains of rounds. Each participation moves through these states (`participation::*` in `imports/constants.fc`): 1. **open (0)** — created by the first `request_loan` for the upcoming round; borrowers' requests are collected and sorted. 2. **distributing (1)** — entered by `participate_in_election` once the election window opens. `distribute` snapshots the available balance *in the same transaction* and decides which loan requests to accept (`decide_loan_requests` / `process_loan_requests`). 3. **staked (2)** — accepted loans have been sent through loan contracts to the Elector. 4. **validating (3)** — `vset_changed` observed the round begin. 5. **held (4)** — the next `vset_changed`; the round is over but stakes are frozen. 6. **recovering (5)** — after `stake_held_until`, `finish_participation` triggers `recover_stakes`; each `recover_stake_result` books rewards or punishments. 7. **ready_to_burn (6)** — the last loan of this round is recovered and its rewards are in `total_coins`. The round holds its bills here for as long as any *older* round can still book rewards, so that deferred deposits cannot mint at a rate which excludes them. 8. **burning (7)** — no older round owes rewards any more, so `burn_all` is sent to the round's collection. This is also where the published rate window is measured — see *The rate window* below. Every bill burns back into the treasury (`mint_tokens` / `burn_tokens`), and `last_bill_burned` deletes the participation. `vset_changed` is driven by config parameter changes (elector validator-set updates), and each stage has a governance-triggerable retry (`retry_distribute`, `retry_recover_stakes`, `retry_burn_all`, `retry_mint_bill`) in case a message is lost. `retry_burn_all` also accepts a round in `ready_to_burn`, which is the escape hatch when an older round is stuck and would otherwise hold that round's bills forever. ### Loan economics `distribute` limits how much can be lent in one round via `rounds_imbalance`, so one of the two round chains cannot starve the other. Borrowers post their own stake alongside the loan (`total_borrowers_stake`); on recovery, the reward is split by `borrower_reward_share` (out of 65535), the treasury's share pays `governance_fee` (out of 65535) to the governor, and the remainder increases `total_coins` for all hGRAM holders. Losses are deducted from the borrower's own stake first — stakers are only exposed after the borrower's stake is exhausted. ### The borrower fee `borrower_fee` (out of 65535) charges the borrower a share of their **contractual** reward, `reward * borrower_reward_share / 65535`, and sends it in GRAM to the hardcoded `burner` address, whose proceeds buy HPO on the open market and burn it. See `docs/specs/2026-08-31-borrower-fee-hpo-burn.md`. Three properties are worth knowing before changing anything near it: - **It is charged on top of the pool's take, never carved out of it.** The fee comes out of `stake_amount` — the borrower's own funds — so `treasury_reward`, `new_coins` and the exchange rate are untouched. This is what distinguishes it from `governance_fee`, which reduces what reaches stakers. Priority on recovery is punishment, then the pool, then the burner, then the borrower. - **The base is the contractual share, not the realised one.** Bidding `min_payment` at or above the reward drives a borrower's *realised* take to zero through the `max(min_payment, ...)` clamp, but not their contractual share, so that route pays the fee from collateral rather than escaping it. Basing it on `treasury_reward` instead would close one more case — `borrower_reward_share = 0` — but would impose a hard ceiling of `share/(255-share)`, putting every useful rate in a sliver at the bottom of the range. On the contractual share the parameter is self-limiting: 65535 takes the whole reward and no collateral. `fee::min_burn` (1 GRAM) is the floor that keeps a zero-share bid paying something. - **The rate is snapshotted into each request.** `borrower_fee` is read at recovery, but from the request, not the extension — so `set_borrower_fee` cannot reprice a committed loan. There is no window in which no participation is mid-flight, so this had to be structural rather than a matter of timing the governance call. ## Deposit flow (`deposit_coins`) See `graphs/02.*`. The deposit fee is dynamic (`get_treasury_fees`); the deposited amount is reserved and the remainder returned as gas excess. A deposit that would mint zero tokens at the current exchange rate (dust below one token nano-unit) is rejected with `err::deposit_too_small` before any state change, so it cannot become a silent donation to the pool. - **`instant_mint = true`**: tokens are minted immediately through the parent at the current rate. - **`instant_mint = false`** (production): the deposit is recorded as a bill on a round's collection, `total_staking` increases, and tokens are minted only when that round finishes (its `burn_all`). The round is chosen as the **latest non-open participation** (state strictly between `open` and `burning`); if none exists, the mint is instant. The invariant behind that choice: **a deposit's tokens must not exist until the rewards of every round whose loans were committed before the deposit are reflected in the exchange rate.** The latest non-open participation is exactly the latest round with already-committed loans, so minting after it is both correct and the minimal delay — as long as rounds burn in `round_since` order. That ordering is **enforced, not assumed**: a round which settles while an older round can still book rewards waits in `ready_to_burn`, and `burn_ready_participations` releases settled rounds in ascending `round_since`, stopping at the first round that has not settled yet. Without that barrier an Elector rejection can finish a later round within seconds, ahead of an older round that is still validating, and that later round's deferred deposits would mint at a stale rate and capture rewards earned before the deposit was even made. Choosing the "currently validating" round instead would be wrong: in the window where the next round is already `staked` but not yet begun, it would let a depositor capture a full round of rewards their coins never took part in. The conservative direction is intentional — a depositor may occasionally wait longer or sit unlent for a round, but can never collect unearned rewards. Note that pending deposits sit in the treasury balance and *are* lendable in subsequent rounds (only rounds starting after the deposit), which is consistent with the invariant: the depositor's tokens exist before any of those rounds' rewards land. ## Unstake flow (`unstake_tokens` → `reserve_tokens`) See `graphs/03.*`. Unstaking starts as a TEP-74 `burn` on the user's wallet, with an optional custom payload selecting a mode (`unstake::*`): - **auto (0)**: instant if the treasury has enough liquid GRAM, otherwise deferred via a bill to the end of the round. - **instant (1)**: instant or rolled back. - **best (2)**: always deferred to the end of the round, maximizing earned rewards. Deferred unstakes reserve the tokens (`total_unstaking`), mint a bill on the **earliest** non-open participation (so payout happens at the first opportunity), and pay out GRAM at the rate current when the bill burns. ## Governance and operations Two privileged roles live in the treasury extension: the **governor** (parameter changes, upgrades, surplus withdrawal, profit recipient) and the **halter** (emergency stop). Key operations, each with a graph and a script in `scripts/`: - Governor handover is two-step with a 24-hour delay (`propose_governor` → `accept_governance`). - `set_stopped` halts new deposits; `set_instant_mint` toggles deferred minting; `set_governance_fee`, `set_borrower_fee` and `set_rounds_imbalance` tune economics. - Upgrades: `upgrade_code` for the treasury itself (see `scripts/upgrade_treasury.md` for the procedure), `proxy_upgrade_code` for the parent, and per-user wallet upgrades (`send_upgrade_wallet` / `migrate_wallet`) with `old_parents` allowing balances to merge from a previous parent. - `gift_coins` donates GRAM to the pool (raises the rate for everyone). ### Repairing a wedged burn chain A round's bills burn as a chain: the collection sends `burn_bill` to bill *i*, and only the returning `bill_burned` drives `burn_next` on to *i+1*. Nothing else ever advances it, so if one link fails to answer, the round stops in `burning` with `total_staking` / `total_unstaking` still counting the bills that never settled. A link can fail because the bill was already burned (`burn_bill` throws `err::stopped` on a second try), because it was counted in `next_item_index` but its `assign_bill` aborted so the account was never initialised, because the collection's own `bill_burned` transaction aborted after the bill had already committed `revoked_at`, or because the bill was reaped for storage after an earlier burn. In every case the message is non-bounceable, so the collection is never told. A wedged round holds nothing else back — `burning` satisfies neither `owes_reward?` nor `holds_bills?`, so later rounds proceed normally — but it occupies a participation slot and its bills can never be paid. Repair it in this order: 1. Read `next_item_index` from the collection's `get_collection_data`, and walk `get_nft_address_by_index` to find the first bill that is uninitialised, or revoked but never settled. 2. Confirm from the treasury's history whether that bill's `mint_tokens` / `burn_tokens` ever arrived. `revoked_at` proves the bill burned; it does **not** prove the treasury settled it. 3. If it never settled, re-create it with `retryMintBill.ts` using the original `(round_since, amount, unstake?, owner, parent)` — **first**, while the round is still `burning`. `retry_mint_bill` accepts that state precisely so the hole can be patched before `last_bill_burned` deletes the participation and strands it. It appends at `next_item_index`, which is fine: settlement is keyed by the owner and amount recorded on the bill, not by its index. 4. Only then run `retryBurnAll.ts` with `start_index = i + 1`. Never restart a partially burned round at index 0: bills that already burned throw, and bills reaped for storage swallow the message, wedging the chain again. Never pass a `start_index` at or past `next_item_index` — that fires `last_bill_burned` immediately and deletes the participation with every remaining bill unburned. The script refuses both. Worth monitoring: a participation sitting in `burning` whose collection has seen no activity for several blocks. That alert is what turns this from a silent loss into a repair. ### Bypassing the reward-ordering barrier `burn_ready_participations` releases settled rounds in ascending `round_since` order and stops at the first round that still owes a reward (`owes_reward?`, states 1–5). That barrier is what deferred minting (`instant_mint = false`) depends on: it stops a later round's deferred deposits from minting at a rate that has not yet booked an older round's reward and so would capture part of it. `retry_burn_all` deliberately steps over that barrier when it is run on a round in `ready_to_burn`, because its job is to unstick a round that is permanently stuck and would otherwise hold every later round's bills forever. Overriding the barrier is safe exactly when the older round can no longer book anything; it is unsafe when the older round is merely still validating, because bypassing the barrier there reopens the same ordering hole it exists to close. > While `instant_mint` is `false`, do not run `retry_burn_all` on a round in `ready_to_burn` > without first confirming that no lower `round_since` is still in states 1–5 > (`owes_reward?`). Check this with `scripts/showState.ts` before retrying: every round below the one being released must be either absent from `participations` or already past `recovering`. `retry_mint_bill` also accepts a round that is already `burning`, but for an unrelated reason — that is the repair window described above, not an override of this barrier. A round only reaches `burning` once its own reward is settled, so a re-minted bill cannot capture an older round's reward and this rule does not apply to it. The treasury's persistent state is split into frequently-loaded fields (`save_data` / `load_data`) and a rarely-needed `extension` cell (`pack_extension` / `unpack_extension`) to keep gas low on hot paths. **Any upgrade must keep the stored data layout compatible or migrate it explicitly.** ### The rate window Four fields describe how fast the pool is growing: `previous_rate` and `current_rate` bracket a window, `window_duration` is its span in seconds, and `last_settled_round` is the highest round whose reward is in `current_rate`. They are deliberately not a round length and not a per-round delta. All four are written in exactly one place — `burn_ready_participations`, once per scan — and that is what makes them exactly paired. **Why at the barrier and not at settlement.** Settlement is not ordered. When the elector rejects a whole round's stakes, `new_stake_error` runs `recover_stake_result` within seconds, so a newer round can settle while an older one is still validating. A snapshot taken at settlement therefore pairs a delta from one event with an interval from another; the rejected round used to publish a spurious 0% against a two-round interval, and it sat there for about a round. At the barrier there is no such gap: a round only reaches `ready_to_burn` with its own reward already in `total_coins`, and `owes_reward?` guarantees nothing under the released run still owes one. So the growth across a release is exactly the reward of the rounds it released, and the span is exactly theirs. Note which half has to wait. Deferring the *reward* instead — parking each round's rate update to be released in order — over-reports, because `total_coins` is credited per loan recovery rather than at settlement, so a deferred per-round snapshot captures rewards booked after it should have been taken. Only the snapshot can wait; the reward cannot. **Why the window is two releases wide.** `rounds_imbalance` lets one round chain lend more than the other, so the reward booked per release alternates and a one-release window sawtooths every round — live, that was roughly ±7.6% about the mean, twice a day. Two releases always cover one high chain and one low chain, so the published figure is level. Sliding it needs three observations, not two: at release *N* the window starts at *N-2*, and rolling forward needs *N-1*, which the pair never held. `mid_rate` and `mid_round` are that third observation. `previous_rate`'s own round is not stored because it is derivable as `last_settled_round - window_duration`. Every released round advances the window, including one that lent nothing and reached the barrier straight from `process_loan_requests`. Over a two-release window that is the honest reading rather than a dilution: the window then spans one lending round and one idle one, which is what keeps the figure level when liquidity only covers every other round, and it lets a pool whose borrowers are bidding but which is lending nothing report zero growth while it is happening. A pool with no `request_loan` at all creates no participation, so nothing is released and the window simply freezes. `mid_rate` and `mid_round` are stored immediately after `last_settled_round`, with the pair they describe, and returned **last** by `get_treasury_state`. Those are separate decisions: nothing off chain parses the extension cell, so grouping there is free, while the tuple is an interface every reader indexes by position. The three rates are `store_coins`. A fixed `uint64` was specced and reverted: at the bound the coin supply actually imposes — dead shares pin `total_tokens`, so the rate cannot pass ~5.9e17 — it saves twelve bits and turns an over-large rate from a cell that widens into a `store_uint` that throws inside `pack_extension`. The comment above `pack_extension` carries the budget. `get_treasury_state` returns the tuple in storage order — root fields as `save_data` writes them, then extension fields as `pack_extension` does — so it now covers everything the treasury stores, `deficit` included, and an integrator can check the list against the layout rather than a changelog. That tuple is ABI, and two rules govern it. It is **append-only**: a field is never inserted and never moved, so an index that means something today means the same thing forever. And it is **complete**: everything the treasury stores appears in it, which is what let `get_deficit` be deleted. It deliberately does *not* mirror storage order any more. Putting `deficit`, `round_duration` and `last_settled_round` in their storage positions rather than at the end was a breaking change for every reader that indexes the tuple, and the census in `scripts/upgrade_treasury.md` records what it cost — four readers broken, three of them on no checklist. The mirror bought only the ability to check the tuple against the layout, which an integrator cannot do anyway, so it was given up rather than paid for again. **Append.** The rollout checklist is a floor, not the population: this getter is documented publicly and read through a published SDK, so it has readers nobody here can enumerate. ## Testing Tests run on `@ton/sandbox` with a mock elector (`wrappers/elector-test`) and cover flows end-to-end (`Wallet`, `Loan`, `Governance`, `Access`, `Large`), getters, and gas bounds. `MaxGas.spec.ts` and `MinGas.spec.ts` pin worst-case and minimum fees — if a change moves gas costs, those expectations (and the fee constants in `imports/`) must be revisited deliberately, not just updated to make tests pass.