Round Lifecycle and Timing
KTON does not run on an internal clock. Its round boundaries are driven entirely by the TON validator set: every time the network rotates its validators, the pool detects the change and advances its own round bookkeeping. This chapter traces a single round from open to close, shows how update_round and finalize_lending_round compute and book round profit, explains how queued deposits and withdrawals settle at finalization, and reconciles the two timescales that matter in practice: the roughly 18.2-hour TON validation round and the roughly 36-hour KTON settlement cycle.
All op-codes, fields, and constants below are cited from contracts/pool.func, contracts/network_config_utils.func, contracts/controller.func, and docs/pool.md.
What drives a round: the validator set hash
The pool stores a single scalar that anchors its round position:
global int saved_validator_set_hash;On almost every incoming message the pool calls update_round(...), which reads the live validator set from network config and compares its hash against the saved one (pool.func):
(int utime_since, int utime_until, cell vset) = get_current_validator_set();
int current_hash = cell_hash(vset);
if (saved_validator_set_hash != current_hash) {
... advance the round ...
}get_current_validator_set() (network_config_utils.func) reads config param 34, the current validator set (validators_ext#12), and returns:
utime_since: actual start unixtime of the current validation roundutime_until: supposed end unixtime (utime_until = utime_since + validators_elected_for)the raw
vsetcell, whosecell_hashis the round fingerprint
When the hash matches, nothing rotates and current_round_closed? is cleared to false. When the hash differs, the network has elected a new validator set, and the pool treats that as the trigger to close the round that just ended.
Round state in the pool
Each round's lending data is a 7-tuple, kept for the current and previous round (pool.func, mirrored in docs/pool.md):
The fields, in order, are [borrowers_dict, round_id, active_borrowers, borrowed, expected, returned, profit]:
borrowers_dict
dict controller_address -> (borrowed, accounted_interest)
round_id
monotonic round index
active_borrowers
number of controllers that have not yet repaid
borrowed
total Gram lent out this round (principal, no interest)
expected
total Gram owed back (borrowed + interest)
returned
Gram repaid so far
profit
running profit, returned - borrowed; equals expected - borrowed once fully settled
The two-round buffer (current plus previous) exists because loans are repaid one round after they are taken: a controller borrows in round N, stakes for a TON validation round, and repays in round N+1. The pool must therefore keep the prior round's borrower book live while the new round is already accepting fresh loans.
How one round closes and the next opens
update_round(int available_balance) is the rotation engine (pool.func):
Step by step when the validator set hash has changed:
Guard on the previous round. If
prev_round_borrowersstill has a non-nullborrowers_dictoractive_borrowers > 0, the previous round's loans have not all been settled. The pool refuses to advance: it setscurrent_round_closed? = trueand returns. Whilecurrent_round_closed?is true,pool::request_loanis rejected (throw_if(error::borrowing_request_in_closed_round, current_round_closed?)), so no new loans go out until repayment catches up. This is the structural reason a round cannot rotate ahead of its outstanding stake.Finalize. Otherwise the previous round is fully repaid, and
finalize_lending_round(available_balance)runs: it books profit, deducts fees, settles deposits and withdrawals, and emits stats (detailed below).Commit the new hash.
saved_validator_set_hash = current_hashadopts the new validator set as the current anchor.Shift the buffer. The just-finalized previous round is discarded; the round that was "current" becomes "previous"; a fresh empty round is opened with
round_index = current_round_index() + 1and an all-zero borrower book[null(), round_index, 0, 0, 0, 0, 0].Reopen.
current_round_closed? = falselets the new round accept loans again.
sent_during_rotation is reset to zero at the top of every update_round call. It accumulates the Gram the pool sends out during finalization (fees, withdrawal distributions, deposit mints) so that the same transaction does not double-spend reserved balance; it is a temporal variable, never saved to storage.
If the pool is halted, update_round returns immediately and no rotation happens. Rounds resume only after the governor unhalts.
Where update_round is called from
update_round is invoked on the message paths that can change accounting or that need a current view:
pool::deposit,pool::request_loan,pool::touch,pool::deploy_controller(via the sharedelsebranch that runsupdate_round(pair_first(get_balance()) - msg_value)thenassert_not_halted!()).pool::withdrawruns its ownupdate_round(pair_first(get_balance()) - msg_value)inside atryblock.pool::loan_repayment: when the last outstanding loan of the previous round is closed (last_one), it callsupdate_round(pair_first(get_balance())). This is the trigger that finalizes a round the instant its final repayment lands, rather than waiting for the next user action.The get-methods
get_pool_full_data(andget_loan,calculate_loan_amountwithupdate? = true) callupdate_roundso off-chain readers see a rotated, finalized view.get_pool_full_data_rawusesupdate? = falseand shows raw stored state.
Computing round profit and folding it into total_balance
finalize_lending_round runs once per round, on the previous round's fully repaid borrower data (pool.func):
The order of operations is exact and load-bearing:
Re-assert the round is closed. Both checks (
borrowers_dict.null?()andactive_borrowers == 0) must hold, or the call throwserror::finalizing_active_credit_round. Profit is never booked on a round with stake still out.Subtract the finalize fee.
profit -= FINALIZE_ROUND_FEE, whereFINALIZE_ROUND_FEE = ONE_TON(1 Gram). This covers the gas and notification cost of the rotation itself. If a round's gross profit was under 1 Gram, the difference is socialized across all holders (the netprofitsimply goes slightly negative or smaller, lowering the rate fractionally).Take the governance fee.
fee = max(muldiv(governance_fee_share, profit, SHARE_BASIS), 0), thenprofit -= fee.SHARE_BASIS = 16,777,216 (2^24)is the fixed-point divisor forgovernance_fee_share. On the public pool the livegovernance_fee_shareraw value corresponds to 16.00%, so 16% of each round's post-finalize-fee profit is skimmed to the treasury. Themax(..., 0)clamp means the governance fee is never negative: a losing round does not refund the treasury.Floor the loss.
profit = max(profit, - total_balance). A negative round (a loss) can lowertotal_balanceby at most the entire balance, never below zero.Fold into the balance.
total_balance += profit. The pool jettonsupplyis untouched. Because the exchange rate istotal_balance / supply, increasingtotal_balancewhile holdingsupplyfixed raises the Gram value of every KTON. This is the auto-compounding mechanism: holders do nothing and their wallet balances never change; the rate appreciates discretely at each rotation (the live public-pool rate sits around 1.035 Gram per KTON).
After the balance is updated, the accrued governance fee from instant withdrawals is added to the round fee and, if it exceeds SERVICE_NOTIFICATION_AMOUNT (0.02 Gram), forwarded to the treasury:
A fee at or below 0.02 Gram is too small to be worth a message and is silently retained (it stays in accrued_governance_fee only if it was not reset; the round fee portion is simply absorbed into total_balance accounting). The interest manager then receives a stats message (op interest_manager::stats) carrying borrowed, expected, returned, signed profit, and the new total_balance, and log_round_completion emits round id, borrowed, returned, profit, balance, and supply for off-chain analytics.
Deposit and withdrawal distribution at finalization
Immediately after lending profit is booked, finalize_lending_round calls finalize_deposit_withdrawal_round, which settles the round's pessimistic (queued) deposits and withdrawals (pool.func):
Withdrawals: distribution of Gram
initiate_distribution_of_tons pays out the round's queued withdrawals (pool.func):
requested_for_withdrawalis the total KTON burned for queued (pessimistic) withdrawals during the round.The Gram owed is computed at the realized round-end rate:
ton_withdrawal = muldiv(requested_for_withdrawal, total_balance, supply), wheretotal_balancealready includes this round's profit. Queued withdrawers therefore earn the round's yield up to the moment they settle.The withdrawn KTON is burned from
supply, the withdrawn Gram is removed fromtotal_balance, andstart_distribution(oppayouts::start_distribution = 0x1140a64f) is sent to the round's withdrawal payout NFT collection. The collection cascades a burn over its linked list of payout-NFT bills, paying each bill's owner-at-burn-time their pro-rata share. (The bill is a burnable receipt; never transfer or sell it, as the payout follows whoever owns it at burn time. See the unstaking chapter.)
Deposits: distribution of KTON
initiate_distribution_of_pool_jettons mints KTON to the round's queued depositors (pool.func):
requested_for_depositis the total Gram escrowed by queued (pessimistic) depositors during the round.KTON is minted at the realized round-end rate:
jetton_mint = muldiv_extra(requested_for_deposit, supply, total_balance). Whensupply == 0the pool bootstraps 1:1 (jetton_mint = requested_for_deposit).The minted KTON goes to the deposit payout collection, which distributes it pro-rata to the round's depositors, and the escrowed Gram is folded into
total_balance.
Both pessimistic distributions settle at the same instant the round finalizes, at the rate that already reflects the round's booked profit. (Note: the two live pools run with the optimistic flag ON, so most deposits and withdrawals take the immediate paths described in earlier chapters; the queued-distribution machinery here is the fallback path and the settlement point for any pessimistic activity.)
The self-halt safety property at finalization
The key invariant of the distribution step is that the pool refuses to pay withdrawals it cannot fully cover. In initiate_distribution_of_tons:
If the funds on hand at finalization are less than the queued Gram payout plus MIN_TONS_FOR_STORAGE (10 Gram, the pool's storage floor), the pool sets halted? = true and pays nobody, rather than partially distributing and leaving some bills unfunded or draining the storage reserve. This is a deliberate self-halt: it stops the protocol in a consistent state until it is topped up, at which point the governor can unhalt and the distribution retries on the next rotation. The property guarantees that every settled withdrawal is paid in full and that the contract never falls below its storage minimum. Withdrawal requests themselves are never blocked by a halt (the withdraw handler must always be able to refund burned KTON), but the payout at finalization is gated by this solvency check.
The two timescales: ~18.2h TON round vs ~36h KTON cycle
The pool's round and a TON validation round are related but not identical, and the difference is what makes a KTON deposit or withdrawal take roughly 36 hours to settle rather than 18.
The TON validation round (~18.2h)
A TON validation round lasts validators_elected_for, read from config param 15 (network_config_utils.func):
On mainnet validators_elected_for = 65536 s, which is about 18.2 hours. The relevant config fields:
validators_elected_for
65,536 s (~18.2 h)
length of one validation round; utime_until = utime_since + validators_elected_for
elections_start_before
config-driven
how far before utime_until the election window opens (loans may start)
elections_end_before
config-driven
how far before utime_until the election window closes (loans must be placed)
stake_held_for
config-driven (~9.1 h)
how long stake stays frozen after the round before it can be recovered
The election window for loans is bounded by these fields. In pool::request_loan the pool checks now() > utime_until - elections_start_before (elections started) and now() < utime_until - elections_end_before (elections not yet closed). Borrowed funds must be staked in the same round their election is open for; the controller enforces (borrowing_time > utime_since) & (now() < utime_until - elections_end_before) on new_stake. The Elector address itself comes from config param 1.
Why stake takes two TON rounds to recover
A controller cannot recover its stake the moment its validation round ends. The Elector keeps the stake frozen for stake_held_for after the round, and the controller must wait for the validator set to change enough times to be guaranteed the unfreeze has happened. From controller.func:
The controller counts validator-set changes via controller::update_validator_hash (capped at validator_set_changes_count < 3) and only sends controller::recover_stake (op 0xeb373a05) to the Elector once it has observed at least two to three set changes and the unfreeze period has elapsed. Because each set change is roughly one ~18.2-hour validation round, recovering one round's stake spans on the order of two TON rounds. Only after recover_stake returns the stake (plus reward, minus any fine) does the controller repay the pool via pool::loan_repayment (0xdfdca27b), which is what lets the pool's update_round finally finalize that round.
The KTON settlement cycle (~36h = 131072s = 2 x validators_elected_for)
Putting the two together: a deposit or withdrawal queued in KTON round N settles only when round N finalizes, and round N finalizes only after its loans are repaid, which requires the controller to wait through the stake-freeze and validator-set changes spanning roughly two TON validation rounds. The application encodes this as:
So the practical KTON settlement cycle is about 36 hours, exactly two TON validation rounds (2 x 65536 s, equivalently 2 x validators_elected_for). The app uses ROUNDS_PER_YEAR = 241 (365.25 days divided by ~36.4 hours) when annualizing yield. Yield itself is variable: it is the realized validator-loan interest booked each round, computed live over the 241 rounds per year, not a fixed figure.
The practical takeaways:
A TON validation round is ~18.2 hours (
validators_elected_for = 65536 s, config 15).The KTON settlement cycle is ~36 hours (
131072 s, two TON rounds), because stake recovery spans roughly two validator-set rotations plus thestake_held_forfreeze.A withdrawal is not instant on the queued path: expect up to about one full ~36-hour cycle from request to Gram in hand.
The exchange rate moves discretely at each rotation (when
update_roundfinalizes), not continuously.
Next: Governance, Roles and Upgrades
Last updated