---
id: TIP-1065
title: Repurpose TIP-20 Balance slots into User State
description: Turn TIP-20 balance slots into packed user state with balance, and cache fields.
authors: @0xrusowsky, Arsenii Kulikov (@klkvr)
status: Draft
related: TIP-20, TIP-1000
protocolVersion: T6
---

# TIP-1065: Repurpose TIP-20 Balance slots into User State

## Abstract

This TIP proposes reinterpreting each TIP-20 `balance` mapping value as a packed user-state word. The slot continues to store the user's token balance, but also gains space for small per-user cache fields. The initial cache field supports reward accounting, allowing balance-changing paths to avoid unnecessary storage reads and mapping-key (`keccak256`) computations.

## Motivation

The goal is to improve performance by minimizing `SLOAD`s and mapping-key `keccak256` computations in common TIP-20 execution paths.

TIP-20 already allocates one storage slot per holder with a nonzero balance. Reusing that slot as user state lets the protocol store small cache fields next to the balance without introducing new mappings or additional slots for active holders. Because TIP-20 total supply is capped at `uint128::MAX`, no account balance can exceed `uint128::MAX`; the upper 128 bits of each 256-bit balance slot are therefore unused by the balance itself.

The first use case is TIP-20 reward accounting, which currently requires reading reward metadata from separate mapping slots during common balance-changing paths. Caching reward state in the existing balance slot reduces redundant storage reads and associated mapping hashing while keeping room in the same slot for future per-user caches.

## Assumptions

- TIP-20 total supply is capped at `uint128::MAX`, so each account balance fits in 128 bits.
- `rewardRecipient != address(0)` means opted in, and `rewardRecipient == address(0)` means opted out.

---

# Specification

At T6, each value in the TIP-20 `balance` mapping is interpreted as a packed `state` word instead of a raw `uint256` balance.

## User State Slot Layout

| Bits | Size | Field |
|---|---:|---|
| `0..127` | 128 | `balance` |
| `128..129` | 2 | `rewardFlag` |
| `130..255` | 126 | reserved, MUST be zero |

`balance` is encoded as `uint128`. This does not reduce the valid TIP-20 balance range because total supply is already capped at `uint128::MAX`. The upper 128 bits are cache space attached to that balance slot, where `reserved` covers bits `136..255` and MUST be zero until assigned by a future TIP.

## Activation and Backward Compatibility

For pre-T6 execution, the slot MUST continue to be interpreted as the legacy raw balance. Cache fields MUST NOT be set pre-T6 activation.

At T6 activation, existing balance slots are interpreted as packed user-state words. This requires no migration because every valid TIP-20 balance fits in the low 128 bits. Existing holders therefore start with their previous balance and `rewardFlag == Uninitialized`.

For T6+ execution, TIP-20 APIs continue to expose balances normally. `balanceOf(account)` returns only the low 128-bit `balance` value, and no external interface exposes the packed cache fields directly.

## General User-State Cache Rules

Cache fields are performance optimizations derived from existing protocol state. The protocol MUST ensure that any initialized cache value is never stale, and always matches the source of truth. An `Uninitialized` cache value is not stale; it means the implementation MUST read the source of truth before using that field.

All packed state writes MUST be charged according to the storage slot's actual zero/nonzero transition. In particular, creating a cache-only slot from `state == 0` is a zero-to-nonzero storage write and MUST be charged as new state under TIP-1000; changing a nonzero balance slot into a cache-only slot is a nonzero-to-nonzero write and MUST NOT receive a nonzero-to-zero refund; clearing any nonzero packed state to `state == 0` is a nonzero-to-zero write and receives whatever refund is otherwise available for clearing that slot; and changing a cache-only slot into a nonzero balance slot is a nonzero-to-nonzero write and MUST NOT be charged as new state.

Balance-changing and cache-maintenance paths MAY create, preserve, update, or clear cache fields, including cache-only slots, subject to each field's source-of-truth and invalidation rules. If no cache field needs to remain nonzero when a balance becomes zero, implementations SHOULD clear the packed state slot so the storage slot does not remain live unnecessarily.

Balance writes encode the new `balance` in the low 128 bits. Existing defined cache fields MAY be preserved, cleared, or updated according to their field-specific rules. Reserved bits MUST remain zero unless a future TIP assigns them. Future cache fields MAY use the reserved region only through a future TIP that defines their encoding, source of truth, invalidation behavior, and handling of invalid values. In practice this means that a packed state slot MAY have `balance == 0` while one of the cache fields is nonzero.

## Reward State Cache

`rewardFlag` is the first cache field defined in the user-state slot. It caches whether the account is opted into TIP-20 rewards so balance-changing paths can avoid unnecessary `SLOAD`s and mapping-key `keccak256` computations against reward-info storage. It can have the following values:

- `0`: `Uninitialized`.
- `1`: `OptedOut`.
- `2`: `OptedIn`.

Any other encoded value is invalid and MUST be treated as a consensus error.

