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.
- checksums.yaml +4 -4
- data/.github/workflows/codeql.yml +4 -4
- data/.github/workflows/docs.yml +1 -1
- data/.github/workflows/spec.yml +31 -13
- data/CHANGELOG.md +53 -0
- data/CODE_OF_CONDUCT.md +3 -5
- data/Gemfile +3 -3
- data/README.md +8 -6
- data/SECURITY.md +2 -2
- data/eth.gemspec +10 -1
- data/lib/eth/abi/decoder.rb +94 -39
- data/lib/eth/abi/encoder.rb +85 -58
- data/lib/eth/abi/function.rb +124 -0
- data/lib/eth/abi/type.rb +69 -5
- data/lib/eth/abi.rb +2 -0
- data/lib/eth/bls.rb +68 -0
- data/lib/eth/chain.rb +3 -0
- data/lib/eth/client/http.rb +5 -8
- data/lib/eth/client/ws.rb +323 -0
- data/lib/eth/client.rb +44 -40
- data/lib/eth/contract/error.rb +62 -0
- data/lib/eth/contract/function.rb +21 -0
- data/lib/eth/contract/function_output.rb +11 -3
- data/lib/eth/contract.rb +55 -4
- data/lib/eth/eip712.rb +48 -13
- data/lib/eth/key.rb +1 -1
- data/lib/eth/tx/eip1559.rb +32 -7
- data/lib/eth/tx/eip2930.rb +31 -6
- data/lib/eth/tx/eip4844.rb +401 -0
- data/lib/eth/tx/eip7702.rb +34 -9
- data/lib/eth/tx/legacy.rb +30 -6
- data/lib/eth/tx.rb +45 -4
- data/lib/eth/util.rb +19 -7
- data/lib/eth/version.rb +1 -1
- data/lib/eth.rb +1 -0
- metadata +53 -15
|
@@ -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
|
|
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
|
-
#
|
|
44
|
-
#
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
@
|
|
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 +
|
|
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
|
-
[
|
|
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"
|