erpc-sdk 0.9.0 → 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,369 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ require_relative "bitcoin_codec"
9
+ require_relative "config"
10
+ require_relative "errors"
11
+ require_relative "transport"
12
+
13
+ module ERPC
14
+ BitcoinTransportFacts = Struct.new(
15
+ :hosted_target,
16
+ :method_names,
17
+ :public_methods,
18
+ keyword_init: true
19
+ ) do
20
+ def initialize(hosted_target:, method_names:, public_methods:)
21
+ super(
22
+ hosted_target: hosted_target.to_s.freeze,
23
+ method_names: method_names.map { |name| name.to_s.dup.freeze }.freeze,
24
+ public_methods: public_methods.to_h.each_with_object({}) do |(name, wire), result|
25
+ result[name.to_s.dup.freeze] = wire.to_s.dup.freeze
26
+ end.freeze
27
+ )
28
+ freeze
29
+ end
30
+
31
+ def wire_method(public_name)
32
+ public_methods.fetch(public_name.to_s) do
33
+ raise ConfigError, "Bitcoin public method is absent from the accepted contract", cause: nil
34
+ end
35
+ rescue KeyError
36
+ raise ConfigError, "Bitcoin public method is absent from the accepted contract", cause: nil
37
+ end
38
+ end
39
+
40
+ module BitcoinTransportContract
41
+ module_function
42
+
43
+ def facts_from_contract(contract)
44
+ clients = contract.fetch("clientContracts")
45
+ method_sets = contract.fetch("methodSets")
46
+ client = clients.find { |value| value.is_a?(Hash) && value["clientContractId"] == "client-bitcoin-rpc" }
47
+ raise ConfigError, "Bitcoin contract facts are unavailable", cause: nil unless client
48
+
49
+ method_set_id = client.fetch("methodSetId")
50
+ hosted = client.fetch("endpointModes").find { |value| value.is_a?(Hash) && value["mode"] == "hosted" }
51
+ method_set = method_sets.find { |value| value.is_a?(Hash) && value["methodSetId"] == method_set_id }
52
+ capabilities = method_set&.fetch("capabilities")
53
+ target = hosted&.fetch("defaultTarget")
54
+ unless target.is_a?(String) && capabilities.is_a?(Array) && !capabilities.empty?
55
+ raise ConfigError, "Bitcoin contract facts are unavailable", cause: nil
56
+ end
57
+
58
+ public_methods = {}
59
+ method_names = []
60
+ capabilities.each do |capability|
61
+ wire = capability.fetch("wireOperation")
62
+ names = capability.fetch("publicNames")
63
+ public_name = names.fetch("ruby")
64
+ unless wire.is_a?(String) && public_name.is_a?(String) && !wire.empty? && !public_name.empty?
65
+ raise ConfigError, "Bitcoin contract facts are unavailable", cause: nil
66
+ end
67
+
68
+ method_names << wire
69
+ public_methods[public_name] = wire
70
+ end
71
+ BitcoinTransportFacts.new(
72
+ hosted_target: target,
73
+ method_names: method_names,
74
+ public_methods: public_methods
75
+ )
76
+ rescue KeyError, NoMethodError, TypeError
77
+ raise ConfigError, "Bitcoin contract facts are unavailable", cause: nil
78
+ end
79
+ end
80
+
81
+ class BitcoinHttpTransport
82
+ attr_reader :method_names
83
+
84
+ def initialize(facts:, api_key: nil, endpoint: nil, direct_endpoint: nil, headers: {}, timeout: DEFAULT_TIMEOUT,
85
+ adapter: NetHttpAdapter.new, namespace: "bitcoin.rpc")
86
+ unless facts.is_a?(BitcoinTransportFacts)
87
+ raise ConfigError, "Bitcoin transport facts are unavailable", cause: nil
88
+ end
89
+ raise ConfigError, "timeout must be positive", cause: nil unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
90
+
91
+ @facts = facts
92
+ @method_names = facts.method_names
93
+ @api_key = api_key.is_a?(String) && !api_key.strip.empty? ? api_key.strip : nil
94
+ @timeout = timeout.to_f
95
+ @adapter = adapter
96
+ @namespace = namespace.to_s.freeze
97
+ @id_mutex = Mutex.new
98
+ @next_id = 0
99
+ @direct = !direct_endpoint.nil?
100
+
101
+ if direct_endpoint
102
+ unless direct_endpoint.is_a?(RpcEndpointConfig)
103
+ raise ConfigError, "bitcoin_rpc must use RpcEndpointConfig", cause: nil
104
+ end
105
+ if direct_endpoint.websocket_url
106
+ raise ConfigError, "bitcoin_rpc does not support WebSocket URLs", cause: nil
107
+ end
108
+
109
+ @target = validate_http_url(direct_endpoint.http_url)
110
+ @headers = direct_endpoint.headers.dup
111
+ @redactions = Redaction.send(:direct_variants_with_path,
112
+ http_url: direct_endpoint.http_url,
113
+ websocket_url: direct_endpoint.websocket_url,
114
+ headers: direct_endpoint.headers
115
+ )
116
+ @available = true
117
+ elsif @api_key
118
+ root = endpoint || default_hosted_target
119
+ @target = hosted_target(root)
120
+ @headers = headers.to_h.dup
121
+ @headers.delete_if { |name, _| name.to_s.casecmp?("authorization") }
122
+ @headers["authorization"] = "Bearer #{@api_key}"
123
+ @redactions = [@api_key]
124
+ @available = true
125
+ else
126
+ @target = nil
127
+ @headers = {}
128
+ @redactions = []
129
+ @available = false
130
+ end
131
+ end
132
+
133
+ def endpoint
134
+ return "" unless @available
135
+
136
+ uri = URI.parse(@target)
137
+ uri.path = "/"
138
+ uri.query = nil
139
+ uri.fragment = nil
140
+ uri.user = nil if uri.respond_to?(:user=)
141
+ uri.to_s
142
+ rescue URI::InvalidURIError, ArgumentError
143
+ ""
144
+ end
145
+
146
+ def inspect
147
+ "#<#{self.class} endpoint=#{endpoint.inspect} header_names=#{safe_header_names.inspect}>"
148
+ end
149
+
150
+ def wire_method(public_name)
151
+ @facts.wire_method(public_name)
152
+ end
153
+
154
+ def request(method, params = nil, projector: nil)
155
+ unless @method_names.include?(method.to_s)
156
+ raise ConfigError, "Bitcoin method is not in the accepted named-read contract", cause: nil
157
+ end
158
+ raise NotConfiguredError, @namespace unless @available
159
+ unless @adapter.respond_to?(:request_stream)
160
+ raise ConfigError, "Bitcoin transport requires a streaming HTTP adapter", cause: nil
161
+ end
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
+ text = stream_body(raw_body)
172
+ BitcoinCodec.decode(
173
+ text,
174
+ request_id,
175
+ redactions: @redactions,
176
+ projector: projector
177
+ )
178
+ rescue InvalidResponseError, JsonRpcError, HttpError, NotConfiguredError, ConfigError, TimeoutError, TransportError
179
+ raise
180
+ rescue JSON::GeneratorError, TypeError, ArgumentError
181
+ raise InvalidResponseError.new("ERPC returned an invalid Bitcoin response"), cause: nil
182
+ end
183
+
184
+ private
185
+
186
+ def default_hosted_target
187
+ # The generated projection supplies the accepted target after its
188
+ # visibility gate. Until then, callers pass the immutable facts' target
189
+ # explicitly through the private constructor path.
190
+ @facts.hosted_target
191
+ end
192
+
193
+ def validate_http_url(value)
194
+ input = value.to_s
195
+ uri = URI.parse(input)
196
+ valid = uri.absolute? && uri.host && %w[http https].include?(uri.scheme) && !input.include?("#")
197
+ valid &&= !input.match?(%r{://[^/?#]*@})
198
+ raise ConfigError, "HTTP endpoint must be an absolute HTTP(S) URL", cause: nil unless valid
199
+
200
+ input.freeze
201
+ rescue URI::InvalidURIError, ArgumentError
202
+ raise ConfigError, "HTTP endpoint must be an absolute HTTP(S) URL", cause: nil
203
+ end
204
+
205
+ def hosted_target(root)
206
+ target = validate_http_url(root)
207
+ uri = URI.parse(target)
208
+ hosted_path = URI.parse(@facts.hosted_target).path.to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
209
+ base_path = uri.path.to_s.sub(%r{/+\z}, "")
210
+ unless hosted_path.empty? || base_path.split("/").last == hosted_path
211
+ base_path = "#{base_path}/#{hosted_path}"
212
+ end
213
+ uri.path = base_path.empty? ? "/" : base_path
214
+ pairs = URI.decode_www_form(uri.query.to_s).reject { |name, _| name.casecmp?("api-key") }
215
+ uri.query = pairs.empty? ? nil : URI.encode_www_form(pairs)
216
+ uri.fragment = nil
217
+ uri.to_s
218
+ rescue URI::InvalidURIError, ArgumentError
219
+ raise ConfigError, "HTTP endpoint must be an absolute HTTP(S) URL", cause: nil
220
+ end
221
+
222
+ def request_headers
223
+ values = @headers.dup
224
+ values.delete_if { |name, _| %w[accept content-type].include?(name.to_s.downcase) }
225
+ values["accept"] = "application/json"
226
+ values["content-type"] = "application/json"
227
+ values
228
+ end
229
+
230
+ def next_id
231
+ @id_mutex.synchronize do
232
+ @next_id += 1
233
+ @next_id = 1 if @next_id > 2_147_483_647
234
+ @next_id
235
+ end
236
+ end
237
+
238
+ def stream_body(raw_body)
239
+ deadline = monotonic_now + @timeout
240
+ consumed = false
241
+ text = nil
242
+ close_resource = nil
243
+ timeout_value = [remaining(deadline), 0.001].max
244
+
245
+ Timeout.timeout(timeout_value) do
246
+ result = @adapter.request_stream(
247
+ method: :post,
248
+ url: @target,
249
+ headers: request_headers,
250
+ body: raw_body,
251
+ timeout: remaining(deadline)
252
+ ) do |status, chunks, response_headers|
253
+ consumed = true
254
+ raise HttpError, status unless status.to_i.between?(200, 299)
255
+
256
+ encoded = non_identity_content_encoding?(response_headers)
257
+ text, close_resource = read_chunks(
258
+ chunks, deadline,
259
+ expected_bytes: encoded ? nil : response_length(response_headers)
260
+ )
261
+ text
262
+ end
263
+
264
+ unless consumed
265
+ status, chunks, close_resource = unpack_stream_response(result)
266
+ raise HttpError, status unless status.to_i.between?(200, 299)
267
+
268
+ text = read_chunks(chunks, deadline).first
269
+ end
270
+ end
271
+ text
272
+ rescue ::Timeout::Error
273
+ raise TimeoutError, @timeout, cause: nil
274
+ rescue HttpError, InvalidResponseError
275
+ raise
276
+ rescue ConfigError, TimeoutError, TransportError
277
+ raise
278
+ rescue IOError, EOFError, SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
279
+ raise TransportError, "Unable to reach ERPC", cause: nil
280
+ ensure
281
+ close_stream(close_resource)
282
+ end
283
+
284
+ def read_chunks(chunks, deadline, expected_bytes: nil)
285
+ total = 0
286
+ buffer = +"".b
287
+ close_resource = chunks if chunks.respond_to?(:close)
288
+ begin
289
+ enumerable = if chunks.respond_to?(:each)
290
+ chunks
291
+ else
292
+ raise ConfigError, "streaming HTTP adapter returned no body", cause: nil
293
+ end
294
+ enumerable.each do |chunk|
295
+ raise TimeoutError, @timeout, cause: nil if remaining(deadline) <= 0
296
+ bytes = chunk.to_s.b
297
+ if total + bytes.bytesize > BITCOIN_RESPONSE_LIMITS.decoded_entity_bytes
298
+ raise InvalidResponseError.new("ERPC returned an invalid Bitcoin response"), cause: nil
299
+ end
300
+
301
+ buffer << bytes
302
+ total += bytes.bytesize
303
+ end
304
+ if expected_bytes && total != expected_bytes
305
+ raise TransportError, "Unable to reach ERPC", cause: nil
306
+ end
307
+ buffer.force_encoding(Encoding::UTF_8)
308
+ unless buffer.valid_encoding?
309
+ raise InvalidResponseError.new("ERPC returned an invalid Bitcoin response"), cause: nil
310
+ end
311
+ [buffer, close_resource]
312
+ ensure
313
+ close_stream(close_resource)
314
+ end
315
+ end
316
+
317
+ def unpack_stream_response(result)
318
+ if result.is_a?(HttpStreamResponse)
319
+ [result.status, result.body, result.close]
320
+ elsif result.respond_to?(:status) && result.respond_to?(:body)
321
+ [result.status, result.body, result.respond_to?(:close) ? result.close : nil]
322
+ elsif result.is_a?(Array) && result.length >= 2
323
+ [result[0], result[1], result[2]]
324
+ else
325
+ raise ConfigError, "streaming HTTP adapter returned no response", cause: nil
326
+ end
327
+ end
328
+
329
+ def close_stream(resource)
330
+ resource.close if resource.respond_to?(:close)
331
+ rescue StandardError
332
+ nil
333
+ end
334
+
335
+ def monotonic_now
336
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
337
+ end
338
+
339
+ def remaining(deadline)
340
+ deadline - monotonic_now
341
+ end
342
+
343
+ def response_length(headers)
344
+ value = Array(headers && (headers["content-length"] || headers["Content-Length"])).first
345
+ return nil if value.nil?
346
+
347
+ Integer(value.to_s, 10)
348
+ rescue ArgumentError, TypeError
349
+ nil
350
+ end
351
+
352
+ def non_identity_content_encoding?(headers)
353
+ values = (headers.respond_to?(:to_h) ? headers.to_h : {}).each_with_object([]) do |(name, value), result|
354
+ next unless name.to_s.casecmp?("content-encoding")
355
+
356
+ result.concat(Array(value))
357
+ end
358
+ values.flat_map { |value| value.to_s.split(",") }
359
+ .map { |value| value.strip.downcase }
360
+ .any? { |coding| !coding.empty? && coding != "identity" }
361
+ end
362
+
363
+ def safe_header_names
364
+ @headers.keys.map(&:to_s).sort
365
+ end
366
+ end
367
+
368
+ private_constant :BitcoinTransportFacts, :BitcoinTransportContract, :BitcoinHttpTransport
369
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module ERPC
6
+ BSC_TRANSPORT_FACTS = SealedEvmContract.facts_from_contract(RPC_NETWORK_CONTRACT, "client-bsc-rpc")
7
+ BSC_RPC_METHODS = BSC_TRANSPORT_FACTS.method_names
8
+ private_constant :BSC_TRANSPORT_FACTS
9
+
10
+ class BscRpc < BaseRpc
11
+ alias_method :endpoint, :endpoint
12
+ alias_method :eth_chain_id, :eth_chain_id
13
+ alias_method :eth_get_balance, :eth_get_balance
14
+ alias_method :eth_call, :eth_call
15
+ end
16
+
17
+ class BscClient
18
+ attr_reader :rpc
19
+
20
+ def initialize(transport)
21
+ @rpc = BscRpc.new(transport)
22
+ freeze
23
+ end
24
+ end
25
+
26
+ module Bsc
27
+ module_function
28
+
29
+ def create_client(transport)
30
+ BscClient.new(transport)
31
+ end
32
+ end
33
+ end
@@ -23,7 +23,9 @@ module ERPC
23
23
  )
24
24
 
25
25
  class Client
26
- attr_reader :solana, :ethereum, :avalanche, :price, :account, :usage, :swap
26
+ attr_reader :solana, :ethereum, :avalanche, :price, :account, :usage, :swap,
27
+ :bitcoin, :base, :arbitrum, :polygon, :bsc, :optimism, :kaia,
28
+ :soneium, :monad, :hyperliquid, :arc, :robinhood, :tron, :near, :sui
27
29
 
28
30
  def initialize(config, http_adapter: nil, websocket_factory: nil)
29
31
  adapter = http_adapter || NetHttpAdapter.new
@@ -303,6 +305,97 @@ module ERPC
303
305
  )
