Skip to content

Bug: TypeEncoder.encode_int/2 overflow guard rejects nearly all valid int<N> values for small bit widths #55

Description

@e-fu

ABI.TypeEncoder.encode_int/2 in lib/abi/type_encoder.ex mixes up bytes and bits in its overflow guard:

defp encode_int(int, desired_size_bits) when rem(desired_size_bits, 8) == 0 and is_integer(int) do
  desired_size_bytes = ceil(desired_size_bits / 8)
  # ...
  significant_bytes =
    if int >= 0 do
      maybe_encode_unsigned(abs(int))
    else
      actual_bit_size = (-1 * int) |> :binary.encode_unsigned() |> bit_size()
      maybe_encode_unsigned(2 ** actual_bit_size + int)
    end

  if byte_size(significant_bytes) > desired_size_bytes - 1 do
    raise("Data overflow encoding int, data `#{int}` cannot fit in #{(desired_size_bytes - 1) * 8} bits")
  end

  # ...
end

The guard byte_size(significant_bytes) > desired_size_bytes - 1 compares a byte count against desired_size_bytes - 1, which evaluates to 0 for int8 (desired_size_bytes = 1). Since :binary.encode_unsigned(0) returns <<0>> (one byte), even 0 trips the guard — so for int8 the encoder rejects every value in the valid signed range -128..127, including 0. The same scaling error makes int16 reject any value whose two's-complement representation needs more than 1 byte (i.e. roughly anything outside -255..255), and so on for larger int<N> widths.

Repro:

iex> ABI.encode("f(int8)", [0])
** (RuntimeError) Data overflow encoding int, data `0` cannot fit in 0 bits

iex> ABI.encode("f(int8)", [42])
** (RuntimeError) Data overflow encoding int, data `42` cannot fit in 0 bits

iex> ABI.encode("f(int8)", [-1])
** (RuntimeError) Data overflow encoding int, data `-1` cannot fit in 0 bits

iex> ABI.encode("f(int16)", [1000])
** (RuntimeError) Data overflow encoding int, data `1000` cannot fit in 8 bits

The error message itself (cannot fit in 0 bits, cannot fit in 8 bits) is the giveaway — the check was meant to compare against desired_size_bits, not (desired_size_bytes - 1) * 8.

The existing test (test/abi/type_encoder_test.exs"int overflow raises data overflow") passed only because it asserted the encoder raises on overflow — which it did, just for the wrong reason (it raised on essentially every value).

Suggested fix

Range-check the integer against the actual signed int<N> range up front, before any byte-encoding:

defp encode_int(int, desired_size_bits) when rem(desired_size_bits, 8) == 0 and is_integer(int) do
  desired_size_bytes = ceil(desired_size_bits / 8)
  max = Bitwise.bsl(1, desired_size_bits - 1)

  if int >= max or int < -max do
    raise(
      "Data overflow encoding int, data `#{int}` cannot fit in #{desired_size_bits}-bit signed range (-#{max}..#{max - 1})"
    )
  end

  # ... existing sign-byte / two's-complement encoding unchanged ...
end

This accepts the full signed range -2^(N-1)..2^(N-1)-1 for every int<N> and produces a correct, informative overflow message at the boundary.

Surfaced by a decode(encode(x)) == x property suite (stream_data) that walks every type in ABI.FunctionSelector.@type type/0. Happy to send a PR with the guard fix and tightened boundary tests (in-range values encode; 128 / -129 raise for int8; etc.) if this direction works.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions