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

Payout NFT Receipts (Bills)

When KTON cannot settle a deposit or a withdrawal in the same transaction, it does not park your funds in an opaque internal queue. Instead it mints you an on-chain NFT, a "bill", that records exactly how much you are owed and pays out automatically when the round closes. This chapter documents the payout NFT subsystem at the contract level: how a collection is deployed per round and per side, how a bill is minted, how the burn cascade distributes assets pro-rata at round end, the unit semantics that differ between deposit and withdrawal bills, the transferability danger, and the tail-cleanup mechanics.

Source of record for this chapter: contracts/payout_nft/nft-collection.func, contracts/payout_nft/nft-item.func, contracts/payout_nft/op-codes.func, contracts/payout_nft/errors.func, plus the pool-side minting and distribution logic in contracts/pool.func and contracts/pool_mint_helpers.func.

Where bills come from

KTON has two settlement paths. The optimistic path (live on the public pool) settles deposits and instant withdrawals inside the originating transaction and never produces a bill. The pessimistic path settles at round end and uses bills. A pessimistic deposit is minted a deposit bill; a pessimistic withdrawal is minted a withdrawal bill.

On the public KTON pool the optimistic flag is ON, so most user deposits convert to KTON immediately. The pessimistic path still matters: it is the structural fallback the contract reverts to, it is what the bill machinery is built for, and the same machinery settles every queued withdrawal that cannot take the (economically disabled) instant path. The contract code paths described below are exactly the ones that run when settlement is deferred to round end.

A bill is a single-use receipt. It is not transferable in any economically safe sense (see "The transferability danger"), and it must never be sent to another contract. The bill's own on-chain metadata says so literally.

Collection identity: per-round, per-side

Each payout collection is a distinct contract. The pool deploys a new collection per validation round and per side: one deposit collection and one withdrawal collection per round, at most. The collection address is derived from a StateInit built in calculate_payout_state_init(pool_address, round_id, distributing_jettons?) (contracts/address_calculations.func).

Two properties of that derivation matter:

  • The metadata name is built as "Deposit Payout#<round_id>" when distributing_jettons? is true, or "Withdrawal Payout#<round_id>" when false. So the side and the round are baked into the collection identity.

  • The address is intentionally unpredictable. The state init folds in random_seed, derived from random() >> 8 plus the hash of the contract's current storage (randomize_lt() is called first). This means you cannot pre-compute or front-run a collection address from the round number alone.

Because the address is unpredictable, the pool stores the live collection address in its own state. The pool's storage carries deposit_payout and withdrawal_payout (each a MsgAddress, nullable) plus the running tallies requested_for_deposit (in Gram) and requested_for_withdrawal (in KTON). See pool.func globals at lines 38 to 41.

Collection storage layout

The collection storage (nft-collection.func, load_data) is:

storage#_ issued_bills:Coins
          admin:MsgAddress
          distribution:Maybe ^Cell
          collection_content:^Cell
          ^[ next_item_index:uint64
             prev:MsgAddress current:MsgAddress next:MsgAddress
             next_state_init:^Cell ]
  • issued_bills is the sum of all bill weights issued so far (not a count). It is the denominator of the pro-rata split.

  • admin is the pool. Only the pool may init, mint, and start distribution.

  • distribution is a Maybe cell. While null, the collection is uninitialized. After init it carries (started?, jettons?, volume, [jetton_wallet]).

  • The trailing ref keeps a singly linked list of items: next_item_index and the prev/current/next item addresses, plus the precomputed next_state_init so the next mint is cheap.

Lifecycle op-codes

All op-codes below are the literal constants from contracts/payout_nft/op-codes.func (collection side) and contracts/op-codes.func (pool side). The pool-side names map to the collection-side handlers one to one.

Step
Pool sends
Collection handler op
Value

Deploy collection

payout::init 0xf5aa8943

op::init_collection 0xf5aa8943

MINTER_DEPLOY_FEE 0.1 Gram

Mint one bill

payout::mint 0x1674b0a0

op::mint_nft 0x1674b0a0

carries remaining value

Start distribution (Gram side)

payouts::start_distribution 0x1140a64f

op::start_distribution 0x1140a64f

the Gram volume to pay out

Start distribution (KTON side)

jetton transfer_notification 0x7362d09c

op::transfer_notification 0x7362d09c

the KTON volume to pay out

Internal op-codes used between the collection and its items:

Op
Code
Direction
Meaning

op::init_nft

0x132f9a45

collection to item

deploy and initialize a bill

op::burn

0xf127fe4e

collection to item, item to item

trigger a bill burn

op::burn_notification

0xed58b0b2

item to collection

report weight and index, request payout

op::distributed_asset

0xdb3b8abd

collection to user

tag on the Gram or KTON payout message

op::return_unused

0x4bc7c2df

collection to pool

sweep leftover gas after the last burn

op::ownership_assigned

0x05138d91

item to owner

standard TEP-62 mint/transfer notice

Minting a bill

Minting begins pool-side in request_to_mint_deposit or request_to_mint_withdrawal (pool_mint_helpers.func). On the first request of a round for that side, the pool sees its stored payout address is null, so it:

  1. Computes the unpredictable collection StateInit and address.

  2. Records the address in deposit_payout / withdrawal_payout.

  3. Sends payout::init 0xf5aa8943 with MINTER_DEPLOY_FEE (0.1 Gram) and a distribution body that encodes distribution_started = false, jettons? = true for deposits or false for withdrawals, an initial volume of 0, and (deposit side only) the collection's own KTON jetton-wallet address.

On init_collection the collection asserts the sender is the admin (error::unauthorized_init 0xffff), asserts it has not already been initialized (error::can_not_reinit_started_distribution 87), and asserts the init body says distribution is not yet started with zero volume (error::wrong_initial_distribution_info 86). This guarantees a freshly deployed collection can never claim to already hold a distribution.

After init (or immediately, if the collection already exists for this round and side), the pool sends payout::mint 0x1674b0a0 to the collection carrying (destination_owner, weight). The pool also increments its own running tally: requested_for_deposit += amount or requested_for_withdrawal += amount.

On mint_nft the collection (nft-collection.func lines 130 to 165):

  • Asserts the sender is the admin (error::unauthorized_mint_request 73).

  • Asserts distribution has not started (error::mint_after_distribution_start 72). You cannot add bills to a round that is already paying out.

  • Adds the new bill's weight to issued_bills.

  • Computes the next item StateInit and address, advances the linked-list cursor, and deploys the item with op::init_nft 0x132f9a45. The init body carries (owner, weight, next, prev). Note the comment in source: next and prev are stored in reverse order on purpose, which is what makes the round-end cascade walk the list correctly.

Each item, on init_nft, reserves min_tons_for_storage (0.09 Gram), stores (owner, amount, prev, next), and sends the owner an ownership_assigned 0x05138d91 so a standard wallet shows the NFT. From this point the bill is a live, individually addressable contract owned by the user.

Bill weight semantics: deposit vs withdrawal

The weight stored in a bill is side-dependent, and this is the single most important detail for reading payouts correctly.

Deposit bill
Withdrawal bill

weight unit

Gram deposited

KTON (pool jetton) being redeemed

issued_bills unit

total Gram queued this round

total KTON queued this round

Asset distributed at round end

newly minted KTON

Gram

jettons? flag in collection

true

false

Distribution trigger

jetton transfer_notification from the minter

start_distribution carrying Gram

So a deposit bill is denominated in the asset you put in (Gram) and pays out the asset you want out (KTON). A withdrawal bill is the mirror: denominated in the KTON you burned, paying out Gram. The pro-rata formula is identical for both sides; only the units and the distributed asset differ.

The conversion rate is not locked when the bill is minted. The rate that actually applies is the realized end-of-round rate, because the total volume to distribute (the numerator of every payout) is fixed only when the pool fires distribution at round close. This is why a bill is a claim on a share of a pool, not a fixed-amount IOU.

Round close: starting distribution

At finalize_deposit_withdrawal_round (pool.func line 665) the pool runs both sides:

Withdrawal side, initiate_distribution_of_tons (line 670): if requested_for_withdrawal is nonzero, the pool computes ton_withdrawal = muldiv(requested_for_withdrawal, total_balance, supply), the Gram owed to the whole batch at the realized rate. If the pool cannot cover that plus MIN_TONS_FOR_STORAGE, it sets halted? = true and pays nobody until topped up (the self-halt safety described in the unstaking chapter). Otherwise it reduces supply and total_balance, clears requested_for_withdrawal and withdrawal_payout, and sends the collection payouts::start_distribution 0x1140a64f with ton_withdrawal + TRANSFER_NOTIFICATION_AMOUNT of Gram attached.

Deposit side, initiate_distribution_of_pool_jettons (line 706): if requested_for_deposit is nonzero, the pool mints jetton_mint = muldiv_extra(requested_for_deposit, supply, total_balance) KTON (or 1:1 if supply == 0) to the deposit collection's jetton wallet, then total_balance += requested_for_deposit and clears the deposit tallies. The KTON arrives at the collection as a jetton transfer_notification, which is what flips the deposit collection into distribution mode.

On the collection side, start_distribution (Gram path) and transfer_notification (KTON path) both do the same thing: assert distribution has not already started (error::distribution_already_started 68), write the real volume to distribute into the distribution cell, mark started = true, and fire the first op::burn 0xf127fe4e at the current item (the head of the list). For the Gram path, volume = msg_value - start_distribution_gas_usage; for the KTON path, volume = burnt_amount carried in the transfer.

The burn cascade and pro-rata distribution

This is the elegant part. There is no loop in the collection over a stored list of owners. Instead the bills form a linked list and the burn propagates itself.

