Protocol Architecture
This chapter is for engineers and integrators. It describes the on-chain components of KTON, what each one is responsible for, how messages flow between them, and how addresses are derived. Everything here is taken from the contract source (pool.func, controller.func, the payout NFT contracts, and the shared op-codes.func) and from live on-chain state.
KTON is a liquid staking protocol on The Open Network (TON). Users deposit Gram (the TON network asset) and receive KTON, a rate-appreciating jetton. The pooled Gram is lent to validator-controllers, which stake it with the TON Elector and earn validation rewards. Realized loan interest, net of fees, is folded into the pool's accounting at each round's end, so 1 KTON redeems for an increasing amount of Gram over time (currently about 1.035 Gram). The supply is never rebased: balances stay fixed and the exchange rate moves instead.
Component map
Pool
pool.func, pool_storage.func
Custodies Gram; mints and burns KTON through the jetton minter; extends per-round loans to controllers; runs round rotation and profit accounting; holds every governance role address. The central contract.
KTON jetton minter
DAOJettonMinter (jetton_dao)
Mints and burns KTON on the Pool's instruction. Its admin is the Pool. Standard TEP-74 DAO-jetton with off-chain metadata.
KTON jetton wallet
DAOJettonWallet (jetton_dao)
Holds one user's KTON balance. Supports DAO voting (can lock the balance during a vote). 9 decimals.
Controller
controller.func
One per validator per round parity. Borrows from the Pool, forwards the stake to the Elector, recovers the stake after the freeze period, and repays the loan. Authenticated purely by address.
Payout NFT collection
payout_nft/nft-collection.func
Per-round, per-side distributor. Mints one "bill" NFT per pessimistic deposit or withdrawal, then cascades a burn at round end to pay each holder pro-rata.
Payout NFT item
payout_nft/nft-item.func
A single user's receipt bill. Self-destructs on burn, paying its owner at burn time.
TON Elector
TON system contract (config param 1)
Native validator election and stake escrow. Not a KTON contract; the protocol only sends it the standard new_stake / recover_stake messages.
The KTON jetton master and the public Pool are deployed separately. The Pool is registered as the minter's admin, so only the Pool can mint or trigger burns. The Payout collections are deployed lazily by the Pool, one per round per side, as the first deposit or withdrawal of a round arrives.
Live addresses:
Public Pool:
EQA9HwEZD_tONfVz6lJS0PVKR5viEiEGyj9AuQewGQVnXPg0KTON jetton master:
EQBuIhXNNkWf9AW9miNGNTSO_uFZ23ejfIWrieXge5f733mw
The Pool contract
The Pool is the protocol's hub. Its storage holds the staking accounting (total_balance, interest_rate, the optimistic and deposits_open? flags), two round-data structures (current_round_borrowers and prev_round_borrowers, each a borrowers dict plus round_id, active_borrowers, borrowed, expected, returned, and profit), the loan bounds per validator, the governance fee, the minter and payout-side addresses with their supplies, the role addresses, and the code cells of its child contracts.
It keeps the codes of three child contracts because it needs them either to deploy children or to recompute and authorize their addresses:
controller_codefor controller address authorization,payout_codefor deploying the deposit and withdrawal payout collections,pool_jetton_wallet_codefor computing the deposit payout's KTON wallet address.
Message dispatch
recv_internal parses the message flags and the body header (op, query_id), loads storage, and branches on op. A handful of op-codes (from op-codes.func) define the public surface:
pool::deposit
0x47d54391
user
Take DEPOSIT_FEE, mint KTON (optimistic) or mint a deposit bill (pessimistic).
pool::withdraw
0x319b0cdc
jetton minter
The burn-notification handler. Pay Gram instantly or mint a withdrawal bill.
pool::request_loan
0xe642c965
controller
Size and grant a loan; emit controller::credit.
pool::loan_repayment
0xdfdca27b
controller
Close a loan; finalize the round if it was the last open loan.
pool::touch
0x4bc7c2df
anyone
Force a round update via the get-data path.
pool::deploy_controller
0xb27edcad
validator
Deploy a fresh controller for the sender.
pool::get_conversion_rate_unsafe
0x4b7b42e6
anyone
Reply with the projected balance and supply.
Governance op-codes (sudo::*, governor::*, halter::*, interest_manager::*, treasury::*) are covered in the Governance chapter and are omitted here.
One detail worth pinning down: the minter's burn_notification is wired to land directly on the withdraw handler. In op-codes.func, payout::burn_notification is defined as pool::withdraw, that is, both equal 0x319b0cdc. So when a user burns KTON, the minter's notification arrives at the Pool already on the unstake code path. The handler first asserts the sender is the minter, then (inside a try) decodes the burned amount and the two custom-payload bits and either pays Gram immediately or queues a withdrawal bill. If anything throws after the minter check, the catch re-mints the burned KTON back to the user, so value is never lost on a failed exit.
Mint and burn helpers
The Pool never holds KTON itself; it instructs the minter. pool_mint_helpers.func builds these messages:
request_to_mint_pool_jettonssendspayout::mint(0x1674b0a0) tojetton_minterand increments the in-memorysupply. For user mints it carries the balance (modeCARRY_ALL_BALANCE); for payout distribution mints it carries an explicit forward amount.request_to_mint_depositandrequest_to_mint_withdrawalmint a bill on the relevant payout collection. If that collection is not yet deployed for the current round (deposit_payoutorwithdrawal_payoutis null), the helper first computes the collection's state-init, derives its address, deploys it withpayout::init(0xf5aa8943), and stores the address before minting the first bill.
For the deposit side it also pre-computes the deposit collection's own KTON jetton wallet address (using pool_jetton_wallet_code) and passes it in the init message, because at round end the Pool mints real KTON to that collection, which then distributes it.
The KTON jetton (minter and wallets)
KTON is a standard DAO-jetton (TEP-74 with voting-capable wallets), 9 decimals, mintable, with the Pool as admin. Two op-codes connect it to the Pool:
Mint: the Pool sends
payout::mint/0x1674b0a0to the minter, which forwards aninternal_transferto the recipient's wallet.Burn: a user sends
jetton::burn(0x595f07bc) to their own wallet; the wallet decreases its balance and notifies the minter, which sends a burn notification (op0x319b0cdc) to the Pool. As noted above, that op is the same code aspool::withdraw, so the unstake is handled in one hop from the minter.
The exchange rate is derived from Pool state, not stored on the jetton: minting uses jettons = muldiv(Gram, supply, total_balance) and redeeming uses Gram = muldiv(jettons, total_balance, supply), bootstrapping 1:1 when supply == 0.
The Payout NFT collection and item (the "bills")
When a deposit or withdrawal cannot settle immediately, the user receives a burnable payout NFT, a "bill", that records their share of the round. There are two collections per round, one for deposits and one for withdrawals, each deployed lazily by the Pool.
The collection stores issued_bills (the running total of bill weight), its admin (the Pool), an optional distribution cell, the collection content, and a small linked-list cursor (next_item_index, prev, current, next, and the next item's state-init). Each item stores inited?, owner, collection, index, the bill amount, and prev / next pointers. Bill op-codes live in payout_nft/op-codes.func:
mint_nft
0x1674b0a0
Mint a bill of a given weight to a user.
start_distribution
0x1140a64f
Begin paying out a round's bills.
burn
0xf127fe4e
Burn one bill.
burn_notification
0xed58b0b2
Item-to-collection burn report.
init_nft
0x132f9a45
Initialize a newly deployed item.
Bill weight differs by side. A deposit bill's weight is the Gram deposited; a withdrawal bill's weight is the KTON redeemed. At round end the Pool fires start_distribution at the collection (see below), and the collection walks the linked list of bills, burning each one in a single automatic cascade. Each burned item pays its owner-at-burn-time muldiv(bill_weight, remaining_volume, issued_bills), then self-destructs.
This is why the bill must not be transferred or sold. The payout goes to whoever owns the bill at burn time, and the on-chain metadata literally warns against sending it on to other contracts. A user also cannot usefully burn a bill early: the collection rejects a burn before distribution has started.
The Controller contracts
A controller is the bridge between the Pool's Gram and the TON Elector. The Pool itself never talks to the Elector; controllers do. There is one controller per validator per round parity (even and odd rounds), so a validator participating continuously runs two controllers.
Controller storage holds its state (rest, sent borrowing request, sent stake request, funds staken, or halted) plus an active-credit flag, the four authorization addresses, round-control fields (saved_validator_set_hash, validator_set_changes_count, validator_set_change_time, stake_held_for), the funds fields (borrowed_amount, borrowing_time), and the V2 fields allocation, allowed_borrow_start_prior_elections_end, approver_set_profit_share, and acceptable_profit_share.
Key controller op-codes (from op-codes.func):
controller::send_request_loan
0x6335b11a
validator to controller
controller::credit
0x1690c604
pool to controller
controller::new_stake
0x4e73744b
validator to controller, then controller to Elector
controller::recover_stake
0xeb373a05
trigger to controller
controller::return_unused_loan
0xed7378a6
trigger to controller
controller::top_up
0xd372158c
pool deploy / funding
controller::approve
0x7b4b42e6
approver
The controller borrows only while the election window is open, forwards the borrowed Gram to the Elector with elector::new_stake (0x4e73744b), and after the validator-set changes and the stake-held period elapse it recovers the stake with elector::recover_stake (0x47657424). On the Elector's recover_stake_ok, the controller computes profit = received - stake_sent and repays the Pool the greater of the full fixed-interest debt or the principal plus the agreed profit share:
It then sends pool::loan_repayment (0xdfdca27b) back to the Pool. The detailed loan lifecycle, watchdog fines, and the profit-share model are covered in the next chapter.
Controller address derivation
Controllers are authenticated by address, not by a stored owner field, so the address must be reproducible from a fixed set of inputs. build_controller_address in pool.func assembles the controller's static data from:
(the approver and halter are packed together in a referenced cell). That static data goes into controller_init_state, and calc_address hashes the state-init to produce the address in the controller workchain.
The practical consequence: the controller address is bound to the validator and to the pool, governor, approver, and halter role addresses. Rotating any of those addresses (for example, changing the governor) changes every controller address. The old controller would no longer pass the Pool's address check, so the validator must deploy a fresh controller. The Pool exposes get_controller_address(controller_id, validator) as a get-method so off-chain code can compute the same address.
When a validator sends pool::deploy_controller (0xb27edcad), the Pool rebuilds the address and init-state for the sender and deploys the controller with controller::top_up (0xd372158c), carrying the remaining message value.
End-to-end message flows
Deposit (optimistic, the live mode)
Both live pools run with the optimistic flag on, so a deposit mints KTON immediately.
In pessimistic mode the Pool instead mints a deposit bill (request_to_mint_deposit), and the real KTON is minted at round finalization.
Unstake (queued / pessimistic, the real exit path)
A withdrawal is not instant. On the live pools the instant path is economically disabled (the instant-withdrawal fee is set to roughly 100%), so the genuine exit is the queued path that settles when the round finalizes, about 36 hours.
If the instant branch's fill_or_kill bit was set and instant liquidity is unavailable, the burn reverts (throw 105) and the KTON is refunded.
Loan and round settlement
When the last loan of a round repays, update_round calls finalize_deposit_withdrawal_round, which (a) sends Gram to the withdrawal collection's start_distribution and decreases supply by the redeemed amount, and (b) mints the round's KTON to the deposit collection and increases total_balance by the deposited Gram. Round profit is added to total_balance while supply is unchanged, which is the auto-compounding mechanism: each KTON appreciates without any holder action and without rebasing.
Component responsibilities at a glance
Pool: custody, accounting, rate math, loan sizing, round rotation, role enforcement. Holds no KTON; never contacts the Elector directly.
Jetton minter / wallet: the KTON token. Mint and burn only on the Pool's instruction (minter admin = Pool). Wallets hold balances and support DAO voting.
Controllers: per-validator, per-parity loan and staking agents. Talk to the Elector; repay the Pool. Address-authenticated, so role rotation forces redeployment.
Payout collections / items: round-scoped, side-scoped distributors of bills. Cascade-burn at round end and pay each holder pro-rata. Bills must never be transferred.
Elector: the native TON system contract the controllers stake with. Outside the protocol's trust boundary; only the standard stake messages cross it.
Next: The Loan and Validator Model
Last updated