erpc-sdk 0.9.1 → 0.11.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,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "bigdecimal"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ require_relative "config"
9
+ require_relative "errors"
10
+ require_relative "transport"
11
+
12
+ module ERPC
13
+ SealedEvmTransportFacts = Struct.new(
14
+ :hosted_target,
15
+ :method_names,
16
+ :public_methods,
17
+ keyword_init: true
18
+ ) do
19
+ def initialize(hosted_target:, method_names:, public_methods:)
20
+ super(
21
+ hosted_target: hosted_target.to_s.freeze,
22
+ method_names: method_names.map { |name| name.to_s.dup.freeze }.freeze,
23
+ public_methods: public_methods.to_h.each_with_object({}) do |(name, wire), result|
24
+ result[name.to_s.dup.freeze] = wire.to_s.dup.freeze
25
+ end.freeze
26
+ )
27
+ freeze
28
+ end
29
+
30
+ def wire_method(public_name)
31
+ public_methods.fetch(public_name.to_s) do
32
+ raise ConfigError, "sealed EVM public method is absent from the accepted contract", cause: nil
33
+ end
34
+ rescue KeyError
35
+ raise ConfigError, "sealed EVM public method is absent from the accepted contract", cause: nil
36
+ end
37
+ end
38
+
39
+ module SealedEvmContract
40
+ module_function
41
+
42
+ def facts_from_contract(contract, client_contract_id)
43
+ clients = contract.fetch("clientContracts")
44
+ method_sets = contract.fetch("methodSets")
45
+ client = clients.find do |value|
46
+ value.is_a?(Hash) && value["clientContractId"] == client_contract_id
47
+ end
48
+ raise ConfigError, "sealed EVM contract facts are unavailable", cause: nil unless client
49
+
50
+ method_set_id = client.fetch("methodSetId")
51
+ hosted = client.fetch("endpointModes").find { |value| value.is_a?(Hash) && value["mode"] == "hosted" }
52
+ method_set = method_sets.find { |value| value.is_a?(Hash) && value["methodSetId"] == method_set_id }
53
+ capabilities = method_set&.fetch("capabilities")
54
+ target = hosted&.fetch("defaultTarget")
55
+ unless target.is_a?(String) && capabilities.is_a?(Array) && !capabilities.empty?
56
+ raise ConfigError, "sealed EVM contract facts are unavailable", cause: nil
57
+ end
58
+
59
+ public_methods = {}
60
+ method_names = []
61
+ capabilities.each do |capability|
62
+ wire = capability.fetch("wireOperation")
63
+ names = capability.fetch("publicNames")
64
+ public_name = names.fetch("ruby")
65
+ unless wire.is_a?(String) && public_name.is_a?(String) && !wire.empty? && !public_name.empty?
66
+ raise ConfigError, "sealed EVM contract facts are unavailable", cause: nil
67
+ end
68
+
69
+ method_names << wire
70
+ public_methods[public_name] = wire
71
+ end
72
+ SealedEvmTransportFacts.new(
73
+ hosted_target: target,
74
+ method_names: method_names,
75
+ public_methods: public_methods
76
+ )
77
+ rescue KeyError, NoMethodError, TypeError
78
+ raise ConfigError, "sealed EVM contract facts are unavailable", cause: nil
79
+ end
80
+ end
81
+
82
+ if defined?(RPC_NETWORK_CONTRACT)
83
+ BASE_TRANSPORT_FACTS = SealedEvmContract.facts_from_contract(RPC_NETWORK_CONTRACT, "client-base-rpc")
84
+ BASE_RPC_METHODS = BASE_TRANSPORT_FACTS.method_names
85
+ private_constant :BASE_TRANSPORT_FACTS
86
+ end
87
+
88
+ class SealedEvmHttpTransport
89
+ attr_reader :method_names
90
+
91
+ def initialize(facts:, api_key: nil, endpoint: nil, direct_endpoint: nil, headers: {}, timeout: DEFAULT_TIMEOUT,
92
+ adapter: NetHttpAdapter.new, namespace:)
93
+ unless facts.is_a?(SealedEvmTransportFacts)
94
+ raise ConfigError, "sealed EVM transport facts are unavailable", cause: nil
95
+ end
96
+ raise ConfigError, "timeout must be positive", cause: nil unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
97
+
98
+ @facts = facts
99
+ @method_names = facts.method_names
100
+ @api_key = api_key.is_a?(String) && !api_key.strip.empty? ? api_key.strip : nil
101
+ @timeout = timeout.to_f
102
+ @adapter = adapter
103
+ @namespace = namespace.to_s.freeze
104
+ @id_mutex = Mutex.new
105
+ @next_id = 0
106
+ @direct = !direct_endpoint.nil?
107
+
108
+ if direct_endpoint
109
+ unless direct_endpoint.is_a?(RpcEndpointConfig)
110
+ raise ConfigError, "#{@namespace} must use RpcEndpointConfig", cause: nil
111
+ end
112
+ if direct_endpoint.websocket_url
113
+ raise ConfigError, "#{@namespace} does not support WebSocket URLs", cause: nil
114
+ end
115
+ @target = validate_http_url(direct_endpoint.http_url)
116
+ @headers = direct_endpoint.headers.dup
117
+ @redactions = Redaction.send(:direct_variants_with_path,
118
+ http_url: direct_endpoint.http_url,
119
+ websocket_url: direct_endpoint.websocket_url,
120
+ headers: direct_endpoint.headers
121
+ )
122
+ @available = true
123
+ elsif @api_key
124
+ @target = hosted_target(endpoint || facts.hosted_target, @api_key)
125
+ @headers = headers.to_h.dup
126
+ @redactions = [@api_key]
127
+ @available = true
128
+ else
129
+ @target = nil
130
+ @headers = {}
131
+ @redactions = []
132
+ @available = false
133
+ end
134
+ end
135
+
136
+ def endpoint
137
+ return "" unless @available
138
+
139
+ uri = URI.parse(@target)
140
+ uri.path = "/"
141
+ uri.query = nil
142
+ uri.fragment = nil
143
+ uri.user = nil if uri.respond_to?(:user=)
144
+ uri.to_s
145
+ rescue URI::InvalidURIError, ArgumentError
146
+ ""
147
+ end
148
+
149
+ def inspect
150
+ "#<#{self.class} endpoint=#{endpoint.inspect} header_names=#{safe_header_names.inspect}>"
151
+ end
152
+
153
+ def wire_method(public_name)
154
+ @facts.wire_method(public_name)
155
+ end
156
+
157
+ def request(method, params = nil)
158
+ unless @method_names.include?(method.to_s)
159
+ raise ConfigError, "sealed EVM method is not in the accepted named-read contract", cause: nil
160
+ end
161
+ raise NotConfiguredError, @namespace unless @available
162
+
163
+ request_id = next_id
164
+ body = {
165
+ "jsonrpc" => "2.0",
166
+ "id" => request_id,
167
+ "method" => method.to_s,
168
+ "params" => params.nil? ? [] : params
169
+ }
170
+ raw_body = JSON.generate(body)
171
+ response = nil
172
+ Timeout.timeout(@timeout) do
173
+ response = @adapter.request(
174
+ method: :post,
175
+ url: @target,
176
+ headers: request_headers,
177
+ body: raw_body,
178
+ timeout: @timeout
179
+ )
180
+ end
181
+ raise HttpError, response.status unless response.respond_to?(:status) && response.status.to_i.between?(200, 299)
182
+
183
+ unwrap(response.body, request_id)
184
+ rescue ::Timeout::Error
185
+ raise TimeoutError, @timeout, cause: nil
186
+ rescue InvalidResponseError, JsonRpcError, HttpError, NotConfiguredError, ConfigError, TimeoutError, TransportError
187
+ raise
188
+ rescue JSON::ParserError, JSON::GeneratorError, TypeError, ArgumentError
189
+ raise InvalidResponseError.new("ERPC returned an invalid response"), cause: nil
190
+ rescue IOError, EOFError, SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
191
+ raise TransportError, "Unable to reach ERPC", cause: nil
192
+ end
193
+
194
+ private
195
+
196
+ def validate_http_url(value)
197
+ input = value.to_s
198
+ uri = URI.parse(input)
199
+ valid = uri.absolute? && uri.host && %w[http https].include?(uri.scheme) && !input.include?("#")
200
+ valid &&= !input.match?(%r{://[^/?#]*@})
201
+ raise ConfigError, "HTTP endpoint must be an absolute HTTP(S) URL", cause: nil unless valid
202
+
203
+ input.freeze
204
+ rescue URI::InvalidURIError, ArgumentError
205
+ raise ConfigError, "HTTP endpoint must be an absolute HTTP(S) URL", cause: nil
206
+ end
207
+
208
+ def hosted_target(value, api_key)
209
+ uri = URI.parse(validate_http_url(value))
210
+ pairs = URI.decode_www_form(uri.query.to_s).reject { |name, _| name.casecmp?("api-key") }
211
+ pairs << ["api-key", api_key]
212
+ uri.query = URI.encode_www_form(pairs)
213
+ uri.fragment = nil
214
+ uri.to_s
215
+ rescue URI::InvalidURIError, ArgumentError
216
+ raise ConfigError, "HTTP endpoint must be an absolute HTTP(S) URL", cause: nil
217
+ end
218
+
219
+ def request_headers
220
+ values = @headers.dup
221
+ values.delete_if { |name, _| %w[accept content-type].include?(name.to_s.downcase) }
222
+ values["accept"] = "application/json"
223
+ values["content-type"] = "application/json"
224
+ values
225
+ end
226
+
227
+ def next_id
228
+ @id_mutex.synchronize do
229
+ @next_id += 1
230
+ @next_id = 1 if @next_id > 2_147_483_647
231
+ @next_id
232
+ end
233
+ end
234
+
235
+ def safe_header_names
236
+ @headers.keys.map(&:to_s).sort
237
+ end
238
+
239
+ def unwrap(raw_body, expected_id)
240
+ parsed = JSON.parse(raw_body.to_s, create_additions: false, allow_nan: false, decimal_class: BigDecimal)
241
+ unless parsed.is_a?(Hash) && parsed["id"] == expected_id
242
+ raise InvalidResponseError.new("ERPC returned an unexpected response id"), cause: nil
243
+ end
244
+ if parsed.key?("error") && !parsed["error"].nil?
245
+ error = parsed["error"]
246
+ unless error.is_a?(Hash) && error["code"].is_a?(Integer) &&
247
+ !error["code"].is_a?(TrueClass) && !error["code"].is_a?(FalseClass) &&
248
+ error["message"].is_a?(String)
249
+ raise InvalidResponseError.new("ERPC returned an invalid RPC error"), cause: nil
250
+ end
251
+
252
+ data = error.key?("data") ? Redaction.value(error["data"], @redactions) : nil
253
+ raise JsonRpcError.new(error["code"], Redaction.text(error["message"], @redactions), data), cause: nil
254
+ end
255
+ result = parsed["result"]
256
+ raise InvalidResponseError.new("ERPC returned an invalid response"), cause: nil unless result.is_a?(String)
257
+
258
+ result
259
+ rescue InvalidResponseError, JsonRpcError
260
+ raise
261
+ rescue JSON::ParserError, JSON::NestingError, ArgumentError, TypeError, SystemStackError
262
+ raise InvalidResponseError.new("ERPC returned an invalid response"), cause: nil
263
+ end
264
+ end
265
+
266
+ class SealedEvmPendingRequest
267
+ def initialize(&block)
268
+ @block = block
269
+ end
270
+
271
+ def send
272
+ @block.call
273
+ end
274
+ end
275
+
276
+ class BaseRpc
277
+ attr_reader :endpoint
278
+
279
+ def initialize(transport)
280
+ @transport = transport
281
+ @endpoint = transport.endpoint
282
+ end
283
+
284
+ def inspect
285
+ "#<#{self.class} endpoint=#{endpoint.inspect}>"
286
+ end
287
+
288
+ def eth_chain_id
289
+ pending(@transport.wire_method("eth_chain_id"), [])
290
+ end
291
+
292
+ def eth_get_balance(address, block = nil)
293
+ params = [address]
294
+ params << block unless block.nil?
295
+ pending(@transport.wire_method("eth_get_balance"), params)
296
+ end
297
+
298
+ def eth_call(transaction, block = nil, state_override = nil)
299
+ params = [transaction]
300
+ params << block unless block.nil?
301
+ params << state_override unless state_override.nil?
302
+ pending(@transport.wire_method("eth_call"), params)
303
+ end
304
+
305
+ private
306
+
307
+ def pending(method, params)
308
+ SealedEvmPendingRequest.new { @transport.request(method, params) }
309
+ end
310
+ end
311
+
312
+ class BaseClient
313
+ attr_reader :rpc
314
+
315
+ def initialize(transport)
316
+ @rpc = BaseRpc.new(transport)
317
+ freeze
318
+ end
319
+ end
320
+
321
+ BaseRpcClient = BaseRpc
322
+
323
+ module Base
324
+ module_function
325
+
326
+ def create_client(transport)
327
+ BaseClient.new(transport)
328
+ end
329
+ end
330
+
331
+ private_constant :SealedEvmTransportFacts, :SealedEvmContract, :SealedEvmHttpTransport,
332
+ :SealedEvmPendingRequest
333
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bitcoin_transport"
4
+
5
+ module ERPC
6
+ if defined?(RPC_NETWORK_CONTRACT)
7
+ BITCOIN_TRANSPORT_FACTS = BitcoinTransportContract.facts_from_contract(RPC_NETWORK_CONTRACT)
8
+ BITCOIN_RPC_METHODS = BITCOIN_TRANSPORT_FACTS.method_names
9
+ private_constant :BITCOIN_TRANSPORT_FACTS
10
+ end
11
+
12
+ class BitcoinPendingRequest
13
+ def initialize(&block)
14
+ @block = block
15
+ end
16
+
17
+ def send
18
+ @block.call
19
+ end
20
+ end
21
+
22
+ class BitcoinRpc
23
+ attr_reader :endpoint
24
+
25
+ def initialize(transport)
26
+ @transport = transport
27
+ @endpoint = transport.endpoint
28
+ end
29
+
30
+ def inspect
31
+ "#<#{self.class} endpoint=#{endpoint.inspect}>"
32
+ end
33
+
34
+ def estimate_smart_fee(conf_target = nil, estimate_mode = nil)
35
+ if estimate_mode && conf_target.nil?
36
+ raise ConfigError, "estimate_mode requires conf_target", cause: nil
37
+ end
38
+ params = conf_target.nil? ? [] : [conf_target]
39
+ params << estimate_mode unless estimate_mode.nil?
40
+ pending("estimate_smart_fee", params)
41
+ end
42
+
43
+ def get_best_block_hash
44
+ pending("get_best_block_hash", [], projector: BitcoinCodec.method(:as_string))
45
+ end
46
+
47
+ def get_block(block_hash, verbosity = nil)
48
+ params = [block_hash]
49
+ params << verbosity unless verbosity.nil?
50
+ pending("get_block", params)
51
+ end
52
+
53
+ def get_blockchain_info
54
+ pending("get_blockchain_info", [])
55
+ end
56
+
57
+ def get_block_count
58
+ pending("get_block_count", [], projector: BitcoinCodec.method(:as_block_count))
59
+ end
60
+
61
+ def get_block_hash(height)
62
+ pending("get_block_hash", [height], projector: BitcoinCodec.method(:as_string))
63
+ end
64
+
65
+ def get_block_header(block_hash, verbose = nil)
66
+ params = [block_hash]
67
+ params << verbose unless verbose.nil?
68
+ pending("get_block_header", params)
69
+ end
70
+
71
+ def get_mempool_info
72
+ pending("get_mempool_info", [])
73
+ end
74
+
75
+ def get_network_info
76
+ pending("get_network_info", [])
77
+ end
78
+
79
+ def get_raw_mempool(verbose = nil)
80
+ pending("get_raw_mempool", verbose.nil? ? [] : [verbose])
81
+ end
82
+
83
+ def get_raw_transaction(transaction_id, verbose = nil, block_hash = nil)
84
+ params = [transaction_id]
85
+ params << verbose unless verbose.nil?
86
+ params << block_hash unless block_hash.nil?
87
+ pending("get_raw_transaction", params)
88
+ end
89
+
90
+ def get_tx_out(transaction_id, output_index, include_mempool = nil)
91
+ params = [transaction_id, output_index]
92
+ params << include_mempool unless include_mempool.nil?
93
+ pending("get_tx_out", params, projector: BitcoinCodec.method(:as_nullable_object))
94
+ end
95
+
96
+ private
97
+
98
+ def pending(public_name, params, projector: nil)
99
+ method = @transport.wire_method(public_name)
100
+ BitcoinPendingRequest.new do
101
+ @transport.request(method, params, projector: projector)
102
+ end
103
+ end
104
+ end
105
+
106
+ class BitcoinClient
107
+ attr_reader :rpc
108
+
109
+ def initialize(transport)
110
+ @rpc = BitcoinRpc.new(transport)
111
+ freeze
112
+ end
113
+ end
114
+
115
+ BitcoinRpcClient = BitcoinRpc
116
+
117
+ module Bitcoin
118
+ module_function
119
+
120
+ def create_client(transport)
121
+ BitcoinClient.new(transport)
122
+ end
123
+ end
124
+
125
+ private_constant :BitcoinPendingRequest
126
+ end