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
nameis built as"Deposit Payout#<round_id>"whendistributing_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 fromrandom() >> 8plus 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_billsis the sum of all bill weights issued so far (not a count). It is the denominator of the pro-rata split.adminis the pool. Only the pool may init, mint, and start distribution.distributionis aMaybecell. 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_indexand theprev/current/nextitem addresses, plus the precomputednext_state_initso 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.
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::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:
Computes the unpredictable collection
StateInitand address.Records the address in
deposit_payout/withdrawal_payout.Sends
payout::init0xf5aa8943withMINTER_DEPLOY_FEE(0.1 Gram) and a distribution body that encodesdistribution_started = false,jettons? = truefor deposits orfalsefor withdrawals, an initialvolumeof 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_request73).Asserts distribution has not started (
error::mint_after_distribution_start72). 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
StateInitand address, advances the linked-list cursor, and deploys the item withop::init_nft0x132f9a45. The init body carries(owner, weight, next, prev). Note the comment in source:nextandprevare 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.
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):
It checks the sender is its
prevneighbor or the collection (error::unauthorized401), so only the legitimate chain can burn it.If its
nextneighbor exists, it forwardsop::burnto that neighbor withburn_notification_amount(0.01 Gram). This is what makes the cascade self-propagating: every burn lights the fuse on the next bill.It sends the collection
op::burn_notification0xed58b0b2carrying(amount, owner, index), using send modeCARRY_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):
Asserts distribution started (
error::burn_before_distribution66). A bill cannot be usefully burned early; this is why you cannot self-burn for a refund before round end.Recomputes the expected item address from the reported
burnt_indexand checks it matches the sender (error::unauthorized_burn_notification74). A forged burn notification cannot steal a share.Computes this bill's share:
which is exactly (this_bill_weight / total_weight) * volume.
Decrements
volume_to_distributebyshare_amountand decrementsissued_billsbyburnt_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).Sends the payout to
burner_address(the bill's owner at burn time), tagged withop::distributed_asset0xdb3b8abd:Withdrawal collection (
jettons? = false): a plain Gram message ofshare_amountto the owner.Deposit collection (
jettons? = true): ajetton_transfer0xf8a7ea5ofshare_amountKTON 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_distribution66, 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 thedistributioncell: whether distribution has started, the side (jettons?), and the remainingvolume.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 billamount(weight).Item
get_bill_amount()returns the bill weight directly.
Summary
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