Skip to content

Commit

Permalink
eth/abi: implement packed encoder
Browse files Browse the repository at this point in the history
  • Loading branch information
q9f committed Jan 2, 2025
1 parent 5a023e2 commit cd1c278
Show file tree
Hide file tree
Showing 6 changed files with 381 additions and 6 deletions.
20 changes: 19 additions & 1 deletion lib/eth/abi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ class ValueOutOfBounds < StandardError; end
#
# @param types [Array] types to be ABI-encoded.
# @param args [Array] values to be ABI-encoded.
# @param packed [Bool] set true to return packed encoding (default: `false`).
# @return [String] the encoded ABI data.
def encode(types, args)
def encode(types, args, packed = false)
return encode_packed(types, args) if packed
types = [types] unless types.instance_of? Array
args = [args] unless args.instance_of? Array

# parse all types
parsed_types = types.map { |t| Type === t ? t : Type.parse(t) }
Expand All @@ -64,6 +68,20 @@ def encode(types, args)
"#{head}#{tail}"
end

# Encodes Application Binary Interface (ABI) data in non-standard packed mode.
# It accepts multiple arguments and encodes using the head/tail mechanism.
#
# @param types [Array] types to be ABI-encoded.
# @param args [Array] values to be ABI-encoded.
# @return [String] the encoded packed ABI data.
def encode_packed(types, args)
raise ArgumentError, "Types and values must be the same length" if types.length != args.length
packed = types.zip(args).map do |type, arg|
Abi::Packed::Encoder.type(type, arg)
end.join
packed.force_encoding(Encoding::ASCII_8BIT)
end

# Decodes Application Binary Interface (ABI) data. It accepts multiple
# arguments and decodes using the head/tail mechanism.
#
Expand Down
2 changes: 1 addition & 1 deletion lib/eth/abi/decoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def primitive_type(type, data)
when "address"

# decoded address with 0x-prefix
"0x#{Util.bin_to_hex data[12..-1]}"
Address.new(Util.bin_to_hex data[12..-1]).to_s.downcase
when "string", "bytes"
if type.sub_type.empty?
size = Util.deserialize_big_endian_to_int data[0, 32]
Expand Down
7 changes: 3 additions & 4 deletions lib/eth/abi/encoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ def type(type, arg)
head, tail = "", ""
head += type(Type.size_type, arg.size)
nested_sub = type.nested_sub
nested_sub_size = type.nested_sub.size

# calculate offsets
if %w(string bytes).include?(type.base_type) && type.sub_type.empty?
Expand Down Expand Up @@ -93,15 +92,15 @@ def type(type, arg)
# @return [String] the encoded primitive type.
# @raise [EncodingError] if value does not match type.
# @raise [ValueOutOfBounds] if value is out of bounds for type.
# @raise [EncodingError] if encoding fails for type.
# @raise [ArgumentError] if encoding fails for type.
def primitive_type(type, arg)
case type.base_type
when "uint"
uint arg, type
when "bool"
bool arg
when "int"
int arg, type
when "bool"
bool arg
when "ureal", "ufixed"
ufixed arg, type
when "real", "fixed"
Expand Down
9 changes: 9 additions & 0 deletions lib/eth/abi/packed/decoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ module Packed

# Provides a utility module to assist decoding ABIs.
module Decoder

# Since the encoding is ambiguous, there is no decoding function.
#
# @param types [Array] the ABI to be decoded.
# @param data [String] ABI data to be decoded.
# @raise [DecodingError] if you try to decode packed ABI data.
# def decode_packed(types, data)
# raise DecodingError, "Since the encoding is ambiguous, there is no decoding function."
# end
end
end
end
Expand Down
165 changes: 165 additions & 0 deletions lib/eth/abi/packed/encoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,175 @@ module Eth

# Provides a Ruby implementation of the Ethereum Application Binary Interface (ABI).
module Abi

# Encapsulates the module for non-standard packed encoding used in Solidity.
module Packed

# Provides a utility module to assist encoding ABIs.
module Encoder
extend self

# Encodes a specific value, either static or dynamic in non-standard
# packed encoding mode.
#
# @param type [Eth::Abi::Type] type to be encoded.
# @param arg [String|Number] value to be encoded.
# @return [String] the packed encoded type.
# @raise [EncodingError] if value does not match type.
# @raise [ArgumentError] if encoding fails for type.
def type(type, arg)
case type
when /^uint(\d+)$/
uint(arg, $1.to_i / 8)
when /^int(\d+)$/
int(arg, $1.to_i / 8)
when "bool"
bool(arg)
when /^ureal(\d+)x(\d+)$/, /^ufixed(\d+)x(\d+)$/
ufixed(arg, $1.to_i / 8, $2.to_i)

Check warning on line 47 in lib/eth/abi/packed/encoder.rb

View check run for this annotation

Codecov / codecov/patch

lib/eth/abi/packed/encoder.rb#L47

Added line #L47 was not covered by tests
when /^real(\d+)x(\d+)$/, /^fixed(\d+)x(\d+)$/
fixed(arg, $1.to_i / 8, $2.to_i)

Check warning on line 49 in lib/eth/abi/packed/encoder.rb

View check run for this annotation

Codecov / codecov/patch

lib/eth/abi/packed/encoder.rb#L49

Added line #L49 was not covered by tests
when "string"
string(arg)
when /^bytes(\d+)$/
bytes(arg, $1.to_i)
when "bytes"
string(arg)
when /^tuple\((.+)\)$/
tuple($1.split(","), arg)

Check warning on line 57 in lib/eth/abi/packed/encoder.rb

View check run for this annotation

Codecov / codecov/patch

lib/eth/abi/packed/encoder.rb#L57

