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

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

Actor
Contract
Responsibility in the 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) and assert_sender!(sender_address, validator).

  • msg_value >= MIN_REQUEST_LOAN_VALUE (1 Gram), else error::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 optional acceptable_profit_share (share units). interest is provisionally set to max_interest.

  • acceptable_profit_share >= approver_set_profit_share, else error::profit_share_mismatch (0xfa06). The validator must accept at least the profit share the approver demands.

  • approved? must be true, else error::controller_not_approved (0xfa00).

  • borrowed_amount must be 0, else error::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) and throw_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) and too_late_borrowing_request (0xf106). If credit_start_prior_elections_end is 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, else error::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_amount is 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

Name
Op-code / value
Source

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