The Loan and Validator Model
This chapter is the technical specification of how pooled Gram becomes a validator stake and how the resulting interest flows back into the KTON pool. It is the engine behind the appreciating exchange rate. KTON does not stake Gram directly with the TON Elector. Instead, the Pool lends Gram to per-validator controller contracts, each controller stakes its own balance (validator funds plus the borrowed Gram) with the Elector, and after the stake is recovered the controller repays the loan with interest. All yield in the protocol comes from this validator-loan interest. There is no other source.
Every op-code, field, and constant below is taken directly from the contract source: controller.func, pool.func, network_config_utils.func, and docs/controller.md.
Roles and contracts in a loan
Validator wallet
(external)
Initiates the loan request and the stake; holds its own operating funds on the controller.
Controller
controller.func
One per validator per round-parity. Borrows from the Pool, stakes with the Elector, recovers, and repays.
Pool
pool.func
Custodies pooled Gram, decides the loan size, extends credit, books the debt, and folds repaid profit into total_balance.
Approver
role on the controller
Approves a controller to borrow and sets approver_set_profit_share.
Interest manager
role on the Pool
Sets interest_rate, min/max loan per validator, disbalance_tolerance, and credit_start_prior_elections_end; receives per-loan and per-round stats.
Elector
TON system contract (config param 1)
Native validator election and stake escrow. Not a KTON contract.
Controller authentication is entirely address based. The controller address is derived from [validator, pool, governor, halter, approver]. Rotating any of those forces a fresh controller deployment.
A controller moves through a fixed state machine (controller.func):
REST (0) -> SENT_BORROWING_REQUEST (1) -> REST -> SENT_STAKE_REQUEST (2)
-> FUNDS_STAKEN (3) -> SENT_RECOVER_REQUEST (4) -> REST
INSOLVENT (5) is a terminal-ish error state reached when the controller cannot repay.1. Loan request: controller side
The validator wallet sends controller::send_request_loan (op 0x6335b11a) to its controller. The handler enforces (controller.func):
assert_state!(state::REST)andassert_sender!(sender_address, validator).msg_value >= MIN_REQUEST_LOAN_VALUE(1 Gram), elseerror::too_low_request_loan_value(0xf604). This value covers controller and pool gas.The request body carries
min_loan,max_loan,max_interest, and an optionalacceptable_profit_share(share units).interestis provisionally set tomax_interest.acceptable_profit_share >= approver_set_profit_share, elseerror::profit_share_mismatch(0xfa06). The validator must accept at least the profit share the approver demands.approved?must be true, elseerror::controller_not_approved(0xfa00).borrowed_amountmust be 0, elseerror::multiple_loans_are_prohibited(0xfa01). One loan at a time.
It then checks the election window from network config (params 15 and 34, read via network_config_utils.func):
A loan can only be requested while the Elector's election for the next validation round is open. allowed_borrow_start_prior_elections_end is a per-controller window set by the approver (default 65536, permissive).
Before forwarding, the controller checks it can afford the worst case. elector_fine = max_recommended_punishment_for_validator_misbehaviour(max_loan + balance) (config param 40, default 101 Gram if unset; see network_config_utils.func), interest_payment = muldiv(max_loan, max_interest, SHARE_BASIS), and the validator's own funds (balance - borrowed_amount) must cover ENSURABLE_BALANCE_FOR_STAKING + elector_fine + interest_payment, else error::too_high_loan_request_amount (0xfa04). If allocation is nonzero, max_loan <= allocation is also required.
On success the controller forwards pool::request_loan (op 0xe642c965) carrying the request plus its static data, using CARRY_ALL_REMAINING_MESSAGE_VALUE, and transitions to SENT_BORROWING_REQUEST.
2. Loan grant: pool side
The Pool handles pool::request_loan (op 0xe642c965) in pool.func. It re-validates everything independently:
assert_state!(state::NORMAL)andthrow_if(borrowing_request_in_closed_round, current_round_closed?)(0xf104).The same election-window triple is re-checked against config params 15 and 34, producing
too_early_borrowing_request(0xf105) andtoo_late_borrowing_request(0xf106). Ifcredit_start_prior_elections_endis nonzero, an additional earliest-borrow gate applies; on the live public pool this value is 0, so that extra check is disabled.The sender is verified to be the controller derived from
[controller_id, validator]:assert_sender!(sender_address, _get_controller_address(controller_id, validator)).max_interest >= interest_rate, elseerror::interest_too_low(0xf100). The validator must accept at least the pool's current rate.
Sizing the loan
The Pool computes how much it can safely lend. First, creditable_funds, the cash actually available after reserving for queued withdrawals and storage:
Then the disbalance cap, which limits how much of the pool a single validator can draw so that no one controller concentrates the stake:
DISBALANCE_TOLERANCE_BASIS = 2 << 8 = 512 (pool.func, the comment notes "2 and not 1 is intentional"). The base term 512/2 = 256 is exactly 50% of total_balance. disbalance_tolerance widens that ceiling: on the live public pool it is 255, raising the per-validator cap close to the full balance. borrowed is what is already lent out this round, so the cap is the remaining room under the ceiling.
The request's min_loan and max_loan are then clamped to the interest-manager's per-validator bounds:
Live per-validator bounds are 1.00 / 2,000,000 Gram (public) and 1.00 / 2,506,300 Gram (private).
Interest and the credit message
Interest is fixed at grant time:
SHARE_BASIS = 256 * 256 * 256 = 16,777,216 (2^24, defined in types.func). interest_rate is a per-round rate in those share units, set by the interest manager. On the live pools the raw value is 10800, which is 10800 / 16,777,216 ≈ 0.06437% of principal per loan. Interest is per-loan, not annualized here; the displayed APY is computed off-chain by annualizing realized round profit.
The Pool then sends exactly one controller::credit message (op 0x1690c604). The split between message value and body is the key mechanic:
The message VALUE is the cash (actual_loan); the BODY is the debt (actual_loan + interest). The controller receives actual_loan in real Gram but is recorded as owing actual_loan + interest. Interest is part of the debt from the first instant of the loan; it is not accrued over time.
The Pool books the loan with ~add_loan(sender_address, actual_loan, interest), which accumulates expected += loan_body + interest for the round and stores [already_borrowed, accounted_interest] per controller in borrowers_dict. The running per-validator total must satisfy total_loan <= max_loan_per_validator, else error::total_credit_too_high (0xf103). The dictionary depth is bounded: cell_depth(borrowers_dict) < MAX_LOAN_DICT_DEPTH (12), else error::credit_book_too_deep (0xf401). Finally a SERVICE_NOTIFICATION_AMOUNT (0.02 Gram) stat message goes to the interest manager.
Controller receives the credit
Back in controller.func, the controller::credit handler (assert_sender!(sender_address, pool)):
msg_value here is the cash (actual_loan), so credit_amount - msg_value = interest, and the controller recomputes its effective interest share and clamps it as a sanity check (error::credit_interest_too_high, 0xfa05). borrowed_amount now holds the full debt including interest. The state returns to REST, and borrowing_time is stamped if it was zero.
3. Staking with the Elector
With cash in hand and state REST, the validator sends controller::new_stake (op 0x4e73744b, aliased from elector::new_stake). The handler enforces, among other checks:
assert_state!(state::REST),assert_sender!(sender_address, validator).query_id > 0(so the Elector replies),msg_value >= ELECTOR_OPERATION_VALUE(1.03 Gram).value >= MIN_STAKE_TO_SEND(50,000 Gram). This is the TON validator-stake floor, not a user deposit minimum.Same-round credit rule: if
borrowed_amountis nonzero, the stake must be sent in the same election round the funds were borrowed for:
Borrowed Gram cannot be carried across rounds. It must be staked into the election it was borrowed for, or returned. The controller forwards the validated new_stake to the Elector (elector_address() from config param 1) with PAY_FEES_SEPARATELY, records stake_amount_sent = value - ELECTOR_OPERATION_VALUE, snapshots the current validator-set hash, resets validator_set_changes_count = 0, and moves to SENT_STAKE_REQUEST. On elector::new_stake_ok (0xf374484c) it advances to FUNDS_STAKEN; on elector::new_stake_error (0xee6f454c) or a bounced new_stake it returns to REST.
While funds are staked, the validator must periodically call controller::update_validator_hash (op 0xf0fd2250) to track validator-set changes (incrementing validator_set_changes_count). If the validator is late by more than GRACE_PERIOD (600 s), anyone may call it and collect HASH_UPDATE_FINE (10 Gram) from the controller. This watchdog incentive keeps the recovery clock advancing even if the validator goes dark.
4. Recovery and settlement
Stake cannot be pulled back immediately. The Elector holds it across the validation round plus the unfreeze hold (stake_held_for). The controller must wait for the validator set to change enough times before recovering, because the Elector accrues credits in several phases (surplus return, complaint rewards, and the final unfreeze) and the controller must take them all in one shot.
controller::recover_stake (op 0xeb373a05) enforces (controller.func):
It sends elector::recover_stake (op 0x47657424) with CARRY_ALL_REMAINING_MESSAGE_VALUE and moves to SENT_RECOVER_REQUEST. As with the hash watchdog, once time_since_unfreeze >= GRACE_PERIOD and borrowed_amount > 0, a non-validator caller may trigger recovery and collect STAKE_RECOVER_FINE (10 Gram); before the grace period only the validator may call it.
The repayment amount: greater of two
When the Elector replies with elector::recover_stake_ok (op 0xf96f7324), the controller computes profit and the amount to return:
This is the V2 revenue-sharing rule. borrowed_amount is principal plus fixed interest. The second branch reconstructs the bare principal (since borrowed_amount = principal * (SHARE_BASIS + interest) / SHARE_BASIS, the inverse muldiv recovers the principal) and adds approver_set_profit_share percent of the round's realized profit. The pool is repaid the greater of the two. In normal conditions the fixed interest dominates; when a round is unusually profitable the profit-share branch wins, so the upside is shared with the pool (and therefore all KTON holders) rather than kept entirely by the validator.
The repayment is sent to the Pool as pool::loan_repayment (op 0xdfdca27b) with PAY_FEES_SEPARATELY, but only if the controller can keep MIN_TONS_FOR_STORAGE (2 Gram) in reserve. If it cannot, it transitions to INSOLVENT (state 5) and does not repay until topped up. On a successful send it zeroes borrowed_amount, borrowing_time, interest, stake_amount_sent, and stake_at, returning to REST. An elector::recover_stake_error (op 0xfffffffe) means the Elector held zero credits; the controller halts itself and goes INSOLVENT to avoid repeated fine drain.
Unused-loan return and watchdog
If a controller borrowed but never won the election (so the funds were never staked), the loan must be returned. controller::return_unused_loan (op 0xed7378a6) requires state == REST, borrowed_amount > 0, and utime_since > borrowing_time (the loan belongs to a prior round). It sends the full borrowed_amount back via pool::loan_repayment. Once the controller is past GRACE_PERIOD, anyone may trigger this and collect STAKE_RECOVER_FINE (10 Gram), the watchdog bounty that guarantees loaned Gram returns to the pool even if the validator abandons it. The governor can also force a return from an INSOLVENT controller via governor::return_available_funds.
5. Pool-side settlement and profit accounting
The Pool handles pool::loan_repayment (op 0xdfdca27b) by calling ~close_loan(sender_address, msg_value) against the previous round's borrowers first, then the current round's. close_loan looks up the borrower's recorded was_borrowed, removes it from the dictionary, and books:
Because borrowed_amount already included interest, a clean repayment of exactly the debt yields zero marginal profit here; the protocol's positive profit comes from the interest that was baked into the debt at grant time (and any profit-share excess). When the last active borrower closes (active_borrowers == 0), the round can finalize via update_round.
finalize_lending_round (in pool.func) closes the books for the round:
governance_fee_share is 16% on the live public pool. The 1 Gram FINALIZE_ROUND_FEE and the governance fee are taken on the round's yield, not on principal. total_balance += profit while supply stays fixed is exactly what makes each KTON redeem for more Gram over time. A round stat message (interest_manager::stats) reports borrowed, returned, profit, the new total_balance, and supply to the interest manager, who may then retune interest_rate for future rounds.
Why this is the entire yield engine
There is no other yield path. The Pool's only productive use of capital is lending to controllers, and the only return is the fixed interest (or the larger profit-share) that controllers fold back through loan_repayment and finalize_lending_round. Validator performance determines realized profit: a validator that wins elections and validates honestly returns interest on schedule, while a slashed or absent validator can produce zero or negative round profit, which is socialized across all holders down to the -total_balance floor. Loan sizing (creditable_funds, the disbalance cap, per-validator min/max) bounds the risk any one validator poses, and the election-window gating plus the same-round credit rule ensure borrowed Gram is only ever exposed for the single election it was borrowed for.
Op-code and constant reference
controller::send_request_loan
0x6335b11a
op-codes.func
pool::request_loan
0xe642c965
op-codes.func
controller::credit
0x1690c604
op-codes.func
controller::new_stake / elector::new_stake
0x4e73744b
op-codes.func
elector::new_stake_ok / _error
0xf374484c / 0xee6f454c
op-codes.func
controller::recover_stake
0xeb373a05
op-codes.func
elector::recover_stake
0x47657424
op-codes.func
elector::recover_stake_ok / _error
0xf96f7324 / 0xfffffffe
op-codes.func
pool::loan_repayment
0xdfdca27b
op-codes.func
controller::return_unused_loan
0xed7378a6
op-codes.func
controller::update_validator_hash
0xf0fd2250
op-codes.func
SHARE_BASIS
16,777,216 (2^24)
types.func
DISBALANCE_TOLERANCE_BASIS
512 (2 << 8)
pool.func
MAX_LOAN_DICT_DEPTH
12
pool.func
FINALIZE_ROUND_FEE
1 Gram
pool.func
MIN_TONS_FOR_STORAGE (pool / controller)
10 Gram / 2 Gram
pool.func / controller.func
ELECTOR_OPERATION_VALUE
1.03 Gram
controller.func
MIN_REQUEST_LOAN_VALUE
1 Gram
controller.func
MIN_STAKE_TO_SEND
50,000 Gram (validator-stake floor)
controller.func
GRACE_PERIOD
600 s
controller.func
HASH_UPDATE_FINE / STAKE_RECOVER_FINE
10 Gram / 10 Gram
controller.func
Next: Round Lifecycle and Timing
Last updated