304
306
  end
305
307
  )
308
+ if defined?(BITCOIN_TRANSPORT_FACTS)
309
+ @bitcoin = BitcoinClient.new(
310
+ BitcoinHttpTransport.new(
311
+ facts: BITCOIN_TRANSPORT_FACTS,
312
+ api_key: config.api_key,
313
+ endpoint: config.endpoint,
314
+ direct_endpoint: config.bitcoin_rpc,
315
+ headers: config.headers,
316
+ timeout: config.timeout,
317
+ adapter: adapter,
318
+ namespace: "bitcoin.rpc"
319
+ )
320
+ )
321
+ @base = BaseClient.new(
322
+ SealedEvmHttpTransport.new(
323
+ facts: BASE_TRANSPORT_FACTS,
324
+ api_key: config.api_key,
325
+ endpoint: config.base_endpoint,
326
+ direct_endpoint: config.base_rpc,
327
+ headers: config.headers,
328
+ timeout: config.timeout,
329
+ adapter: adapter,
330
+ namespace: "base.rpc"
331
+ )
332
+ )
333
+ @arbitrum = ArbitrumClient.new(
334
+ SealedEvmHttpTransport.new(
335
+ facts: ARBITRUM_TRANSPORT_FACTS,
336
+ api_key: config.api_key,
337
+ endpoint: config.arbitrum_endpoint,
338
+ direct_endpoint: config.arbitrum_rpc,
339
+ headers: config.headers,
340
+ timeout: config.timeout,
341
+ adapter: adapter,
342
+ namespace: "arbitrum.rpc"
343
+ )
344
+ )
345
+ @polygon = sealed_client(config, POLYGON_TRANSPORT_FACTS, config.polygon_rpc,
346
+ config.polygon_endpoint, "polygon.rpc", PolygonClient, adapter)
347
+ @bsc = sealed_client(config, BSC_TRANSPORT_FACTS, config.bsc_rpc,
348
+ config.bsc_endpoint, "bsc.rpc", BscClient, adapter)
349
+ @optimism = sealed_client(config, OPTIMISM_TRANSPORT_FACTS, config.optimism_rpc,
350
+ config.optimism_endpoint, "optimism.rpc", OptimismClient, adapter)
351
+ @kaia = sealed_client(config, KAIA_TRANSPORT_FACTS, config.kaia_rpc,
352
+ config.kaia_endpoint, "kaia.rpc", KaiaClient, adapter)
353
+ @soneium = sealed_client(config, SONEIUM_TRANSPORT_FACTS, config.soneium_rpc,
354
+ config.soneium_endpoint, "soneium.rpc", SoneiumClient, adapter)
355
+ @monad = sealed_client(config, MONAD_TRANSPORT_FACTS, config.monad_rpc,
356
+ config.monad_endpoint, "monad.rpc", MonadClient, adapter)
357
+ @hyperliquid = sealed_client(config, HYPERLIQUID_TRANSPORT_FACTS, config.hyperliquid_rpc,
358
+ config.hyperliquid_endpoint, "hyperliquid.rpc", HyperliquidClient, adapter)
359
+ @arc = sealed_client(config, ARC_TRANSPORT_FACTS, config.arc_rpc,
360
+ config.arc_endpoint, "arc.rpc", ArcClient, adapter)
361
+ @robinhood = sealed_client(config, ROBINHOOD_TRANSPORT_FACTS, config.robinhood_rpc,
362
+ config.robinhood_endpoint, "robinhood.rpc", RobinhoodClient, adapter)
363
+ @tron = sealed_client(config, TRON_TRANSPORT_FACTS, config.tron_rpc,
364
+ config.tron_endpoint, "tron.rpc", TronClient, adapter)
365
+ @near = NearClient.new(
366
+ NearHttpTransport.new(
367
+ facts: NEAR_TRANSPORT_FACTS,
368
+ api_key: config.api_key,
369
+ endpoint: config.near_endpoint,
370
+ direct_endpoint: config.near_rpc,
371
+ headers: config.headers,
372
+ timeout: config.timeout,
373
+ adapter: adapter,
374
+ namespace: "near.rpc"
375
+ )
376
+ )
377
+ @sui = SuiClient.new(
378
+ SuiGrpcWebTransport.new(
379
+ facts: SUI_TRANSPORT_FACTS,
380
+ api_key: config.api_key,
381
+ endpoint: config.sui_endpoint,
382
+ direct_endpoint: config.sui_grpc_web,
383
+ headers: config.headers,
384
+ timeout: config.timeout,
385
+ adapter: adapter,
386
+ namespace: "sui.grpc"
387
+ )
388
+ )
389
+ else
390
+ # The bootstrap tree may run before the repaired generated projection
391
+ # is present. The released namespaces remain fully constructible in
392
+ # that phase; the new surfaces activate when their private projection
393
+ # is loaded.
394
+ @bitcoin = @base = @arbitrum = @polygon = @bsc = @optimism = @kaia = @soneium = @monad = nil
395
+ @hyperliquid = @arc = @robinhood = @tron = nil
396
+ @near = nil
397
+ @sui = nil
398
+ end
306
399
  @closed = false
307
400
  end
308
401
 
@@ -318,6 +411,21 @@ module ERPC
318
411
 
319
412
  private
320
413
 
414
+ def sealed_client(config, facts, override, endpoint, namespace, client_class, adapter)
415
+ client_class.new(
416
+ SealedEvmHttpTransport.new(
417
+ facts: facts,
418
+ api_key: config.api_key,
419
+ endpoint: endpoint,
420
+ direct_endpoint: override,
421
+ headers: config.headers,
422
+ timeout: config.timeout,
423
+ adapter: adapter,
424
+ namespace: namespace
425
+ )
426
+ )
427
+ end
428
+
321
429
  def avalanche_namespace(transport, methods, prefix)
322
430
  RpcNamespace.new(
323
431
  transport,