Skip to content

Extension codec: Uninitialized omits its TLV length field, so decodeToken throws on accounts with unused extension space #1448

Description

@xianlinc

Summary

The generated Extension codec encodes Uninitialized without its TLV length field, so it
round-trips as 2 bytes where the on-chain format is 4. Because Extension[] is decoded with
{ size: 'remainder' }, the decoder has to consume the free space after an account's last
extension, and Uninitialized is what it finds there. It walks that space 2 bytes at a
time, so an odd number of free bytes leaves one byte over and decodeToken throws.

Any token account allocated with room to spare therefore fails to decode about half the
time, even though the program reads it fine and @solana/spl-token unpacks it fine.

Reproduction

@solana-program/token-2022@0.16.1, @solana/kit@8.2.0:

import { address } from "@solana/kit";
import { decodeToken, getExtensionEncoder } from "@solana-program/token-2022";

const enc = getExtensionEncoder();
console.log(enc.encode({ __kind: "Uninitialized" }).length);  // 2
console.log(enc.encode({ __kind: "ImmutableOwner" }).length); // 4  <- same empty payload

const decodes = (freeBytes) => {
  const data = new Uint8Array(165 + 1 + freeBytes);
  data[165] = 2; // AccountType::Account
  try {
    decodeToken({
      address: address("11111111111111111111111111111112"), data, exists: true,
      executable: false, lamports: 0n, space: BigInt(data.length),
      programAddress: address("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"),
    });
    return "ok";
  } catch { return "throws"; }
};
for (const n of [0, 1, 2, 3, 4, 5]) console.log(n, decodes(n));

Output:

2
4

 0 ok
 1 throws
 2 ok
 3 throws
 4 ok
 5 throws

The result is exactly periodic in the free-byte count, whatever real entries come first.

Why the account shape is normal

InitializeAccount3 only requires required <= actual, and a close refunds the difference,
so allocating a token account with spare room is legal and common. Anything that sizes an
account from getAccountLenForMint, or from the mint's own length, produces this shape.
Two real mainnet accounts of 415 and 395 bytes, both with an entirely unused extension
region, are what led us here.

@solana/spl-token reads the same accounts without complaint. getExtensionData walks the
TLV, advances by 4 + length, and returns null when it does not find the entry, so an
unused region is harmless there.

Cause

In clients/js/src/generated/types/extension.ts:

getDiscriminatedUnionEncoder(
  [
    ['Uninitialized', getUnitEncoder()],                                  // no size prefix
    ['TransferFeeConfig', addEncoderSizePrefix(getStructEncoder([...]), getU16Encoder())],
    ...
  ],
  { size: getU16Encoder() },   // this is the discriminator width, not a length prefix
)

Every other variant wraps its payload in addEncoderSizePrefix(..., getU16Encoder()).
Uninitialized does not, so it loses the 2-byte length that the on-chain layout always
carries. ImmutableOwner is the clearest comparison: identical empty payload, 4 bytes.

Two other variants had the same shape in older releases and now look correct:
PausableAccount was 2 bytes in 0.4.2 and is 4 in 0.16.1, and ConfidentialMintBurn has
gained a payload. Uninitialized is unchanged in every published version I checked.

Suggested fix

Give Uninitialized the same size prefix as its siblings, so it encodes as
type(2) + length(2) and the decoder advances 4 bytes per entry through unused space.

Workaround

Read the base fields with @solana-program/token, which parses only the fixed 165 bytes,
and scan the extension bytes by hand for the one entry you need, stopping at type 0.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions