Skip to content

Commit

Permalink
Dynamic struct encoding (#135) (#185)
Browse files Browse the repository at this point in the history
* add test case

* eth/abi: support dynamic struct encoding

* remove unused function

* typo

* uncomment spec

* cleanup spec

* test tuple2

* add tuple2.sol

* Update lib/eth/contract/function_input.rb

* Update lib/eth/client.rb

* Update lib/eth/contract/function.rb

* Update lib/eth/abi/type.rb

* Update lib/eth/abi.rb

* Update lib/eth/abi.rb

* Update lib/eth/abi/type.rb

* Update lib/eth/abi/type.rb

* Update lib/eth/abi/type.rb

* Update lib/eth/abi.rb

* Update lib/eth/abi.rb

* Update lib/eth/abi.rb

* Update lib/eth/abi/type.rb

* eth/abi: fix tests for dynamic struct encoding

* eth/abi: fix documentation and formatting

Co-authored-by: Peter Chung <[email protected]>
  • Loading branch information
q9f and peter-chung-xfers authored Dec 21, 2022
1 parent 9e3e2af commit 490f28d
Show file tree
Hide file tree
Showing 13 changed files with 629 additions and 18 deletions.
68 changes: 63 additions & 5 deletions lib/eth/abi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class ValueOutOfBounds < StandardError; end
def encode(types, args)

# parse all types
parsed_types = types.map { |t| Type.parse(t) }
parsed_types = types.map { |t| Type === t ? t : Type.parse(t) }

# prepare the "head"
head_size = (0...args.size)
Expand Down Expand Up @@ -81,12 +81,16 @@ def encode_type(type, arg)
size = encode_type Type.size_type, arg.size
padding = Constant::BYTE_ZERO * (Util.ceil32(arg.size) - arg.size)
return "#{size}#{arg}#{padding}"
elsif type.dynamic?
raise EncodingError, "Argument must be an Array" unless arg.instance_of? Array
elsif type.base_type == "tuple" && type.dimensions.size == 1 && type.dimensions[0] != 0
result = ""
result += encode_struct_offsets(type.nested_sub, arg)
result += arg.map { |x| encode_type(type.nested_sub, x) }.join
result
elsif type.dynamic? && arg.is_a?(Array)

# encodes dynamic-sized arrays
head, tail = "", ""
head += encode_type Type.size_type, arg.size
head += encode_type(Type.size_type, arg.size)
nested_sub = type.nested_sub
nested_sub_size = type.nested_sub.size

Expand All @@ -102,8 +106,10 @@ def encode_type(type, arg)
offset += total_bytes_length + 32
end

head += encode_type Type.size_type, offset
head += encode_type(Type.size_type, offset)
end
elsif nested_sub.base_type == "tuple" && nested_sub.dynamic?
head += encode_struct_offsets(nested_sub, arg)
end

arg.size.times do |i|
Expand Down Expand Up @@ -145,6 +151,8 @@ def encode_primitive_type(type, arg)
return encode_fixed arg, type
when "string", "bytes"
return encode_bytes arg, type
when "tuple"
return encode_tuple arg, type
when "hash"
return encode_hash arg, type
when "address"
Expand Down Expand Up @@ -381,6 +389,56 @@ def encode_bytes(arg, type)
end
end

# Properly encodes tuples.
def encode_tuple(arg, type)
raise EncodingError, "Expecting Hash: #{arg}" unless arg.instance_of? Hash
raise EncodingError, "Expecting #{type.components.size} elements: #{arg}" unless arg.size == type.components.size

static_size = 0
type.components.each_with_index do |component, i|
if type.components[i].dynamic?
static_size += 32
else
static_size += Util.ceil32(type.components[i].size || 0)
end
end

dynamic_offset = static_size
offsets_and_static_values = []
dynamic_values = []

type.components.each_with_index do |component, i|
component_type = type.components[i]
if component_type.dynamic?
offsets_and_static_values << encode_type(Type.size_type, dynamic_offset)
dynamic_value = encode_type(component_type, arg.is_a?(Array) ? arg[i] : arg[component_type.name])
dynamic_values << dynamic_value
dynamic_offset += dynamic_value.size
else
offsets_and_static_values << encode_type(component_type, arg.is_a?(Array) ? arg[i] : arg[component_type.name])
end
end

offsets_and_static_values.join + dynamic_values.join
end

# Properly encode struct offsets.
def encode_struct_offsets(type, arg)
result = ""
offset = arg.size
tails_encoding = arg.map { |a| encode_type(type, a) }
arg.size.times do |i|
if i == 0
offset *= 32
else
offset += tails_encoding[i - 1].size
end
offset_string = encode_type(Type.size_type, offset)
result += offset_string
end
result
end

# Properly encodes hash-strings.
def encode_hash(arg, type)
size = type.sub_type.to_i
Expand Down
54 changes: 44 additions & 10 deletions lib/eth/abi/type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,28 @@ class ParseError < StandardError; end
# The dimension attribute, e.g., `[10]` for an array of size 10.
attr :dimensions

# The components of a tuple type.
attr :components

# The name of tuple component.
attr :name

# Create a new Type object for base types, sub types, and dimensions.
# Should not be used; use {Type.parse} instead.
#
# @param base_type [String] the base-type attribute.
# @param sub_type [String] the sub-type attribute.
# @param dimensions [Array] the dimension attribute.
# @param components [Array] the components attribute.
# @param component_name [String] the tuple component's name.
# @return [Eth::Abi::Type] an ABI type object.
def initialize(base_type, sub_type, dimensions)
def initialize(base_type, sub_type, dimensions, components = nil, component_name = nil)
sub_type = sub_type.to_s
@base_type = base_type
@sub_type = sub_type
@dimensions = dimensions
@components = components
@name = component_name
end

# Converts the self.parse method into a constructor.
Expand All @@ -56,9 +66,12 @@ def initialize(base_type, sub_type, dimensions)
# Creates a new Type upon success (using konstructor).
#
# @param type [String] a common Solidity type.
# @param components [Array] the components attribute.
# @param component_name [String] the tuple component's name.
# @return [Eth::Abi::Type] a parsed Type object.
# @raise [ParseError] if it fails to parse the type.
def parse(type)
def parse(type, components = nil, component_name = nil)
return type if type.is_a?(Type)
_, base_type, sub_type, dimension = /([a-z]*)([0-9]*x?[0-9]*)((\[[0-9]*\])*)/.match(type).to_a

# type dimension can only be numeric
Expand All @@ -73,6 +86,8 @@ def parse(type)
@base_type = base_type
@sub_type = sub_type
@dimensions = dims.map { |x| x[1...-1].to_i }
@components = components.map { |component| Eth::Abi::Type.parse(component["type"], component.dig("components"), component.dig("name")) } unless components.nil?
@name = component_name
end

# Creates a new uint256 type used for size.
Expand All @@ -98,15 +113,13 @@ def ==(another_type)
def size
s = nil
if dimensions.empty?
unless ["string", "bytes"].include?(base_type) and sub_type.empty?
if !(["string", "bytes", "tuple"].include?(base_type) and sub_type.empty?)
s = 32
elsif base_type == "tuple" && components.none?(&:dynamic?)
s = components.sum(&:size)
end
else
unless dimensions.last == 0
unless nested_sub.dynamic?
s = dimensions.last * nested_sub.size
end
end
elsif dimensions.last != 0 && !nested_sub.dynamic?
s = dimensions.last * nested_sub.size
end
@size ||= s
end
Expand All @@ -122,7 +135,24 @@ def dynamic?
#
# @return [Eth::Abi::Type] nested sub-type.
def nested_sub
@nested_sub ||= self.class.new(base_type, sub_type, dimensions[0...-1])
@nested_sub ||= self.class.new(base_type, sub_type, dimensions[0...-1], components, name)
end

# Allows exporting the type as string.
#
# @return [String] the type string.
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?
base_type
else
"#{base_type}#{sub_type}"
end
else
"#{base_type}#{sub_type}#{dimensions.map { |x| "[#{x == 0 ? "" : x}]" }.join}"
end
end

private
Expand All @@ -138,6 +168,10 @@ def validate_base_type(base_type, sub_type)

# 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
when "tuple"

# tuples can not have any suffix
raise ParseError, "Tuple type must have no suffix or numerical suffix" unless sub_type.empty?
when "uint", "int"

# integers must have a numerical suffix
Expand Down
2 changes: 1 addition & 1 deletion lib/eth/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ def call_raw(contract, func, *args, **kwargs)

# Encodes function call payloads.
def call_payload(fun, args)
types = fun.inputs.map { |i| i.type }
types = fun.inputs.map(&:parsed_type)
encoded_str = Util.bin_to_hex(Eth::Abi.encode(types, args))
Util.prefix_hex(fun.signature + (encoded_str.empty? ? "0" * 64 : encoded_str))
end
Expand Down
2 changes: 1 addition & 1 deletion lib/eth/contract/function.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def initialize(data)
# @param inputs [Array<Eth::Contract::FunctionInput>] function input class list.
# @return [String] function string.
def self.calc_signature(name, inputs)
"#{name}(#{inputs.collect { |x| x.raw_type }.join(",")})"
"#{name}(#{inputs.map { |x| x.parsed_type.to_s }.join(",")})"
end

# Encodes a function signature.
Expand Down
7 changes: 6 additions & 1 deletion lib/eth/contract/function_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,18 @@ class Contract::FunctionInput
# @param data [Hash] contract abi data.
def initialize(data)
@raw_type = data["type"]
@type = Eth::Abi::Type.parse(data["type"])
@type = Eth::Abi::Type.parse(data["type"], data["components"])
@name = data["name"]
end

# Returns complete types with subtypes, e.g., `uint256`.
def type
@type.base_type + @type.sub_type + @type.dimensions.map { |dimension| "[#{dimension > 0 ? dimension : ""}]" }.join("")
end

# Returns parsed types.
def parsed_type
@type
end
end
end
107 changes: 107 additions & 0 deletions spec/eth/abi/type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
expect(Abi::Type.new "bytes", "32", []).to eq Abi::Type.parse("bytes32")
expect(Abi::Type.new "uint", 256, [10]).to eq Abi::Type.parse("uint256[10]")
expect(Abi::Type.new "fixed", "128x128", [1, 2, 3, 0]).to eq Abi::Type.parse("fixed128x128[1][2][3][]")
expect(Abi::Type.new "tuple", nil, []).to eq Abi::Type.parse("tuple")
expect(Abi::Type.new "tuple", nil, [0]).to eq Abi::Type.parse("tuple[]")
expect(Abi::Type.new "tuple", nil, [10]).to eq Abi::Type.parse("tuple[10]")
end
end

Expand Down Expand Up @@ -51,6 +54,27 @@
end
end

describe ".dynamic?" do
it "can tell if a type is dynamic" do
expect(Abi::Type.parse("string").dynamic?).to eq(true)
expect(Abi::Type.parse("bytes").dynamic?).to eq(true)
expect(Abi::Type.parse("uint256[]").dynamic?).to eq(true)
expect(Abi::Type.parse("uint256[4][]").dynamic?).to eq(true)

expect(Abi::Type.parse("bytes32").dynamic?).to eq(false)
expect(Abi::Type.parse("uint256").dynamic?).to eq(false)
expect(Abi::Type.parse("fixed128x128").dynamic?).to eq(false)
expect(Abi::Type.parse("bool").dynamic?).to eq(false)

expect(Abi::Type.parse("uint256[2]").dynamic?).to eq(false)
expect(Abi::Type.parse("address[2][2]").dynamic?).to eq(false)
expect(Abi::Type.parse("ufixed192x64[2][2][2][2][2]").dynamic?).to eq(false)

expect(Abi::Type.parse("tuple[]", [{ "type" => "bytes8" }]).dynamic?).to eq(true)
expect(Abi::Type.parse("tuple[2]", [{ "type" => "bytes8" }]).dynamic?).to eq(false)
end
end

describe ".size .nested_sub" do
it "can compute the type size" do

Expand All @@ -68,6 +92,9 @@
expect(Abi::Type.parse("uint256[2]").size).to eq 64
expect(Abi::Type.parse("address[2][2]").size).to eq 128
expect(Abi::Type.parse("ufixed192x64[2][2][2][2][2]").size).to eq 1024

expect(Abi::Type.parse("tuple[]", [{ "type" => "uint256" }]).size).to eq(nil)
expect(Abi::Type.parse("tuple[10]", [{ "type" => "uint256" }]).size).to eq(320)
end

it "can nest sub types" do
Expand All @@ -78,4 +105,84 @@
expect(Abi::Type.parse("uint256[2][2]").nested_sub.dimensions).to eq [2]
end
end

describe ".to_s" do
it "serializes type into raw type string" do
expect(Abi::Type.parse("string").to_s).to eq("string")
expect(Abi::Type.parse("bytes").to_s).to eq("bytes")
expect(Abi::Type.parse("bytes32").to_s).to eq("bytes32")
expect(Abi::Type.parse("string[]").to_s).to eq("string[]")
expect(Abi::Type.parse("string[10]").to_s).to eq("string[10]")
expect(Abi::Type.parse("uint256").to_s).to eq("uint256")
expect(Abi::Type.parse("uint256[2]").to_s).to eq("uint256[2]")
expect(Abi::Type.parse("uint256[2][]").to_s).to eq("uint256[2][]")
expect(Abi::Type.parse("uint256[2][2]").to_s).to eq("uint256[2][2]")
expect(Abi::Type.parse("tuple[]", [
{
"type" => "string",
},
{
"type" => "bytes",
},
]).to_s).to eq("(string,bytes)[]")
expect(Abi::Type.parse("tuple[10]", [
{
"type" => "string",
},
{
"type" => "bytes",
},
]).to_s).to eq("(string,bytes)[10]")
expect(Abi::Type.parse("tuple", [
{
"type" => "string",
},
{
"type" => "string",
},
{
"components" => [
{
"type" => "uint256",
},
{
"type" => "string",
},
{
"components" => [
{
"type" => "string",
},
{
"type" => "bytes",
},
],
"type" => "tuple",
},
],
"type" => "tuple[]",
},
{
"type" => "uint256",
},
{
"type" => "string[]",
},
{
"type" => "bytes[10]",
},
{
"components" => [
{
"type" => "string",
},
{
"type" => "bytes",
},
],
"type" => "tuple",
},
]).to_s).to eq("(string,string,(uint256,string,(string,bytes))[],uint256,string[],bytes[10],(string,bytes))")
end
end
end
Loading

0 comments on commit 490f28d

Please sign in to comment.