`rewardRecipient(account)` remains the source of truth for reward delegation and opt-in status, and any initialized `rewardFlag` MUST always be aligned with it. As defined in TIP-20, `rewardRecipient != address(0)` means opted in, and `rewardRecipient == address(0)` means opted out.

The reward-state cache may be used by balance-changing paths that need to know whether a holder is opted into rewards, including transfers, mints, fee refunds, reward claims, and any internal balance movement that updates `optedInSupply`. These paths MAY use an initialized `rewardFlag` instead of reading reward info from separate mapping slots. When `rewardFlag` is `Uninitialized`, the implementation MUST read `rewardRecipient(account)` and derive the effective reward state from it.

Once `rewardFlag` has been initialized to `OptedOut` or `OptedIn`, it MUST NOT be reset to `Uninitialized`; future changes MUST move directly between initialized states.

`setRewardRecipient(recipient)` MUST update `rewardRecipient`. It MUST also ensure the `rewardFlag` cache for the account is not left stale: `setRewardRecipient(recipient)` MUST update `rewardFlag` in that slot to match the new effective reward state before returning.

`optedInSupply` updates MUST be based on effective opt-in transitions, preserving the existing supply accounting semantics. A cached `rewardFlag` value MUST NOT be used in a way that changes the result relative to deriving opt-in status from `rewardRecipient`.

## Transfer Storage Accounting

For a transfer, reward accounting is applied independently to the sender and recipient before their balances are updated. The legacy reward-accounting baseline per participant reads the whole `UserRewardInfo` struct (`rewardRecipient`, `rewardPerToken`, and `rewardBalance`) and `globalRewardPerToken`; this is 4 `SLOAD`s total.

The following table counts reward-accounting storage operations per participant and excludes the balance `SLOAD`s and `SSTORE`s that are still required to move tokens, as well as any `optedInSupply` update caused by moving value between opted-in and opted-out accounts.

| Token reward state | User reward state | User accumulation state | Legacy reward accounting | T6 reward accounting with initialized `rewardFlag` | Savings per participant |
|---|---|---|---|---|---|
| No rewards distributed (`globalRewardPerToken == 0`) | Opted out | No accumulated rewards | baseline | none | 4 `SLOAD`s |
| No rewards distributed (`globalRewardPerToken == 0`) | Opted in | No accumulated rewards | baseline | `SLOAD globalRewardPerToken` | 3 `SLOAD`s |
| Rewards distributed | Opted out | No accumulated rewards | baseline; if `rewardPerToken` is stale, `SSTORE rewardPerToken` | none | 4 `SLOAD`s and, when stale, 1 `SSTORE` |
| Rewards distributed | Opted in | No accumulated rewards (`rewardPerToken == globalRewardPerToken`) | baseline | `SLOAD globalRewardPerToken`, `SLOAD rewardPerToken` | 2 `SLOAD`s |
| Rewards distributed | Opted in | Accumulated rewards (`rewardPerToken < globalRewardPerToken`) | baseline, plus recipient reward-balance read/write when delegated; `SSTORE rewardPerToken` and `SSTORE rewardBalance` | `SLOAD globalRewardPerToken`, `SLOAD rewardPerToken`, `SLOAD rewardRecipient`, plus recipient reward-balance read/write when rewards are nonzero; `SSTORE rewardPerToken` and recipient `SSTORE rewardBalance` when rewards are nonzero | 1 `SLOAD`; if the holder self-delegates, also avoids the holder `rewardBalance` `SSTORE` by using the same recipient reward-balance path |

Reward-state mutation and checkpoint paths, such as `claimRewards` and `setRewardRecipient`, may realize less of the transfer-path savings because they call reward checkpointing, which may persist the user's current `rewardPerToken` before rewards are claimed or the reward recipient changes.

For a standard nonzero transfer, these per-participant savings apply once to the sender and once to the recipient. The maximum common transfer saving is therefore 8 reward-accounting `SLOAD`s when both participants are opted out, while transfers between initialized opted-in accounts with no pending accumulation save 4 reward-accounting `SLOAD`s.

## Invariants

- The low 128 bits of each T6+ user-state word MUST equal the account's TIP-20 balance.
- No TIP-20 account balance may exceed `uint128::MAX`.
- Reserved bits MUST remain zero until assigned by a future TIP.
- Pre-T6 execution MUST preserve and interpret balance slots as legacy raw balances.
- At T6 activation, existing valid balances MUST decode to the same `balance` with `rewardFlag == Uninitialized`.
- `balanceOf(account)` MUST return the low 128-bit `balance` and MUST NOT expose cache fields.
- Invalid `rewardFlag` values MUST be treated as `Uninitialized`.
- `rewardRecipient` MUST remain the source of truth for reward delegation and opt-in status. Any update that changes `rewardRecipient` MUST atomically update `rewardFlag` for the same account, creating a cache-only packed state slot if needed, so an initialized `rewardFlag` cannot become stale. Once initialized, `rewardFlag` MUST NOT return to `Uninitialized`. Using `rewardFlag` MUST NOT change `optedInSupply` or reward-accounting results relative to deriving opt-in status from `rewardRecipient`.
