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

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 round

  • utime_until: supposed end unixtime (utime_until = utime_since + validators_elected_for)

  • the raw vset cell, whose cell_hash is 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]:

Field
Meaning

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:

  1. Guard on the previous round. If prev_round_borrowers still has a non-null borrowers_dict or active_borrowers > 0, the previous round's loans have not all been settled. The pool refuses to advance: it sets current_round_closed? = true and returns. While current_round_closed? is true, pool::request_loan is 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.

  2. 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).

  3. Commit the new hash. saved_validator_set_hash = current_hash adopts the new validator set as the current anchor.

  4. 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() + 1 and an all-zero borrower book [null(), round_index, 0, 0, 0, 0, 0].

  5. Reopen. current_round_closed? = false lets 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 shared else branch that runs update_round(pair_first(get_balance()) - msg_value) then assert_not_halted!()).

  • pool::withdraw runs its own update_round(pair_first(get_balance()) - msg_value) inside a try block.

  • pool::loan_repayment: when the last outstanding loan of the previous round is closed (last_one), it calls update_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 (and get_loan, calculate_loan_amount with update? = true) call update_round so off-chain readers see a rotated, finalized view. get_pool_full_data_raw uses update? = false and 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:

  1. Re-assert the round is closed. Both checks (borrowers_dict.null?() and active_borrowers == 0) must hold, or the call throws error::finalizing_active_credit_round. Profit is never booked on a round with stake still out.

  2. Subtract the finalize fee. profit -= FINALIZE_ROUND_FEE, where FINALIZE_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 net profit simply goes slightly negative or smaller, lowering the rate fractionally).

  3. Take the governance fee. fee = max(muldiv(governance_fee_share, profit, SHARE_BASIS), 0), then profit -= fee. SHARE_BASIS = 16,777,216 (2^24) is the fixed-point divisor for governance_fee_share. On the public pool the live governance_fee_share raw value corresponds to 16.00%, so 16% of each round's post-finalize-fee profit is skimmed to the treasury. The max(..., 0) clamp means the governance fee is never negative: a losing round does not refund the treasury.

  4. Floor the loss. profit = max(profit, - total_balance). A negative round (a loss) can lower total_balance by at most the entire balance, never below zero.

  5. Fold into the balance. total_balance += profit. The pool jetton supply is untouched. Because the exchange rate is total_balance / supply, increasing total_balance while holding supply fixed 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_withdrawal is 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), where total_balance already 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 from total_balance, and start_distribution (op payouts::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_deposit is 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). When supply == 0 the 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:

Field (config 15)
Value (mainnet)
Role

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 the stake_held_for freeze.

  • 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_round finalizes), not continuously.

Next: Governance, Roles and Upgrades

Last updated