eth 0.5.14 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ # Copyright (c) 2016-2025 The Ruby-Eth Contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require "socket"
16
+ require "openssl"
17
+ require "uri"
18
+ require "base64"
19
+ require "securerandom"
20
+ require "digest/sha1"
21
+ require "thread"
22
+ require "ipaddr"
23
+
24
+ # Provides the {Eth} module.
25
+ module Eth
26
+
27
+ # Provides a WS/S-RPC client with automatic reconnection support.
28
+ class Client::Ws < Client
29
+
30
+ # The host of the WebSocket endpoint.
31
+ attr_reader :host
32
+
33
+ # The port of the WebSocket endpoint.
34
+ attr_reader :port
35
+
36
+ # The full URI of the WebSocket endpoint, including path.
37
+ attr_reader :uri
38
+
39
+ # Attribute indicator for SSL.
40
+ attr_reader :ssl
41
+
42
+ # Constructor for the WebSocket client. Should not be used; use
43
+ # {Client.create} instead.
44
+ #
45
+ # @param host [String] a URI pointing to a WebSocket RPC-API.
46
+ def initialize(host)
47
+ super
48
+ @uri = URI.parse(host)
49
+ raise ArgumentError, "Unable to parse the WebSocket-URI!" unless %w[ws wss].include?(@uri.scheme)
50
+ @host = @uri.host
51
+ @port = @uri.port
52
+ @ssl = @uri.scheme == "wss"
53
+ @path = build_path(@uri)
54
+ @mutex = Mutex.new
55
+ @socket = nil
56
+ @fragments = nil
57
+ end
58
+
59
+ # Sends an RPC request to the connected WebSocket endpoint.
60
+ #
61
+ # @param payload [Hash] the RPC request parameters.
62
+ # @return [String] a JSON-encoded response.
63
+ def send_request(payload)
64
+ attempts = 0
65
+ begin
66
+ attempts += 1
67
+ @mutex.synchronize do
68
+ ensure_socket
69
+ write_frame(@socket, payload)
70
+ return read_message(@socket)
71
+ end
72
+ rescue IOError, SystemCallError => e
73
+ @mutex.synchronize { close_socket }
74
+ retry if attempts < 2
75
+ raise e
76
+ end
77
+ end
78
+
79
+ # Closes the underlying WebSocket connection.
80
+ #
81
+ # @return [void]
82
+ def close
83
+ @mutex.synchronize { close_socket }
84
+ end
85
+
86
+ private
87
+
88
+ def ensure_socket
89
+ return if @socket && !@socket.closed?
90
+
91
+ socket = open_socket
92
+ begin
93
+ perform_handshake(socket)
94
+ @socket = socket
95
+ @fragments = nil
96
+ rescue StandardError
97
+ begin
98
+ socket.close unless socket.closed?
99
+ rescue IOError, SystemCallError
100
+ nil
101
+ end
102
+ @socket = nil
103
+ raise
104
+ end
105
+ end
106
+
107
+ # Establishes the TCP socket for the RPC connection and upgrades it to TLS
108
+ # when a secure endpoint is requested. TLS sessions enforce peer
109
+ # verification, load the default system trust store, and enable hostname
110
+ # verification when the current OpenSSL bindings support it.
111
+ #
112
+ # @return [TCPSocket, OpenSSL::SSL::SSLSocket] the established socket.
113
+ # @raise [IOError, SystemCallError, OpenSSL::SSL::SSLError] if the socket
114
+ # cannot be opened or the TLS handshake fails.
115
+ def open_socket
116
+ tcp = TCPSocket.new(@host, @port)
117
+ return tcp unless @ssl
118
+
119
+ context = OpenSSL::SSL::SSLContext.new
120
+ params = { verify_mode: OpenSSL::SSL::VERIFY_PEER }
121
+ params[:verify_hostname] = true if context.respond_to?(:verify_hostname=)
122
+ context.set_params(params)
123
+ context.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
124
+
125
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp, context)
126
+ ssl_socket.hostname = @host
127
+ ssl_socket.sync_close = true
128
+ ssl_socket.connect
129
+ ssl_socket
130
+ end
131
+
132
+ def perform_handshake(socket)
133
+ key = Base64.strict_encode64(SecureRandom.random_bytes(16))
134
+ request = build_handshake_request(key)
135
+ socket.write(request)
136
+ response = read_handshake_response(socket)
137
+ verify_handshake!(response, key)
138
+ end
139
+
140
+ def build_handshake_request(key)
141
+ origin = build_origin_header
142
+ host_header = build_host_header
143
+ "GET #{@path} HTTP/1.1\r\n" \
144
+ "Host: #{host_header}\r\n" \
145
+ "Upgrade: websocket\r\n" \
146
+ "Connection: Upgrade\r\n" \
147
+ "Sec-WebSocket-Version: 13\r\n" \
148
+ "Sec-WebSocket-Key: #{key}\r\n" \
149
+ "Origin: #{origin}\r\n\r\n"
150
+ end
151
+
152
+ def read_handshake_response(socket)
153
+ response = +""
154
+ until response.end_with?("\r\n\r\n")
155
+ chunk = socket.readpartial(1024)
156
+ raise IOError, "Incomplete WebSocket handshake" if chunk.nil?
157
+ response << chunk
158
+ end
159
+ response
160
+ rescue EOFError
161
+ raise IOError, "Incomplete WebSocket handshake"
162
+ end
163
+
164
+ def verify_handshake!(response, key)
165
+ status_line = response.lines.first&.strip
166
+ unless status_line&.start_with?("HTTP/1.1 101")
167
+ raise IOError, "WebSocket handshake failed (status: #{status_line || "unknown"})"
168
+ end
169
+
170
+ accept = response[/Sec-WebSocket-Accept:\s*(.+)\r/i, 1]&.strip
171
+ expected = Base64.strict_encode64(Digest::SHA1.digest("#{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
172
+ raise IOError, "WebSocket handshake failed (missing accept header)" unless accept
173
+ raise IOError, "WebSocket handshake failed (invalid accept header)" unless accept == expected
174
+ end
175
+
176
+ def write_frame(socket, payload, opcode = 0x1)
177
+ frame_payload = payload.is_a?(String) ? payload.dup : payload.to_s
178
+ mask_key = SecureRandom.random_bytes(4)
179
+ header = [0x80 | opcode]
180
+
181
+ length = frame_payload.bytesize
182
+ if length <= 125
183
+ header << (0x80 | length)
184
+ elsif length <= 0xFFFF
185
+ header << (0x80 | 126)
186
+ header.concat([length].pack("n").bytes)
187
+ else
188
+ header << (0x80 | 127)
189
+ header.concat([length].pack("Q>").bytes)
190
+ end
191
+
192
+ masked_payload = apply_mask(frame_payload, mask_key)
193
+ socket.write(header.pack("C*") + mask_key + masked_payload)
194
+ end
195
+
196
+ def read_message(socket)
197
+ loop do
198
+ frame = read_frame(socket)
199
+ return frame if frame
200
+ end
201
+ end
202
+
203
+ def read_frame(socket)
204
+ header = read_bytes(socket, 2)
205
+ byte1, byte2 = header.bytes
206
+ opcode = byte1 & 0x0F
207
+ masked = (byte2 & 0x80) == 0x80
208
+ length = byte2 & 0x7F
209
+
210
+ length = read_bytes(socket, 2).unpack1("n") if length == 126
211
+ length = read_bytes(socket, 8).unpack1("Q>") if length == 127
212
+
213
+ mask_key = masked ? read_bytes(socket, 4).bytes : nil
214
+ payload = read_bytes(socket, length)
215
+ payload_bytes = payload.bytes
216
+ if mask_key
217
+ payload_bytes.map!.with_index { |byte, index| byte ^ mask_key[index % 4] }
218
+ end
219
+ data = payload_bytes.pack("C*")
220
+
221
+ case opcode
222
+ when 0x0
223
+ (@fragments ||= +"") << data
224
+ if (byte1 & 0x80) == 0x80
225
+ message = @fragments.dup
226
+ @fragments = nil
227
+ message
228
+ else
229
+ nil
230
+ end
231
+ when 0x1, 0x2
232
+ if (byte1 & 0x80) == 0x80
233
+ data
234
+ else
235
+ @fragments = data
236
+ nil
237
+ end
238
+ when 0x8
239
+ close_socket
240
+ raise IOError, "WebSocket closed"
241
+ when 0x9
242
+ write_frame(socket, data, 0xA)
243
+ nil
244
+ when 0xA
245
+ nil
246
+ else
247
+ nil
248
+ end
249
+ end
250
+
251
+ def read_bytes(socket, length)
252
+ data = +""
253
+ while data.bytesize < length
254
+ chunk = socket.read(length - data.bytesize)
255
+ raise IOError, "Unexpected end of WebSocket stream" if chunk.nil? || chunk.empty?
256
+ data << chunk
257
+ end
258
+ data
259
+ end
260
+
261
+ def apply_mask(payload, mask_key)
262
+ mask_bytes = mask_key.bytes
263
+ payload.bytes.map.with_index { |byte, index| byte ^ mask_bytes[index % 4] }.pack("C*")
264
+ end
265
+
266
+ def build_origin_header
267
+ scheme = @ssl ? "https" : "http"
268
+ host = format_origin_host(@uri.host)
269
+ default_port = @ssl ? 443 : 80
270
+ port = @uri.port
271
+ port_suffix = port == default_port ? "" : ":#{port}"
272
+ "#{scheme}://#{host}#{port_suffix}"
273
+ end
274
+
275
+ def build_host_header
276
+ "#{format_host(@uri.host)}:#{@uri.port}"
277
+ end
278
+
279
+ def format_origin_host(host)
280
+ return "localhost" if loopback_host?(host)
281
+ format_host(host)
282
+ end
283
+
284
+ def format_host(host)
285
+ return host unless host&.include?(":")
286
+ host.start_with?("[") ? host : "[#{host}]"
287
+ end
288
+
289
+ def loopback_host?(host)
290
+ return false if host.nil?
291
+ return true if host == "localhost"
292
+ IPAddr.new(host).loopback?
293
+ rescue IPAddr::InvalidAddressError
294
+ false
295
+ end
296
+
297
+ def close_socket
298
+ return unless @socket
299
+
300
+ begin
301
+ write_frame(@socket, [1000].pack("n"), 0x8)
302
+ rescue IOError, SystemCallError
303
+ # ignore errors while closing
304
+ ensure
305
+ begin
306
+ @socket.close unless @socket.closed?
307
+ rescue IOError, SystemCallError
308
+ nil
309
+ end
310
+ @socket = nil
311
+ @fragments = nil
312
+ end
313
+ end
314
+
315
+ def build_path(uri)
316
+ path = uri.path
317
+ path = "/" if path.nil? || path.empty?
318
+ query = uri.query
319
+ path += "?#{query}" if query
320
+ path
321
+ end
322
+ end
323
+ end
data/lib/eth/client.rb CHANGED
@@ -16,7 +16,7 @@
16
16
  module Eth
17
17
 
18
18
  # Provides the {Eth::Client} super-class to connect to Ethereum
19
- # network's RPC-API endpoints (IPC or HTTP).
19
+ # network's RPC-API endpoints (IPC, HTTP/S, or WS/S).
20
20
  class Client
21
21
 
22
22
  # The client's RPC-request ID starting at 0.
@@ -40,20 +40,37 @@ module Eth
40
40
  # A custom error type if a contract interaction fails.
41
41
  class ContractExecutionError < StandardError; end
42
42
 
43
- # Creates a new RPC-Client, either by providing an HTTP/S host or
44
- # an IPC path. Supports basic authentication with username and password.
43
+ # Raised when an RPC call returns an error. Carries the optional
44
+ # hex-encoded error data to support custom error decoding.
45
+ class RpcError < IOError
46
+ attr_reader :data
47
+
48
+ # Constructor for the {RpcError} class.
49
+ #
50
+ # @param message [String] the error message returned by the RPC.
51
+ # @param data [String] optional hex encoded error data.
52
+ def initialize(message, data = nil)
53
+ super(message)
54
+ @data = data
55
+ end
56
+ end
57
+
58
+ # Creates a new RPC-Client, either by providing an HTTP/S host, WS/S host,
59
+ # or an IPC path. Supports basic authentication with username and password.
45
60
  #
46
61
  # **Note**, this sets the folling gas defaults: {Tx::DEFAULT_PRIORITY_FEE}
47
62
  # and {Tx::DEFAULT_GAS_PRICE. Use {#max_priority_fee_per_gas} and
48
63
  # {#max_fee_per_gas} to set custom values prior to submitting transactions.
49
64
  #
50
- # @param host [String] either an HTTP/S host or an IPC path.
65
+ # @param host [String] either an HTTP/S host, WS/S host, or an IPC path.
51
66
  # @return [Eth::Client::Ipc] an IPC client.
52
67
  # @return [Eth::Client::Http] an HTTP client.
68
+ # @return [Eth::Client::Ws] a WebSocket client.
53
69
  # @raise [ArgumentError] in case it cannot determine the client type.
54
70
  def self.create(host)
55
71
  return Client::Ipc.new host if host.end_with? ".ipc"
56
72
  return Client::Http.new host if host.start_with? "http"
73
+ return Client::Ws.new host if host.start_with? "ws"
57
74
  raise ArgumentError, "Unable to detect client type!"
58
75
  end
59
76
 
@@ -251,21 +268,28 @@ module Eth
251
268
  # @param **sender_key [Eth::Key] the sender private key.
252
269
  # @param **legacy [Boolean] enables legacy transactions (pre-EIP-1559).
253
270
  # @return [Object] returns the result of the call.
271
+ # @see https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_call
254
272
  def call(contract, function, *args, **kwargs)
255
- func = contract.functions.select { |func| func.name == function }
256
- raise ArgumentError, "this function does not exist!" if func.nil? || func.size === 0
257
- selected_func = func.first
258
- func.each do |f|
259
- if f.inputs.size === args.size
260
- selected_func = f
261
- end
262
- end
263
- output = call_raw(contract, selected_func, *args, **kwargs)
273
+ function = contract.function(function, args: args.size)
274
+ output = function.decode_call_result(
275
+ eth_call(
276
+ {
277
+ data: function.encode_call(*args),
278
+ to: kwargs[:address] || contract.address,
279
+ from: kwargs[:from],
280
+ gas: kwargs[:gas],
281
+ gasPrice: kwargs[:gas_price],
282
+ value: kwargs[:value],
283
+ }.compact
284
+ )["result"]
285
+ )
264
286
  if output&.length == 1
265
287
  output[0]
266
288
  else
267
289
  output
268
290
  end
291
+ rescue RpcError => e
292
+ raise ContractExecutionError, contract.decode_error(e)
269
293
  end
270
294
 
271
295
  # Executes a contract function with a transaction (transactional
@@ -298,13 +322,12 @@ module Eth
298
322
  else
299
323
  Tx.estimate_intrinsic_gas(contract.bin)
300
324
  end
301
- fun = contract.functions.select { |func| func.name == function }[0]
302
325
  params = {
303
326
  value: kwargs[:tx_value] || 0,
304
327
  gas_limit: gas_limit,
305
328
  chain_id: chain_id,
306
329
  to: kwargs[:address] || contract.address,
307
- data: call_payload(fun, args),
330
+ data: contract.function(function, args: args.size).encode_call(*args),
308
331
  }
309
332
  send_transaction(params, kwargs[:legacy], kwargs[:sender_key], kwargs[:nonce])
310
333
  end
@@ -320,8 +343,8 @@ module Eth
320
343
  begin
321
344
  hash = wait_for_tx(transact(contract, function, *args, **kwargs))
322
345
  return hash, tx_succeeded?(hash)
323
- rescue IOError => e
324
- raise ContractExecutionError, e
346
+ rescue RpcError => e
347
+ raise ContractExecutionError, contract.decode_error(e)
325
348
  end
326
349
  end
327
350
 
@@ -442,28 +465,6 @@ module Eth
442
465
  end
443
466
  end
444
467
 
445
- # Non-transactional function call called from call().
446
- # @see https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_call
447
- def call_raw(contract, func, *args, **kwargs)
448
- params = {
449
- data: call_payload(func, args),
450
- to: kwargs[:address] || contract.address,
451
- from: kwargs[:from],
452
- }.compact
453
-
454
- raw_result = eth_call(params)["result"]
455
- types = func.outputs.map { |i| i.type }
456
- return nil if raw_result == "0x"
457
- Eth::Abi.decode(types, raw_result)
458
- end
459
-
460
- # Encodes function call payloads.
461
- def call_payload(fun, args)
462
- types = fun.inputs.map(&:parsed_type)
463
- encoded_str = Util.bin_to_hex(Eth::Abi.encode(types, args))
464
- Util.prefix_hex(fun.signature + (encoded_str.empty? ? "0" * 64 : encoded_str))
465
- end
466
-
467
468
  # Encodes constructor params
468
469
  def encode_constructor_params(contract, args)
469
470
  types = contract.constructor_inputs.map { |input| input.type }
@@ -481,7 +482,9 @@ module Eth
481
482
  id: next_id,
482
483
  }
483
484
  output = JSON.parse(send_request(payload.to_json))
484
- raise IOError, output["error"]["message"] unless output["error"].nil?
485
+ if (err = output["error"])
486
+ raise RpcError.new(err["message"], err["data"])
487
+ end
485
488
  output
486
489
  end
487
490
 
@@ -523,3 +526,4 @@ end
523
526
  # Load the client/* libraries
524
527
  require "eth/client/http"
525
528
  require "eth/client/ipc"
529
+ require "eth/client/ws"
@@ -0,0 +1,62 @@
1
+ # Copyright (c) 2016-2025 The Ruby-Eth Contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # -*- encoding : ascii-8bit -*-
16
+
17
+ # Provides the {Eth} module.
18
+ module Eth
19
+ # Provide classes for contract custom errors.
20
+ class Contract::Error
21
+ attr_accessor :name, :inputs, :signature, :error_string
22
+
23
+ # Constructor of the {Eth::Contract::Error} class.
24
+ #
25
+ # @param data [Hash] contract abi data for the error.
26
+ def initialize(data)
27
+ @name = data["name"]
28
+ @inputs = data.fetch("inputs", []).map do |input|
29
+ Eth::Contract::FunctionInput.new(input)
30
+ end
31
+ @error_string = self.class.calc_signature(@name, @inputs)
32
+ @signature = self.class.encoded_error_signature(@error_string)
33
+ end
34
+
35
+ # Creates error strings.
36
+ #
37
+ # @param name [String] error name.
38
+ # @param inputs [Array<Eth::Contract::FunctionInput>] error input class list.
39
+ # @return [String] error string.
40
+ def self.calc_signature(name, inputs)
41
+ "#{name}(#{inputs.map { |x| x.parsed_type.to_s }.join(",")})"
42
+ end
43
+
44
+ # Encodes an error signature.
45
+ #
46
+ # @param signature [String] error signature.
47
+ # @return [String] encoded error signature string.
48
+ def self.encoded_error_signature(signature)
49
+ Util.prefix_hex(Util.bin_to_hex(Util.keccak256(signature)[0..3]))
50
+ end
51
+
52
+ # Decodes a revert error payload.
53
+ #
54
+ # @param data [String] the hex-encoded revert data including selector.
55
+ # @return [Array] decoded error arguments.
56
+ def decode(data)
57
+ types = inputs.map(&:type)
58
+ payload = "0x" + data[10..]
59
+ Eth::Abi.decode(types, payload)
60
+ end
61
+ end
62
+ end
@@ -53,5 +53,26 @@ module Eth
53
53
  def self.encoded_function_signature(signature)
54
54
  Util.bin_to_hex Util.keccak256(signature)[0..3]
55
55
  end
56
+
57
+ # Encodes a function call arguments
58
+ #
59
+ # @param args [Array] function arguments
60
+ # @return [String] encoded function call data
61
+ def encode_call(*args)
62
+ types = inputs.map(&:parsed_type)
63
+ encoded_str = Util.bin_to_hex(Eth::Abi.encode(types, args))
64
+ Util.prefix_hex(signature + (encoded_str.empty? ? "0" * 64 : encoded_str))
65
+ end
66
+
67
+ # Decodes a function call result
68
+ #
69
+ # @param data [String] eth_call result in hex format
70
+ # @return [Array]
71
+ def decode_call_result(data)
72
+ return nil if data == "0x"
73
+
74
+ types = outputs.map(&:parsed_type)
75
+ Eth::Abi.decode(types, data)
76
+ end
56
77
  end
57
78
  end
@@ -19,19 +19,27 @@ module Eth
19
19
 
20
20
  # Provide classes for contract function output.
21
21
  class Contract::FunctionOutput
22
- attr_accessor :type, :name
22
+ attr_accessor :type, :raw_type, :name
23
23
 
24
24
  # Constructor of the {Eth::Contract::FunctionOutput} class.
25
25
  #
26
26
  # @param data [Hash] contract abi data.
27
27
  def initialize(data)
28
- @type = Eth::Abi::Type.parse(data["type"])
28
+ @raw_type = data["type"]
29
+ @type = Eth::Abi::Type.parse(data["type"], data["components"])
29
30
  @name = data["name"]
30
31
  end
31
32
 
32
33
  # Returns complete types with subtypes, e.g., `uint256`.
33
34
  def type
34
- @type.base_type + @type.sub_type + @type.dimensions.map { |dimension| "[#{dimension > 0 ? dimension : ""}]" }.join("")
35
+ @type.base_type +
36
+ @type.sub_type +
37
+ @type.dimensions.map { |dimension| "[#{dimension > 0 ? dimension : ""}]" }.join("")
38
+ end
39
+
40
+ # Returns parsed types.
41
+ def parsed_type
42
+ @type
35
43
  end
36
44
  end
37
45
  end
data/lib/eth/contract.rb CHANGED
@@ -25,7 +25,7 @@ module Eth
25
25
  attr_accessor :key
26
26
  attr_accessor :gas_limit, :gas_price, :max_fee_per_gas, :max_priority_fee_per_gas, :nonce
27
27
  attr_accessor :bin, :name, :abi, :class_object
28
- attr_accessor :events, :functions, :constructor_inputs
28
+ attr_accessor :events, :functions, :constructor_inputs, :errors
29
29
 
30
30
  # Constructor of the {Eth::Contract} class.
31
31
  #
@@ -44,7 +44,7 @@ module Eth
44
44
  @name = _name
45
45
  @bin = bin
46
46
  @abi = abi
47
- @constructor_inputs, @functions, @events = parse_abi(abi)
47
+ @constructor_inputs, @functions, @events, @errors = parse_abi(abi)
48
48
  end
49
49
 
50
50
  # Creates a contract wrapper from a Solidity file.
@@ -106,6 +106,52 @@ module Eth
106
106
  end
107
107
  end
108
108
 
109
+ # Finds a function by name.
110
+ #
111
+ # @param name [String] function name.
112
+ # @param args [Integer, nil] number of arguments of a function.
113
+ # @return [Eth::Contract::Function] function object.
114
+ # @raise [ArgumentError] if function not found.
115
+ def function(name, args: nil)
116
+ functions.find do |f|
117
+ f.name == name && (args.nil? || args == f.inputs.size)
118
+ end || raise(ArgumentError, "this function does not exist!")
119
+ end
120
+
121
+ # Finds an error by name.
122
+ #
123
+ # @param name [String] error name.
124
+ # @param args [Integer, nil] number of arguments of an error.
125
+ # @return [Eth::Contract::Error] error object.
126
+ # @raise [ArgumentError] if error not found.
127
+ def error(name, args: nil)
128
+ errors.find do |e|
129
+ e.name == name && (args.nil? || args == e.inputs.size)
130
+ end || raise(ArgumentError, "this error does not exist!")
131
+ end
132
+
133
+ # Decodes a custom error returned by an RPC error using the contract ABI.
134
+ #
135
+ # @param rpc_error [RpcError] the RPC error containing revert data.
136
+ # @return [String] a human readable error message.
137
+ def decode_error(rpc_error)
138
+ data = rpc_error.data
139
+ return rpc_error.message if data.nil? || errors.nil?
140
+
141
+ signature = data[0, 10]
142
+ if (err = errors.find { |e| e.signature == signature })
143
+ values = err.decode(data)
144
+ args = values&.map { |v| v.is_a?(String) ? v : v.inspect }&.join(",")
145
+ args ||= ""
146
+ "execution reverted: #{err.name}(#{args})"
147
+ elsif signature == "0x08c379a0"
148
+ reason = Abi.decode(["string"], "0x" + data[10..])&.first
149
+ "execution reverted: #{reason}"
150
+ else
151
+ rpc_error.message
152
+ end
153
+ end
154
+
109
155
  # Create meta classes for smart contracts.
110
156
  def build
111
157
  class_name = @name
@@ -116,9 +162,12 @@ module Eth
116
162
  def_delegators :parent, :name, :abi, :bin
117
163
  def_delegators :parent, :gas_limit, :gas_price, :gas_limit=, :gas_price=, :nonce, :nonce=
118
164
  def_delegators :parent, :max_fee_per_gas, :max_fee_per_gas=, :max_priority_fee_per_gas, :max_priority_fee_per_gas=
119
- def_delegators :parent, :events
165
+ def_delegators :parent, :events, :errors
120
166
  def_delegators :parent, :address, :address=
121
167
  def_delegator :parent, :functions
168
+ def_delegator :parent, :function
169
+ def_delegator :parent, :error
170
+ def_delegator :parent, :decode_error
122
171
  def_delegator :parent, :constructor_inputs
123
172
  define_method :parent do
124
173
  parent
@@ -140,7 +189,8 @@ module Eth
140
189
  end
141
190
  functions = abi.select { |x| x["type"] == "function" }.map { |fun| Eth::Contract::Function.new(fun) }
142
191
  events = abi.select { |x| x["type"] == "event" }.map { |evt| Eth::Contract::Event.new(evt) }
143
- [constructor_inputs, functions, events]
192
+ errors = abi.select { |x| x["type"] == "error" }.map { |err| Eth::Contract::Error.new(err) }
193
+ [constructor_inputs, functions, events, errors]
144
194
  end
145
195
  end
146
196
  end
@@ -151,3 +201,4 @@ require "eth/contract/function"
151
201
  require "eth/contract/function_input"
152
202
  require "eth/contract/function_output"
153
203
  require "eth/contract/initializer"
204
+ require "eth/contract/error"