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.
Summary
The generated
Extensioncodec encodesUninitializedwithout its TLV length field, so itround-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 lastextension, and
Uninitializedis what it finds there. It walks that space 2 bytes at atime, so an odd number of free bytes leaves one byte over and
decodeTokenthrows.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-tokenunpacks it fine.Reproduction
@solana-program/token-2022@0.16.1,@solana/kit@8.2.0:Output:
The result is exactly periodic in the free-byte count, whatever real entries come first.
Why the account shape is normal
InitializeAccount3only requiresrequired <= 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-tokenreads the same accounts without complaint.getExtensionDatawalks theTLV, advances by
4 + length, and returnsnullwhen it does not find the entry, so anunused region is harmless there.
Cause
In
clients/js/src/generated/types/extension.ts:Every other variant wraps its payload in
addEncoderSizePrefix(..., getU16Encoder()).Uninitializeddoes not, so it loses the 2-byte length that the on-chain layout alwayscarries.
ImmutableOwneris the clearest comparison: identical empty payload, 4 bytes.Two other variants had the same shape in older releases and now look correct:
PausableAccountwas 2 bytes in 0.4.2 and is 4 in 0.16.1, andConfidentialMintBurnhasgained a payload.
Uninitializedis unchanged in every published version I checked.Suggested fix
Give
Uninitializedthe same size prefix as its siblings, so it encodes astype(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.