# 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, its rewards are in `total_coins`, and `current_rate`/`previous_rate` are updated. 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. 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 255), 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. ## 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` 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.** ## 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.