erpc-sdk 0.4.0

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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "socket"
7
+ require "thread"
8
+ require "uri"
9
+
10
+ module ERPC
11
+ HttpResponse = Struct.new(:status, :body, keyword_init: true)
12
+
13
+ class NetHttpAdapter
14
+ def request(method:, url:, headers:, body: nil, timeout: DEFAULT_TIMEOUT)
15
+ uri = URI.parse(url)
16
+ request = request_class(method).new(uri.request_uri, headers)
17
+ request.body = body unless body.nil?
18
+ response = start(uri, timeout) { |http| http.request(request) }
19
+ HttpResponse.new(status: response.code.to_i, body: response.body.to_s)
20
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
21
+ raise TimeoutError, timeout
22
+ rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
23
+ raise TransportError, "Unable to reach ERPC"
24
+ end
25
+
26
+ def stream(url:, headers:, timeout: DEFAULT_TIMEOUT)
27
+ Enumerator.new do |yielder|
28
+ uri = URI.parse(url)
29
+ request = Net::HTTP::Get.new(uri.request_uri, headers)
30
+ start(uri, timeout) do |http|
31
+ http.request(request) do |response|
32
+ status = response.code.to_i
33
+ raise HttpError, status unless status.between?(200, 299)
34
+
35
+ response.read_body { |chunk| yielder << chunk }
36
+ end
37
+ end
38
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
39
+ raise TimeoutError, timeout
40
+ rescue HttpError
41
+ raise
42
+ rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
43
+ raise TransportError, "Unable to reach ERPC"
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def start(uri, timeout, &block)
50
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout,
51
+ read_timeout: timeout, write_timeout: timeout, &block)
52
+ end
53
+
54
+ def request_class(method)
55
+ { get: Net::HTTP::Get, post: Net::HTTP::Post }.fetch(method.to_sym)
56
+ end
57
+ end
58
+
59
+ class HttpJsonRpcTransport
60
+ attr_reader :endpoint, :max_batch_size
61
+
62
+ def initialize(api_key:, endpoint:, headers:, timeout:, adapter:, max_batch_size: 256)
63
+ @api_key = api_key
64
+ @endpoint = endpoint
65
+ @headers = headers
66
+ @timeout = timeout
67
+ @adapter = adapter
68
+ @max_batch_size = max_batch_size
69
+ @next_id = 0
70
+ @id_mutex = Mutex.new
71
+ end
72
+
73
+ def request(method, params = nil)
74
+ request_id = next_id
75
+ body = { "jsonrpc" => "2.0", "id" => request_id, "method" => method }
76
+ body["params"] = params unless params.nil?
77
+ unwrap(post(body), request_id)
78
+ end
79
+
80
+ def batch(calls)
81
+ return [] if calls.empty?
82
+ if calls.length > max_batch_size
83
+ raise InvalidResponseError, "A batch may contain at most #{max_batch_size} calls"
84
+ end
85
+
86
+ requests = calls.map do |call|
87
+ method = call.fetch(:method) { call.fetch("method") }
88
+ item = { "jsonrpc" => "2.0", "id" => next_id, "method" => method }
89
+ if call.key?(:params) || call.key?("params")
90
+ item["params"] = call.key?(:params) ? call[:params] : call["params"]
91
+ end
92
+ item
93
+ rescue KeyError
94
+ raise ConfigError, "batch calls require a method"
95
+ end
96
+
97
+ response = post(requests)
98
+ unless response.is_a?(Array)
99
+ raise_rpc(response["error"]) if response.is_a?(Hash) && response["error"].is_a?(Hash)
100
+ raise InvalidResponseError, "ERPC returned a non-array batch response"
101
+ end
102
+
103
+ by_id = {}
104
+ response.each do |item|
105
+ unless item.is_a?(Hash) && valid_id?(item["id"])
106
+ raise InvalidResponseError, "ERPC returned an invalid batch item"
107
+ end
108
+ raise InvalidResponseError, "ERPC returned a duplicate batch id" if by_id.key?(item["id"])
109
+
110
+ by_id[item["id"]] = item
111
+ end
112
+
113
+ results = requests.map do |request|
114
+ item = by_id.delete(request["id"])
115
+ raise InvalidResponseError, "ERPC omitted a batch response" unless item
116
+
117
+ unwrap(item, request["id"])
118
+ end
119
+ raise InvalidResponseError, "ERPC returned an unexpected batch response id" unless by_id.empty?
120
+
121
+ results
122
+ end
123
+
124
+ private
125
+
126
+ def next_id
127
+ @id_mutex.synchronize { @next_id += 1 }
128
+ end
129
+
130
+ def post(body)
131
+ uri = URI.parse(endpoint)
132
+ query = URI.decode_www_form(uri.query.to_s)
133
+ query << ["api-key", @api_key]
134
+ uri.query = URI.encode_www_form(query)
135
+ response = @adapter.request(
136
+ method: :post,
137
+ url: uri.to_s,
138
+ headers: @headers.merge("accept" => "application/json", "content-type" => "application/json"),
139
+ body: JSON.generate(body),
140
+ timeout: @timeout
141
+ )
142
+ raise HttpError, response.status unless response.status.between?(200, 299)
143
+
144
+ JSON.parse(response.body)
145
+ rescue JSON::ParserError
146
+ raise InvalidResponseError, "ERPC returned malformed JSON"
147
+ end
148
+
149
+ def unwrap(response, expected_id)
150
+ unless response.is_a?(Hash) && response["id"] == expected_id
151
+ raise InvalidResponseError, "ERPC returned an unexpected response id"
152
+ end
153
+ raise_rpc(response["error"]) if response.key?("error")
154
+ raise InvalidResponseError, "ERPC returned an invalid response" unless response.key?("result")
155
+
156
+ response["result"]
157
+ end
158
+
159
+ def raise_rpc(error)
160
+ unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
161
+ raise InvalidResponseError, "ERPC returned an invalid RPC error"
162
+ end
163
+
164
+ data = error.key?("data") ? Redaction.value(error["data"], @api_key) : nil
165
+ raise JsonRpcError.new(error["code"], Redaction.text(error["message"], @api_key), data)
166
+ end
167
+
168
+ def valid_id?(value)
169
+ value.is_a?(Integer) || value.is_a?(String)
170
+ end
171
+ end
172
+
173
+ class RestTransport
174
+ attr_reader :endpoint
175
+
176
+ def initialize(credential:, endpoint:, headers:, timeout:, adapter:)
177
+ @credential = credential
178
+ @endpoint = endpoint
179
+ @headers = headers
180
+ @timeout = timeout
181
+ @adapter = adapter
182
+ end
183
+
184
+ def get(path, query = nil)
185
+ response = @adapter.request(
186
+ method: :get,
187
+ url: url(path, query),
188
+ headers: @headers.merge("authorization" => "Bearer #{@credential}", "accept" => "application/json"),
189
+ timeout: @timeout
190
+ )
191
+ raise HttpError, response.status unless response.status.between?(200, 299)
192
+
193
+ JSON.parse(response.body)
194
+ rescue JSON::ParserError
195
+ raise InvalidResponseError, "ERPC returned malformed JSON"
196
+ end
197
+
198
+ def stream(path, query = nil)
199
+ @adapter.stream(
200
+ url: url(path, query),
201
+ headers: @headers.merge("authorization" => "Bearer #{@credential}", "accept" => "text/event-stream"),
202
+ timeout: @timeout
203
+ )
204
+ end
205
+
206
+ private
207
+
208
+ def url(path, query)
209
+ uri = URI.parse(URLs.with_path(endpoint, path))
210
+ pairs = []
211
+ (query || {}).each do |key, value|
212
+ next if value.nil?
213
+
214
+ if value.is_a?(Array)
215
+ value.each { |item| pairs << [key.to_s, item.to_s] }
216
+ else
217
+ rendered = value == true ? "true" : value == false ? "false" : value.to_s
218
+ pairs << [key.to_s, rendered]
219
+ end
220
+ end
221
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
222
+ uri.to_s
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERPC
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest/sha1"
5
+ require "json"
6
+ require "openssl"
7
+ require "securerandom"
8
+ require "socket"
9
+ require "thread"
10
+ require "timeout"
11
+ require "uri"
12
+
13
+ module ERPC
14
+ class WebSocketConnection
15
+ MAX_MESSAGE_SIZE = 16 * 1024 * 1024
16
+ private_constant :MAX_MESSAGE_SIZE
17
+
18
+ def initialize(url, timeout)
19
+ @uri = URI.parse(url)
20
+ @timeout = timeout
21
+ @write_mutex = Mutex.new
22
+ connect
23
+ end
24
+
25
+ def write_text(text)
26
+ write_frame(0x1, text.b)
27
+ end
28
+
29
+ def read_message
30
+ payload = +"".b
31
+ started = false
32
+ loop do
33
+ final, opcode, chunk = read_frame
34
+ case opcode
35
+ when 0x0
36
+ raise TransportError, "ERPC returned an invalid WebSocket frame" unless started
37
+ payload << chunk
38
+ when 0x1, 0x2
39
+ raise TransportError, "ERPC returned an invalid WebSocket frame" if started
40
+ started = true
41
+ payload << chunk
42
+ when 0x8
43
+ raise TransportError, "ERPC WebSocket connection closed"
44
+ when 0x9
45
+ write_frame(0xA, chunk)
46
+ next
47
+ when 0xA
48
+ next
49
+ else
50
+ raise TransportError, "ERPC returned an invalid WebSocket frame"
51
+ end
52
+ raise TransportError, "ERPC WebSocket message is too large" if payload.bytesize > MAX_MESSAGE_SIZE
53
+ return payload.force_encoding(Encoding::UTF_8) if final
54
+ end
55
+ end
56
+
57
+ def close
58
+ write_frame(0x8, "".b)
59
+ rescue StandardError
60
+ nil
61
+ ensure
62
+ @socket&.close
63
+ end
64
+
65
+ private
66
+
67
+ def connect
68
+ unless %w[ws wss].include?(@uri.scheme) && @uri.host
69
+ raise ConfigError, "WebSocket endpoint must be an absolute WS(S) URL"
70
+ end
71
+
72
+ tcp = Timeout.timeout(@timeout) { TCPSocket.new(@uri.host, @uri.port) }
73
+ @socket = if @uri.scheme == "wss"
74
+ context = OpenSSL::SSL::SSLContext.new
75
+ context.set_params
76
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
77
+ ssl.hostname = @uri.host if ssl.respond_to?(:hostname=)
78
+ ssl.sync_close = true
79
+ Timeout.timeout(@timeout) { ssl.connect }
80
+ ssl
81
+ else
82
+ tcp
83
+ end
84
+ handshake
85
+ rescue ::Timeout::Error
86
+ tcp&.close
87
+ raise TimeoutError, @timeout
88
+ rescue ConfigError, TimeoutError
89
+ tcp&.close
90
+ raise
91
+ rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
92
+ tcp&.close
93
+ raise TransportError, "Unable to reach ERPC WebSocket"
94
+ end
95
+
96
+ def handshake
97
+ key = Base64.strict_encode64(SecureRandom.random_bytes(16))
98
+ host = @uri.host
99
+ default_port = (@uri.scheme == "wss" ? 443 : 80)
100
+ host = "#{host}:#{@uri.port}" unless @uri.port == default_port
101
+ request = [
102
+ "GET #{@uri.request_uri} HTTP/1.1",
103
+ "Host: #{host}",
104
+ "Upgrade: websocket",
105
+ "Connection: Upgrade",
106
+ "Sec-WebSocket-Key: #{key}",
107
+ "Sec-WebSocket-Version: 13",
108
+ "\r\n"
109
+ ].join("\r\n")
110
+ Timeout.timeout(@timeout) { @socket.write(request) }
111
+ headers = read_until("\r\n\r\n", 64 * 1024)
112
+ status, *lines = headers.split("\r\n")
113
+ raise TransportError, "ERPC rejected the WebSocket upgrade" unless status&.match?(%r{\AHTTP/1\.[01] 101\b})
114
+
115
+ response_headers = lines.filter_map do |line|
116
+ name, separator, value = line.partition(":")
117
+ [name.downcase, value.strip] unless separator.empty?
118
+ end.to_h
119
+ expected = Base64.strict_encode64(Digest::SHA1.digest("#{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
120
+ unless response_headers["upgrade"]&.downcase == "websocket" &&
121
+ response_headers["connection"]&.downcase&.split(/\s*,\s*/)&.include?("upgrade") &&
122
+ secure_compare(response_headers["sec-websocket-accept"].to_s, expected)
123
+ raise TransportError, "ERPC returned an invalid WebSocket upgrade"
124
+ end
125
+ rescue ::Timeout::Error
126
+ raise TimeoutError, @timeout
127
+ end
128
+
129
+ def read_until(delimiter, limit)
130
+ value = +"".b
131
+ until value.end_with?(delimiter)
132
+ value << read_exact(1)
133
+ raise TransportError, "ERPC returned oversized WebSocket headers" if value.bytesize > limit
134
+ end
135
+ value
136
+ end
137
+
138
+ def read_frame
139
+ header = read_exact(2).bytes
140
+ unless (header[0] & 0x70).zero?
141
+ raise TransportError, "ERPC returned an invalid WebSocket frame"
142
+ end
143
+ final = (header[0] & 0x80) != 0
144
+ opcode = header[0] & 0x0F
145
+ masked = (header[1] & 0x80) != 0
146
+ raise TransportError, "ERPC returned an invalid WebSocket frame" if masked
147
+
148
+ length = header[1] & 0x7F
149
+ length = read_exact(2).unpack1("n") if length == 126
150
+ length = read_exact(8).unpack1("Q>") if length == 127
151
+ if opcode >= 0x8 && (!final || length > 125)
152
+ raise TransportError, "ERPC returned an invalid WebSocket control frame"
153
+ end
154
+ raise TransportError, "ERPC WebSocket message is too large" if length > MAX_MESSAGE_SIZE
155
+
156
+ payload = read_exact(length)
157
+ [final, opcode, payload]
158
+ end
159
+
160
+ def write_frame(opcode, payload)
161
+ mask = SecureRandom.random_bytes(4)
162
+ header = [0x80 | opcode]
163
+ length = payload.bytesize
164
+ if length < 126
165
+ header << (0x80 | length)
166
+ prefix = header.pack("C*")
167
+ elsif length <= 65_535
168
+ header << (0x80 | 126)
169
+ prefix = header.pack("C*") + [length].pack("n")
170
+ else
171
+ header << (0x80 | 127)
172
+ prefix = header.pack("C*") + [length].pack("Q>")
173
+ end
174
+ masked = payload.bytes.each_with_index.map { |byte, index| byte ^ mask.getbyte(index % 4) }.pack("C*")
175
+ @write_mutex.synchronize do
176
+ Timeout.timeout(@timeout) { @socket.write(prefix + mask + masked) }
177
+ end
178
+ rescue ::Timeout::Error
179
+ raise TimeoutError, @timeout
180
+ rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
181
+ raise TransportError, "Unable to reach ERPC WebSocket"
182
+ end
183
+
184
+ def read_exact(length)
185
+ value = +"".b
186
+ while value.bytesize < length
187
+ ready = IO.select([@socket], nil, nil, @timeout)
188
+ raise TimeoutError, @timeout unless ready
189
+
190
+ chunk = @socket.readpartial(length - value.bytesize)
191
+ raise EOFError if chunk.empty?
192
+
193
+ value << chunk
194
+ end
195
+ value
196
+ rescue EOFError, IOError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
197
+ raise TransportError, "ERPC WebSocket connection closed"
198
+ end
199
+
200
+ def secure_compare(left, right)
201
+ return false unless left.bytesize == right.bytesize
202
+
203
+ left.bytes.zip(right.bytes).reduce(0) { |difference, (a, b)| difference | (a ^ b) }.zero?
204
+ end
205
+ end
206
+
207
+ class WebSocketJsonRpcTransport
208
+ def initialize(connection_url, credential, timeout, connection_factory: nil)
209
+ @connection_url = connection_url
210
+ @credential = credential
211
+ @timeout = timeout
212
+ @connection_factory = connection_factory || ->(url, seconds) { WebSocketConnection.new(url, seconds) }
213
+ @mutex = Mutex.new
214
+ @connection = nil
215
+ @reader = nil
216
+ @pending = {}
217
+ @listeners = {}
218
+ @next_id = 0
219
+ @next_listener_id = 0
220
+ @closed = false
221
+ end
222
+
223
+ def request(method, params = nil)
224
+ connection = ensure_connection
225
+ request_id = @mutex.synchronize { @next_id += 1 }
226
+ queue = Queue.new
227
+ @mutex.synchronize { @pending[request_id] = queue }
228
+ body = { "jsonrpc" => "2.0", "id" => request_id, "method" => method }
229
+ body["params"] = params unless params.nil?
230
+ connection.write_text(JSON.generate(body))
231
+ response = Timeout.timeout(@timeout) { queue.pop }
232
+ raise response if response.is_a?(Exception)
233
+
234
+ unwrap(response, request_id)
235
+ rescue ::Timeout::Error
236
+ raise TimeoutError, @timeout
237
+ ensure
238
+ @mutex.synchronize { @pending.delete(request_id) } if request_id
239
+ end
240
+
241
+ def on_notification(listener = nil, &block)
242
+ selected = listener || block
243
+ raise ArgumentError, "listener is required" unless selected
244
+
245
+ listener_id = @mutex.synchronize do
246
+ @next_listener_id += 1
247
+ @listeners[@next_listener_id] = selected
248
+ @next_listener_id
249
+ end
250
+ -> { @mutex.synchronize { @listeners.delete(listener_id) } }
251
+ end
252
+
253
+ def close
254
+ connection = @mutex.synchronize do
255
+ return if @closed
256
+
257
+ @closed = true
258
+ current, @connection = @connection, nil
259
+ current
260
+ end
261
+ connection&.close
262
+ @reader&.join(1)
263
+ fail_pending(TransportError.new("WebSocket transport is closed"))
264
+ @mutex.synchronize { @listeners.clear }
265
+ nil
266
+ end
267
+
268
+ private
269
+
270
+ def ensure_connection
271
+ @mutex.synchronize do
272
+ raise TransportError, "WebSocket transport is closed" if @closed
273
+ return @connection if @connection
274
+
275
+ @connection = @connection_factory.call(@connection_url, @timeout)
276
+ connection = @connection
277
+ @reader = Thread.new { read_loop(connection) }
278
+ @reader.report_on_exception = false
279
+ connection
280
+ end
281
+ end
282
+
283
+ def read_loop(connection)
284
+ loop do
285
+ value = JSON.parse(connection.read_message)
286
+ next unless value.is_a?(Hash)
287
+
288
+ id = value["id"]
289
+ if id.is_a?(Integer) || id.is_a?(String)
290
+ queue = @mutex.synchronize { @pending[id] }
291
+ queue << value if queue
292
+ elsif value["method"].is_a?(String) && value.key?("params")
293
+ listeners = @mutex.synchronize { @listeners.values.dup }
294
+ listeners.each do |listener|
295
+ listener.call(value)
296
+ rescue StandardError
297
+ next
298
+ end
299
+ end
300
+ rescue JSON::ParserError
301
+ next
302
+ end
303
+ rescue StandardError => error
304
+ @mutex.synchronize { @connection = nil if @connection.equal?(connection) }
305
+ fail_pending(TransportError.new(Redaction.text(error.message, @credential)))
306
+ end
307
+
308
+ def fail_pending(error)
309
+ queues = @mutex.synchronize { @pending.values.dup }
310
+ queues.each { |queue| queue << error }
311
+ end
312
+
313
+ def unwrap(response, expected_id)
314
+ unless response.is_a?(Hash) && response["id"] == expected_id
315
+ raise InvalidResponseError, "ERPC returned an invalid WebSocket response"
316
+ end
317
+ if response.key?("error")
318
+ error = response["error"]
319
+ unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
320
+ raise InvalidResponseError, "ERPC returned an invalid RPC error"
321
+ end
322
+ data = error.key?("data") ? Redaction.value(error["data"], @credential) : nil
323
+ raise JsonRpcError.new(error["code"], Redaction.text(error["message"], @credential), data)
324
+ end
325
+ raise InvalidResponseError, "ERPC returned an invalid WebSocket response" unless response.key?("result")
326
+
327
+ response["result"]
328
+ end
329
+ end
330
+
331
+ class RpcSubscription
332
+ include Enumerable
333
+
334
+ attr_reader :id
335
+
336
+ def initialize(id, queue, unsubscribe)
337
+ @id = id
338
+ @queue = queue
339
+ @unsubscribe = unsubscribe
340
+ @closed = false
341
+ end
342
+
343
+ def each
344
+ return enum_for(:each) unless block_given?
345
+
346
+ loop { yield self.next }
347
+ rescue StopIteration
348
+ self
349
+ end
350
+
351
+ def next(timeout: nil)
352
+ raise StopIteration if @closed && @queue.empty?
353
+
354
+ return @queue.pop if timeout.nil?
355
+
356
+ Timeout.timeout(timeout) { @queue.pop }
357
+ rescue ::Timeout::Error
358
+ raise TimeoutError, timeout
359
+ end
360
+
361
+ def unsubscribe
362
+ return true if @closed
363
+
364
+ result = @unsubscribe.call
365
+ @closed = true if result
366
+ result
367
+ end
368
+ end
369
+
370
+ class SubscriptionsBase
371
+ def initialize(transport)
372
+ @transport = transport
373
+ end
374
+
375
+ def on_notification(listener = nil, &block)
376
+ @transport.on_notification(listener, &block)
377
+ end
378
+
379
+ def raw(method, params = nil)
380
+ @transport.request(method, params)
381
+ end
382
+
383
+ def close
384
+ @transport.close
385
+ end
386
+
387
+ private
388
+
389
+ def subscribe_to(subscribe_method, params, unsubscribe_method, listener)
390
+ queue = Queue.new
391
+ subscription_id = nil
392
+ remove = @transport.on_notification do |notification|
393
+ raw_params = notification["params"]
394
+ next unless raw_params.is_a?(Hash) && raw_params["subscription"] == subscription_id && raw_params.key?("result")
395
+
396
+ result = raw_params["result"]
397
+ queue << result
398
+ listener&.call(result)
399
+ end
400
+ subscription_id = @transport.request(subscribe_method, params)
401
+ unless subscription_id.is_a?(Integer) || subscription_id.is_a?(String)
402
+ remove.call
403
+ raise InvalidResponseError, "ERPC returned an invalid subscription id"
404
+ end
405
+ unsubscribe = lambda do
406
+ result = @transport.request(unsubscribe_method, [subscription_id])
407
+ raise InvalidResponseError, "ERPC returned an invalid unsubscribe result" unless [true, false].include?(result)
408
+
409
+ remove.call if result
410
+ result
411
+ end
412
+ RpcSubscription.new(subscription_id, queue, unsubscribe)
413
+ rescue StandardError
414
+ remove&.call
415
+ raise
416
+ end
417
+ end
418
+
419
+ class EthereumSubscriptions < SubscriptionsBase
420
+ def subscribe(subscription, *options, listener: nil, &block)
421
+ result = subscribe_to("eth_subscribe", [subscription, *options], "eth_unsubscribe", listener || block)
422
+ unless result.id.is_a?(String)
423
+ result.unsubscribe
424
+ raise InvalidResponseError, "ERPC returned an invalid subscription id"
425
+ end
426
+ result
427
+ end
428
+ end
429
+
430
+ class SolanaSubscriptions < SubscriptionsBase
431
+ def account_subscribe(address, options = nil, listener: nil, &block)
432
+ params = [address]
433
+ params << options unless options.nil?
434
+ numeric_subscribe("accountSubscribe", params, "accountUnsubscribe", listener || block)
435
+ end
436
+
437
+ def transaction_subscribe(filter, options = nil, listener: nil, &block)
438
+ params = [filter]
439
+ params << options unless options.nil?
440
+ numeric_subscribe("transactionSubscribe", params, "transactionUnsubscribe", listener || block)
441
+ end
442
+
443
+ def raw_subscribe(subscribe_method, params, unsubscribe_method, listener: nil, &block)
444
+ subscribe_to(subscribe_method, params, unsubscribe_method, listener || block)
445
+ end
446
+
447
+ private
448
+
449
+ def numeric_subscribe(subscribe_method, params, unsubscribe_method, listener)
450
+ result = subscribe_to(subscribe_method, params, unsubscribe_method, listener)
451
+ unless result.id.is_a?(Integer)
452
+ result.unsubscribe
453
+ raise InvalidResponseError, "ERPC returned an invalid subscription id"
454
+ end
455
+ result
456
+ end
457
+ end
458
+ end
data/lib/erpc_sdk.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "erpc_sdk/version"
4
+ require_relative "erpc_sdk/errors"
5
+ require_relative "erpc_sdk/config"
6
+ require_relative "erpc_sdk/transport"
7
+ require_relative "erpc_sdk/rpc"
8
+ require_relative "erpc_sdk/rest"
9
+ require_relative "erpc_sdk/websocket"
10
+ require_relative "erpc_sdk/client"