> For the complete documentation index, see [llms.txt](https://docs.kton.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kton.io/protocol-internals/09-round-lifecycle-and-timing.md).

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

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

```
global [cell, int, int, int, int, int, int] current_round_borrowers;
global [cell, int, int, int, int, int, int] prev_round_borrowers;
```

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

```func
() update_round(int available_balance) impure inline_ref {
    sent_during_rotation = 0;
    if(halted?) { return (); }
    (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) {
        var [borrowers_dict, round_id, active_borrowers, ...] = prev_round_borrowers;
        if((~ borrowers_dict.null?()) | (active_borrowers > 0)) {
            current_round_closed? = true;
            return ();
        }
        prev_round_borrowers~finalize_lending_round(available_balance);
        saved_validator_set_hash = current_hash;
        int round_index = current_round_index() + 1;
        log_round_rotation(round_index);
        (prev_round_borrowers, current_round_borrowers) =
            (current_round_borrowers, [null(), round_index, 0, 0, 0, 0, 0]);
        current_round_closed? = false;
    } else {
        current_round_closed? = false;
    }
}
```

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

```func
([cell, int, int, int, int, int, int], ()) finalize_lending_round(borrowers_data, available_balance) impure {
    var [borrowers_dict, round_id, active_borrowers, borrowed, expected, returned, profit] = borrowers_data;
    throw_unless(error::finalizing_active_credit_round, borrowers_dict.null?());
    throw_unless(error::finalizing_active_credit_round, active_borrowers == 0);

    profit -= FINALIZE_ROUND_FEE;
    int fee = max(muldiv(governance_fee_share, profit, SHARE_BASIS), 0);
    profit -= fee;
    profit = max(profit, - total_balance);
    total_balance += profit;
    ...
}
```

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:

```func
fee += accrued_governance_fee;
if (fee > SERVICE_NOTIFICATION_AMOUNT) {
    sent_during_rotation += fee;
    accrued_governance_fee = 0;
    send_msg_builder(get_treasury(roles), fee, ...);
}
```

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

```func
() finalize_deposit_withdrawal_round (int available_balance, int round_id) impure {
    initiate_distribution_of_tons(available_balance, round_id);
    initiate_distribution_of_pool_jettons(round_id);
}
```

### Withdrawals: distribution of Gram

`initiate_distribution_of_tons` pays out the round's queued withdrawals (`pool.func`):

```func
() initiate_distribution_of_tons(available_balance, round_id) impure {
    ifnot(requested_for_withdrawal) { return (); }
    int ton_withdrawal = muldiv(requested_for_withdrawal, total_balance, supply);
    slice prev_withdrawal_payout = withdrawal_payout;
    if(available_balance < ton_withdrawal + MIN_TONS_FOR_STORAGE) {
        halted? = true;
        return ();
    } else {
        supply -= requested_for_withdrawal;
        requested_for_withdrawal = 0;
        withdrawal_payout = null();
    }
    total_balance -= ton_withdrawal;
    sent_during_rotation += ton_withdrawal + TRANSFER_NOTIFICATION_AMOUNT;
    var msg = ... .store_body_header(payouts::start_distribution, cur_lt());
    send_raw_message(msg.end_cell(), sendmode::REGULAR);
}
```

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

```func
() initiate_distribution_of_pool_jettons(round_id) impure {
    ifnot(requested_for_deposit) { return (); }
    int jetton_mint = supply ? muldiv_extra(requested_for_deposit, supply, total_balance)
                             : requested_for_deposit;
    sent_during_rotation += TRANSFER_NOTIFICATION_AMOUNT + PAYOUT_DISTRIBUTION_AMOUNT;
    request_to_mint_pool_jettons(deposit_payout, jetton_mint, cur_lt(), false);
    total_balance += requested_for_deposit;
    deposit_payout = null();
    requested_for_deposit = 0;
}
```

* `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`:

```func
if(available_balance < ton_withdrawal + MIN_TONS_FOR_STORAGE) {
    halted? = true;
    return ();
}
```

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

```func
(int, int, int) get_validator_config() inline {
    slice cs = config_param(15).begin_parse();
    (int validators_elected_for, int elections_start_before, int elections_end_before, int _stake_held_for) =
        (cs~load_uint(32), cs~load_uint(32), cs~load_uint(32), cs.preload_uint(32));
    ...
}
```

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

```func
;; We need to be guaranteed to wait for unfreezing round and only then send `recover_stake`.
;; So we are waiting for the change of 3 validator sets.
throw_unless(error::too_early_stake_recover_attempt_count, validator_set_changes_count >= 2);
int time_since_unfreeze = now() - validator_set_change_time - stake_held_for;
throw_unless(error::too_early_stake_recover_attempt_time,
             (validator_set_changes_count > 2) | (time_since_unfreeze > 60));
```

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:

```
KTON_ROUND_DURATION_SECS.mainnet = 131072 s = exactly 2 x validators_elected_for ≈ 36 h
```

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**