Added line #L57 was not covered by tests
when /^hash(\d+)$/
hash(arg, $1.to_i)
when "address"
address(arg)
when /^(.+)\[\]$/
array($1, arg)
when /^(.+)\[(\d+)\]$/
fixed_array($1, arg, $2.to_i)
else
raise EncodingError, "Unhandled type: #{type}"

Check warning on line 67 in lib/eth/abi/packed/encoder.rb

View check run for this annotation

Codecov / codecov/patch

lib/eth/abi/packed/encoder.rb#L67

Added line #L67 was not covered by tests
end
end

private

# Properly encodes signed integers.
def uint(value, byte_size)
raise ArgumentError, "Don't know how to handle this input." unless value.is_a? Numeric
raise ValueOutOfBounds, "Number out of range: #{value}" if value > Constant::UINT_MAX or value < Constant::UINT_MIN
[value].pack("Q>")[-1, byte_size].rjust(byte_size, "\x00".b).b
end

# Properly encodes signed integers.
def int(value, byte_size)
raise ArgumentError, "Don't know how to handle this input." unless value.is_a? Numeric
raise ValueOutOfBounds, "Number out of range: #{value}" if value > Constant::INT_MAX or value < Constant::INT_MIN
[value].pack("q>")[-1, byte_size].rjust(byte_size, value < 0 ? "\xFF".b : "\x00".b).b
end

# Properly encodes booleans.
def bool(value)
raise EncodingError, "Argument is not bool: #{value}" unless value.instance_of? TrueClass or value.instance_of? FalseClass
(value ? "\x01".b : "\x00".b).b
end

# Properly encodes unsigned fixed-point numbers.
def ufixed(value, byte_size, decimals)
raise ArgumentError, "Don't know how to handle this input." unless value.is_a? Numeric
raise ValueOutOfBounds, value unless value >= 0 and value < 2 ** decimals
scaled_value = (value * (10 ** decimals)).to_i
uint(scaled_value, byte_size)

Check warning on line 98 in lib/eth/abi/packed/encoder.rb

View check run for this annotation

Codecov / codecov/patch

lib/eth/abi/packed/encoder.rb#L95-L98

Added lines #L95 - L98 were not covered by tests
end

# Properly encodes signed fixed-point numbers.
def fixed(value, byte_size, decimals)
raise ArgumentError, "Don't know how to handle this input." unless value.is_a? Numeric
raise ValueOutOfBounds, value unless value >= -2 ** (decimals - 1) and value < 2 ** (decimals - 1)
scaled_value = (value * (10 ** decimals)).to_i
int(scaled_value, byte_size)

Check warning on line 106 in lib/eth/abi/packed/encoder.rb

View check run for this annotation

Codecov / codecov/patch

lib/eth/abi/packed/encoder.rb#L103-L106

Added lines #L103 - L106 were not covered by tests
end

# Properly encodes byte(-string)s.
def bytes(value, length)
raise EncodingError, "Expecting String: #{value}" unless value.instance_of? String
value = handle_hex_string value, length
raise ArgumentError, "Value must be a string of length #{length}" unless value.is_a?(String) && value.bytesize == length
value.b
end

# Properly encodes (byte-)strings.
def string(value)
raise ArgumentError, "Value must be a string" unless value.is_a?(String)
value.b
end

# Properly encodes tuples.
def tuple(types, values)
encode(types, values)

Check warning on line 125 in lib/eth/abi/packed/encoder.rb

View check run for this annotation

Codecov / codecov/patch

lib/eth/abi/packed/encoder.rb#L125

Added line #L125 was not covered by tests
end

# Properly encodes hash-strings.
def hash(value, byte_size)
raise EncodingError, "Argument too long: #{value}" unless byte_size > 0 and byte_size <= 32
hash_bytes = handle_hex_string value, byte_size
raise ArgumentError, "Hash value must be #{byte_size} bytes" unless hash_bytes.bytesize == byte_size
hash_bytes.b
end

# Properly encodes addresses.
def address(value)
if value.is_a? Address

# from checksummed address with 0x prefix
Util.zpad_hex value.to_s[2..-1], 20
elsif value.is_a? Integer

# address from integer
Util.zpad_int value, 20
elsif value.size == 20

# address from encoded address
Util.zpad value, 20
elsif value.size == 40

# address from hexadecimal address
Util.zpad_hex value, 20
elsif value.size == 42 and value[0, 2] == "0x"

# address from hexadecimal address with 0x prefix
Util.zpad_hex value[2..-1], 20
else
raise EncodingError, "Could not parse address: #{value}"

Check warning on line 159 in lib/eth/abi/packed/encoder.rb

View check run for this annotation

Codecov / codecov/patch

lib/eth/abi/packed/encoder.rb#L159

Added line #L159 was not covered by tests
end
end

# Properly encodes dynamic-sized arrays.
def array(type, values)
values.map { |value| type(type, value) }.join.b
end

# Properly encodes fixed-size arrays.
def fixed_array(type, values, size)
raise ArgumentError, "Array size does not match" unless values.size == size
array(type, values)
end

# The ABI encoder needs to be able to determine between a hex `"123"`
# and a binary `"123"` string.
def handle_hex_string(val, len)
if Util.prefixed? val or
(len === val.size * 2 and Util.hex? val)

# There is no way telling whether a string is hex or binary with certainty
# in Ruby. Therefore, we assume a `0x` prefix to indicate a hex string.
# Additionally, if the string size is exactly the double of the expected
# binary size, we can assume a hex value.
Util.hex_to_bin val
else

# Everything else will be assumed binary or raw string.
val.b
end
end
end
end
end
Expand Down
Loading

0 comments on commit cd1c278

Please sign in to comment.