eth 0.5.14 → 0.5.17

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,40 @@ 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 error code and the optional
44
+ # hex-encoded error data to support custom error decoding.
45
+ class RpcError < IOError
46
+ attr_reader :data
47
+ attr_reader :code
48
+
49
+ # Constructor for the {RpcError} class.
50
+ #
51
+ # @param message [String] the error message returned by the RPC.
52
+ # @param data [String] optional hex encoded error data.
53
+ # @param code [String] optional error code returned by the RPC.
54
+ def initialize(message, data = nil, code = nil)
55
+ super(message)
56
+ @data = data
57
+ @code = code
58
+ end
59
+ end
60
+
61
+ # Creates a new RPC-Client, either by providing an HTTP/S host, WS/S host,
62
+ # or an IPC path. Supports basic authentication with username and password.
45
63
  #
46
- # **Note**, this sets the folling gas defaults: {Tx::DEFAULT_PRIORITY_FEE}
64
+ # **Note**, this sets the following gas defaults: {Tx::DEFAULT_PRIORITY_FEE}
47
65
  # and {Tx::DEFAULT_GAS_PRICE. Use {#max_priority_fee_per_gas} and
48
66
  # {#max_fee_per_gas} to set custom values prior to submitting transactions.
49
67
  #
50
- # @param host [String] either an HTTP/S host or an IPC path.
68
+ # @param host [String] either an HTTP/S host, WS/S host, or an IPC path.
51
69
  # @return [Eth::Client::Ipc] an IPC client.
52
70
  # @return [Eth::Client::Http] an HTTP client.
71
+ # @return [Eth::Client::Ws] a WebSocket client.
53
72
  # @raise [ArgumentError] in case it cannot determine the client type.
54
73
  def self.create(host)
55
74
  return Client::Ipc.new host if host.end_with? ".ipc"
56
75
  return Client::Http.new host if host.start_with? "http"
76
+ return Client::Ws.new host if host.start_with? "ws"
57
77
  raise ArgumentError, "Unable to detect client type!"
58
78
  end
59
79
 
@@ -251,21 +271,28 @@ module Eth
251
271
  # @param **sender_key [Eth::Key] the sender private key.
252
272
  # @param **legacy [Boolean] enables legacy transactions (pre-EIP-1559).
253
273
  # @return [Object] returns the result of the call.
274
+ # @see https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_call
254
275
  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)
276
+ function = contract.function(function, args: args.size)
277
+ output = function.decode_call_result(
278
+ eth_call(
279
+ {
280
+ data: function.encode_call(*args),
281
+ to: kwargs[:address] || contract.address,
282
+ from: kwargs[:from],
283
+ gas: kwargs[:gas],
284
+ gasPrice: kwargs[:gas_price],
285
+ value: kwargs[:value],
286
+ }.compact
287
+ )["result"]
288
+ )
264
289
  if output&.length == 1
265
290
  output[0]
266
291
  else
267
292
  output
268
293
  end
294
+ rescue RpcError => e
295
+ raise ContractExecutionError, contract.decode_error(e)
269
296
  end
270
297
 
271
298
  # Executes a contract function with a transaction (transactional
@@ -298,13 +325,12 @@ module Eth
298
325
  else
299
326
  Tx.estimate_intrinsic_gas(contract.bin)
300
327
  end
301
- fun = contract.functions.select { |func| func.name == function }[0]
302
328
  params = {
303
329
  value: kwargs[:tx_value] || 0,
304
330
  gas_limit: gas_limit,
305
331
  chain_id: chain_id,
306
332
  to: kwargs[:address] || contract.address,
307
- data: call_payload(fun, args),
333
+ data: contract.function(function, args: args.size).encode_call(*args),
308
334
  }
309
335
  send_transaction(params, kwargs[:legacy], kwargs[:sender_key], kwargs[:nonce])
310
336
  end
@@ -320,8 +346,8 @@ module Eth
320
346
  begin
321
347
  hash = wait_for_tx(transact(contract, function, *args, **kwargs))
322
348
  return hash, tx_succeeded?(hash)
323
- rescue IOError => e
324
- raise ContractExecutionError, e
349
+ rescue RpcError => e
350
+ raise ContractExecutionError, contract.decode_error(e)
325
351
  end
326
352
  end
327
353
 
@@ -442,28 +468,6 @@ module Eth
442
468
  end
443
469
  end
444
470
 
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
471
  # Encodes constructor params
468
472
  def encode_constructor_params(contract, args)
469
473
  types = contract.constructor_inputs.map { |input| input.type }
@@ -481,7 +485,9 @@ module Eth
481
485
  id: next_id,
482
486
  }
483
487
  output = JSON.parse(send_request(payload.to_json))
484
- raise IOError, output["error"]["message"] unless output["error"].nil?
488
+ if (err = output["error"])
489
+ raise RpcError.new(err["message"], err["data"], err["code"])
490
+ end
485
491
  output
486
492
  end
487
493
 
@@ -523,3 +529,4 @@ end
523
529
  # Load the client/* libraries
524
530
  require "eth/client/http"
525
531
  require "eth/client/ipc"
532
+ 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