For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

Component
Source
Responsibility

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_tONfVz6lJS0PVKR5viEiEGyj9AuQewGQVnXPg0

  • KTON 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_code for controller address authorization,

  • payout_code for deploying the deposit and withdrawal payout collections,

  • pool_jetton_wallet_code for 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:

Op
Code
Sender
Effect

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_jettons sends payout::mint (0x1674b0a0) to jetton_minter and increments the in-memory supply. For user mints it carries the balance (mode CARRY_ALL_BALANCE); for payout distribution mints it carries an explicit forward amount.

  • request_to_mint_deposit and request_to_mint_withdrawal mint a bill on the relevant payout collection. If that collection is not yet deployed for the current round (deposit_payout or withdrawal_payout is null), the helper first computes the collection's state-init, derives its address, deploys it with payout::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 / 0x1674b0a0 to the minter, which forwards an internal_transfer to 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 (op 0x319b0cdc) to the Pool. As noted above, that op is the same code as pool::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:

Op
Code
Meaning

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

Op
Code
Direction

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