TLV Layout Primer
How Token-2022 stores extensions on-chain — a 4-byte Type-Length-Value header followed by per-extension payload, walked left-to-right until a type-0 sentinel.
What it is
Token-2022 extends SPL Token without breaking its layout by appending TLV (Type-Length-Value) blocks after the base account. Every Token-2022 feature — transfer fees, interest, confidential transfers, metadata, transfer hooks, scaled UI amounts — is a TLV extension following the same 4-byte header convention.
A TLV entry is exactly 4 bytes of header plus a variable payload:
| Offset | Length | Field | Type | Notes |
|---|---|---|---|---|
| 0 | 2 | extension_type |
u16 LE |
Which extension this is (1 TransferFeeConfig, 2 TransferFeeAmount, 3 MintCloseAuthority, 4 ConfidentialTransferMint, …). 0 means Uninitialized and signals the end of the list. |
| 2 | 2 | length |
u16 LE |
Byte length of the payload that follows — not including the 4-byte header. |
| 4 | N | payload |
varies | Extension-specific encoded data. |
Why it exists
SPL Token’s Mint is exactly 82 bytes and TokenAccount is exactly 165. You can’t add fields without breaking every existing token on Solana. Token-2022 is a parallel program that keeps the base layouts byte-identical to SPL Token and tacks features on as TLV blocks past offset 165, so a decoder that doesn’t know about a particular extension can still read the base balance and authority correctly.
Byte layout of a Token-2022 account
Token-2022 accounts always look like this, whether they’re a 166-byte bare Mint or a multi-kilobyte one with five extensions stacked:
| Offset | Length | Field | Type | Notes |
|---|---|---|---|---|
| 0 | 82 or 165 | base | SPL Token base | Byte-identical to SPL Token Mint (82) or TokenAccount (165). |
| 82 | 83 | padding (Mints only) | zero bytes | Mints are padded out to 165 so the discriminator and TLV always sit at the same offsets regardless of base kind. |
| 165 | 1 | account_type |
u8 enum |
1 = Mint, 2 = TokenAccount. The only on-chain signal that distinguishes the two — you cannot tell from length alone. |
| 166 | varies | TLV entries | TLV[] | One entry per active extension, packed back-to-back. List ends with an entry whose extension_type == 0. |
This means a Token-2022 Mint with one extension has 82 (base) + 83 (padding) + 1 (discriminator) + 4 (TLV header) + N (payload) bytes total. A bare Mint with no extensions is exactly 166 bytes; anything larger has extensions attached.
Walking the TLV list
The reference walker is a tight loop — every Token-2022 explorer, indexer, and decoder does this:
position = 166
while position + 4 <= data.length:
extension_type = read_u16_le(data, position)
if extension_type == 0:
break # Uninitialized — end of list
length = read_u16_le(data, position + 2)
payload = data[position + 4 .. position + 4 + length]
yield (extension_type, payload)
position += 4 + length
That’s the entire walker. The + 4 <= data.length guard avoids reading the header off the end of a truncated buffer; production decoders also bounds-check position + 4 + length before slicing the payload.
Where you see it
Anything owned by TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb with data length over 166 bytes has at least one extension. RPC getMint and getTokenAccountBalance parsed responses unpack this for you; raw getAccountInfo returns the bytes and you walk them yourself.
Common gotchas
extension_type == 0is the sentinel, not the first slot. A fresh extension area looks like one type-0 entry — that is the end-of-list marker. Don’t try to walk past it.lengthis the payload length, not the entry length. Total entry size is4 + length. Forgetting the 4-byte header is the most common decoder bug here.- Mint vs TokenAccount disambiguation lives at offset 165. Both base layouts share size 165 (after Mint padding) — only the
account_typebyte tells you which interpretation to apply to bytes 0–164. - Some extensions are kind-specific. TransferFeeConfig is a Mint-side extension; TransferFeeAmount is the TokenAccount-side counterpart. The TLV format doesn’t enforce kind compatibility — the program does. A naive decoder that reads any TLV against any base will produce nonsense for cross-kind entries.
- Extensions don’t use SPL’s
COptionencoding internally. Most useOptionalNonZeroPubkey(32 bytes, all-zeros = None, no tag) or plain BorshOption<T>. See TransferFeeConfig for the gotcha in context.
Last verified: 2026-05-19