fix(utils): decode omitted trailing optional args in IDL instruction decoder (#185)

create_v2 instructions can legally omit the trailing is_cashback_enabled
OptionBool from the wire; decode_instruction rejected that form, so every
consumer of parse_token_creation_from_instruction silently dropped those
coins (the committed blocksubscribe fixture carries one such 145-byte
create_v2).

- When the arg buffer is exhausted and every remaining arg is
  option-typed (native option or pump.fun's OptionBool), report them as
  unset (None) instead of failing the decode; mandatory args still fail.
- Count trailing optional args as 0 bytes in the instruction min-size
  table.
- Decode Anchor's native option type (1-byte tag + value), previously
  unsupported entirely - update_buyback_config's Option<u64> could not
  even be sized.
- Add learning-examples/verify_create_v2_optional_args.py machine-checking
  the omitted, present, truncated-mandatory and native-option forms plus
  the event-parser path against the committed fixture.
- Drop the append-0x00 workaround in verify_extreme_fast_zero_rpc.py and
  update the stale CLAUDE.md note about the strict decoder.

Fixes #184

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-07-30 20:32:15 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c16fc856d0
commit 9a3f2c0bfb
4 changed files with 275 additions and 27 deletions
+74 -14
View File
@@ -15,6 +15,7 @@ DISCRIMINATOR_SIZE = 8
PUBLIC_KEY_SIZE = 32
STRING_LENGTH_PREFIX_SIZE = 4
ENUM_DISCRIMINATOR_SIZE = 1
OPTION_PREFIX_SIZE = 1
class IDLParser:
@@ -110,19 +111,9 @@ class IDLParser:
instruction = self.instructions[discriminator]
data_args = ix_data[DISCRIMINATOR_SIZE:]
# Decode instruction arguments
args = {}
decode_offset = 0
for arg in instruction.get("args", []):
try:
value, decode_offset = self._decode_type(
data_args, decode_offset, arg["type"]
)
args[arg["name"]] = value
except Exception as e:
if self.verbose:
print(f"❌ Decode error in argument '{arg['name']}': {e}")
return None
args = self._decode_instruction_args(instruction, data_args)
if args is None:
return None
# Helper to safely retrieve account public keys
def get_account_key(index: int) -> str | None:
@@ -144,6 +135,37 @@ class IDLParser:
"accounts": account_info,
}
def _decode_instruction_args(
self, instruction: dict[str, Any], data_args: bytes
) -> dict[str, Any] | None:
"""Decode instruction arguments, or None if the data is malformed.
Trailing option-typed args can legally be absent from the wire
(create_v2's is_cashback_enabled, issue #184): when the buffer is
exhausted and every remaining arg is optional, they are reported
as unset instead of failing the whole decode.
"""
args: dict[str, Any] = {}
decode_offset = 0
arg_defs = instruction.get("args", [])
for i, arg in enumerate(arg_defs):
if decode_offset >= len(data_args) and all(
self._is_optional_type(remaining["type"]) for remaining in arg_defs[i:]
):
for remaining in arg_defs[i:]:
args[remaining["name"]] = None
break
try:
value, decode_offset = self._decode_type(
data_args, decode_offset, arg["type"]
)
args[arg["name"]] = value
except Exception as e:
if self.verbose:
print(f"❌ Decode error in argument '{arg['name']}': {e}")
return None
return args
# --------------------------------------------------------------------------
# Public Methods (External API) - Events
# --------------------------------------------------------------------------
@@ -361,8 +383,15 @@ class IDLParser:
"""Calculate minimum data sizes for each instruction."""
for discriminator, instruction in self.instructions.items():
try:
required_args = list(instruction.get("args", []))
# Trailing option-typed args may be omitted from the wire
# entirely, so they contribute nothing to the minimum size.
while required_args and self._is_optional_type(
required_args[-1]["type"]
):
required_args.pop()
min_size = DISCRIMINATOR_SIZE
for arg in instruction.get("args", []):
for arg in required_args:
min_size += self._calculate_type_min_size(arg["type"])
self.instruction_min_sizes[discriminator] = min_size
if self.verbose and instruction["name"] == "initialize":
@@ -385,11 +414,28 @@ class IDLParser:
element_type, array_length = type_def["array"]
element_size = self._calculate_type_min_size(element_type)
return element_size * array_length
if "option" in type_def:
# The None form is just the tag byte.
return OPTION_PREFIX_SIZE
raise ValueError(
f"Invalid or unknown type definition for size calculation: {type_def}"
)
def _is_optional_type(self, type_def: str | dict) -> bool:
"""Whether a type may legally be absent when it trails instruction data.
Covers Anchor's native ``option`` wrapper and pump.fun's ``OptionBool``
defined type — both are observed omitted from the wire when trailing.
"""
if not isinstance(type_def, dict):
return False
if "option" in type_def:
return True
if "defined" in type_def:
return self._get_defined_type_name(type_def) == "OptionBool"
return False
def _get_primitive_size(self, type_name: str) -> int:
"""Get size in bytes for primitive types from the central map."""
info = self._PRIMITIVE_TYPE_INFO.get(type_name)
@@ -449,9 +495,23 @@ class IDLParser:
return self._decode_defined_type(data, offset, type_name)
if "array" in type_def:
return self._decode_array(data, offset, type_def["array"])
if "option" in type_def:
return self._decode_option(data, offset, type_def["option"])
raise ValueError(f"Invalid or unknown type definition for decoding: {type_def}")
def _decode_option(
self, data: bytes, offset: int, inner_type: str | dict
) -> tuple[Any, int]:
"""Decode Anchor's native option type: a 1-byte tag, then the value if Some."""
tag = struct.unpack_from("<B", data, offset)[0]
offset += OPTION_PREFIX_SIZE
if tag == 0:
return None, offset
if tag != 1:
raise ValueError(f"Invalid option tag {tag}") # noqa: TRY003
return self._decode_type(data, offset, inner_type)
def _decode_array(
self, data: bytes, offset: int, array_def: list
) -> tuple[list[Any], int]: