Skip to content

Commit

Permalink
eth/abi: implement packed encoder (#310)
Browse files Browse the repository at this point in the history
* eth/abi: implement packed encoder

* eth/abi: implement packed encoder

* gem: update copyright headers

* spec: add more tests

* spec: add more tests

* spec: add more tests
  • Loading branch information
q9f authored Jan 3, 2025
1 parent 633c25d commit 8a7e9e9
Show file tree
Hide file tree
Showing 8 changed files with 91,484 additions and 16 deletions.
27 changes: 26 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 solidity_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,26 @@ 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.
# @raise [ArgumentError] if types and args are of different size.
def solidity_packed(types, args)
raise ArgumentError, "Types and values must be the same length" if types.length != args.length

# We do not use the type system for packed encoding but want to call the parser once
# to enforce the type validation.
_ = types.map { |t| Type === t ? t : Type.parse(t) }

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 Expand Up @@ -123,6 +147,7 @@ def decode(types, data)
end
end

require "eth/abi/packed/encoder"
require "eth/abi/decoder"
require "eth/abi/encoder"
require "eth/abi/event"
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
196 changes: 196 additions & 0 deletions lib/eth/abi/packed/encoder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Copyright (c) 2016-2025 The Ruby-Eth Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# -*- encoding : ascii-8bit -*-

# Provides the {Eth} module.
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)
when /^real(\d+)x(\d+)$/, /^fixed(\d+)x(\d+)$/
fixed(arg, $1.to_i / 8, $2.to_i)
when "string"
string(arg)
when /^bytes(\d+)$/
bytes(arg, $1.to_i)
when "bytes"
string(arg)
when /^tuple\((.+)\)$/
tuple($1.split(","), arg)
when /^hash(\d+)$/
hash(arg, $1.to_i / 8)
when "address"
address(arg)
when /^(.+)\[\]$/
array($1, arg)
when /^(.+)\[(\d+)\]$/
fixed_array($1, arg, $2.to_i)
else
raise EncodingError, "Unhandled type: #{type}"
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
i = value.to_i
Util.zpad_int i, byte_size
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
real_size = byte_size * 8
i = value.to_i % 2 ** real_size
Util.zpad_int i, byte_size
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" : "\x00").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)
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)
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)
Abi.solidity_packed(types, values)
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
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}"
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
end
20 changes: 10 additions & 10 deletions lib/eth/abi/type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,10 @@ def size
if dimensions.empty?
if !(["string", "bytes", "tuple"].include?(base_type) and sub_type.empty?)
s = 32
elsif base_type == "tuple" && components.none?(&:dynamic?)
elsif base_type == "tuple" and components.none?(&:dynamic?)
s = components.sum(&:size)
end
elsif dimensions.last != 0 && !nested_sub.dynamic?
elsif dimensions.last != 0 and !nested_sub.dynamic?
s = dimensions.last * nested_sub.size
end
@size ||= s
Expand All @@ -153,7 +153,7 @@ def to_s
if base_type == "tuple"
"(" + components.map(&:to_s).join(",") + ")" + (dimensions.size > 0 ? dimensions.map { |x| "[#{x == 0 ? "" : x}]" }.join : "")
elsif dimensions.empty?
if %w[string bytes].include?(base_type) && sub_type.empty?
if %w[string bytes].include?(base_type) and sub_type.empty?
base_type
else
"#{base_type}#{sub_type}"
Expand All @@ -175,7 +175,7 @@ def validate_base_type(base_type, sub_type)
when "bytes"

# bytes can be no longer than 32 bytes
raise ParseError, "Maximum 32 bytes for fixed-length string or bytes" unless sub_type.empty? || sub_type.to_i <= 32
raise ParseError, "Maximum 32 bytes for fixed-length string or bytes" unless sub_type.empty? or (sub_type.to_i <= 32 and sub_type.to_i > 0)
when "tuple"

# tuples can not have any suffix
Expand All @@ -187,16 +187,16 @@ def validate_base_type(base_type, sub_type)

# integer size must be valid
size = sub_type.to_i
raise ParseError, "Integer size out of bounds" unless size >= 8 && size <= 256
raise ParseError, "Integer size out of bounds" unless size >= 8 and size <= 256
raise ParseError, "Integer size must be multiple of 8" unless size % 8 == 0
when "ureal", "real", "fixed", "ufixed"

# floats must have valid dimensional suffix
raise ParseError, "Real type must have suffix of form <high>x<low>, e.g. 128x128" unless sub_type =~ /\A[0-9]+x[0-9]+\z/
high, low = sub_type.split("x").map(&:to_i)
total = high + low
raise ParseError, "Real size out of bounds (max 32 bytes)" unless total >= 8 && total <= 256
raise ParseError, "Real high/low sizes must be multiples of 8" unless high % 8 == 0 && low % 8 == 0
raise ParseError, "Real type must have suffix of form <size>x<decimals>, e.g. 128x128" unless sub_type =~ /\A[0-9]+x[0-9]+\z/
size, decimals = sub_type.split("x").map(&:to_i)
total = size + decimals
raise ParseError, "Real size out of bounds (max 32 bytes)" unless total >= 8 and total <= 256
raise ParseError, "Real size must be multiples of 8" unless size % 8 == 0
when "hash"

# hashs must have numerical suffix
Expand Down
Loading

0 comments on commit 8a7e9e9

Please sign in to comment.