When an item receives op::burn (nft-item.func burn, line 149):

  1. It checks the sender is its prev neighbor or the collection (error::unauthorized 401), so only the legitimate chain can burn it.

  2. If its next neighbor exists, it forwards op::burn to that neighbor with burn_notification_amount (0.01 Gram). This is what makes the cascade self-propagating: every burn lights the fuse on the next bill.

  3. It sends the collection op::burn_notification 0xed58b0b2 carrying (amount, owner, index), using send mode CARRY_ALL_BALANCE | DESTROY (64 + 32). So the item pays out the rest of its balance and self-destructs in the same message.

When the collection receives op::burn_notification (line 225):

  1. Asserts distribution started (error::burn_before_distribution 66). A bill cannot be usefully burned early; this is why you cannot self-burn for a refund before round end.

  2. Recomputes the expected item address from the reported burnt_index and checks it matches the sender (error::unauthorized_burn_notification 74). A forged burn notification cannot steal a share.

  3. Computes this bill's share:

which is exactly (this_bill_weight / total_weight) * volume.

  1. Decrements volume_to_distribute by share_amount and decrements issued_bills by burnt_amount (so the next bill divides the remaining volume by the remaining weight; the proportions stay exact and rounding dust does not accumulate against later bills).

  2. Sends the payout to burner_address (the bill's owner at burn time), tagged with op::distributed_asset 0xdb3b8abd:

    • Withdrawal collection (jettons? = false): a plain Gram message of share_amount to the owner.

    • Deposit collection (jettons? = true): a jetton_transfer 0xf8a7ea5 of share_amount KTON from the collection's jetton wallet to the owner.

Because each item forwards the burn to its next before the collection processes the notification, the whole list of bills burns in one automatic cascade seeded by a single start_distribution. No per-user transaction is needed. The owner simply receives Gram or KTON.

Tail cleanup

The cascade terminates at the head of the list, index 0. In the burn_notification handler:

When the index-0 bill reports its burn, the collection knows the list is fully drained. It sweeps its entire remaining balance back to the pool (admin) with op::return_unused 0x4bc7c2df, mode CARRY_ALL_BALANCE (128). This reclaims leftover gas budget so the round's settlement nets out cleanly and no dust is stranded in the spent collection.

The transferability danger

The item contract is a TEP-62 NFT: it implements op::nft_transfer 0x5fcc3d14 and ownership can technically change. This is a trap, not a feature, and the docs are explicit about it.

The payout in the burn cascade goes to burner_address, which is the bill's owner at burn time, not the original depositor. If you transfer or sell a bill before the round closes, the new owner receives your Gram or KTON when the cascade runs. There is no claw-back. The bill's own on-chain metadata, generated in get_nft_content (nft-collection.func line 321), sets the description to:

  • Deposit bill: "DO NOT SEND ON CONTRACTS: Automatically converts deposited TON to Pool Jettons when ready"

  • Withdrawal bill: "DO NOT SEND ON CONTRACTS: Converts burned Pool Jettons to TON when ready"

Practical rules:

  • Do not transfer a payout bill. Keep it in the wallet that minted it until it settles.

  • Do not send a bill to a contract. A contract destination may not be able to receive the eventual payout, and the bill is not designed to be held by arbitrary contracts.

  • Do not try to self-burn early. The collection rejects any burn before distribution starts with error::burn_before_distribution 66, so an early burn cannot extract value ahead of schedule. The bill settles when, and only when, the pool finalizes the round.

There is nothing for a well-behaved user to do with a bill except hold it. It will self-destruct and pay you automatically at round end (the practical ~36 hour settlement cycle described in the unstaking chapter).

Get methods for monitoring

The collection and item expose read methods useful for indexers and for verifying a pending claim:

  • Collection get_issued_bills() returns (issued_bills, next_item_index): total outstanding weight and the bill count.

  • Collection get_distribution_data() returns the distribution cell: whether distribution has started, the side (jettons?), and the remaining volume.

  • Collection get_collection_data() returns (next_item_index, collection_content, admin).

  • Collection get_nft_address_by_index(index) derives a bill address deterministically from its index.

  • Item get_nft_data() returns (init?, index, collection, owner, content) where content carries the bill amount (weight).

  • Item get_bill_amount() returns the bill weight directly.

Summary

Concept
Deposit bill
Withdrawal bill

Minted when

a deposit cannot settle in-transaction

a withdrawal queues to round end

weight denominated in

Gram deposited

KTON burned

Pays out

newly minted KTON

Gram

Collection name

Deposit Payout#<round>

Withdrawal Payout#<round>

Distribution trigger

jetton transfer to the collection

start_distribution with Gram

Per-bill share

muldiv(weight, remaining_volume, remaining_issued_bills)

same formula

At burn, paid to

owner at burn time (transfer danger)

owner at burn time (transfer danger)

Lifecycle end

self-destruct via DESTROY; tail sweeps gas with return_unused

same

The bill system gives KTON a fully on-chain, automatic, pro-rata settlement queue with no privileged off-chain keeper: one start_distribution triggers a self-propagating burn cascade that pays every claimant their exact share at the realized end-of-round rate, then cleans up after itself. The one rule for users is simple: never move the receipt.

Next: Contract Reference

Last updated