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,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "json"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ require_relative "config"
9
+ require_relative "errors"
10
+ require_relative "transport"
11
+
12
+ module ERPC
13
+ NearTransportFacts = Struct.new(:hosted_target, :method_names, keyword_init: true) do
14
+ def initialize(hosted_target:, method_names:)
15
+ super(hosted_target: hosted_target.to_s.freeze, method_names: method_names.map { |name| name.to_s.dup.freeze }.freeze)
16
+ freeze
17
+ end
18
+ end
19
+
20
+ module NearContract
21
+ module_function
22
+
23
+ def facts_from_contract(contract)
24
+ client = contract.fetch("clientContracts").find { |value| value["clientContractId"] == "client-near-rpc" }
25
+ method_set_id = client.fetch("methodSetId")
26
+ hosted = client.fetch("endpointModes").find { |value| value["mode"] == "hosted" }
27
+ method_set = contract.fetch("methodSets").find { |value| value["methodSetId"] == method_set_id }
28
+ NearTransportFacts.new(
29
+ hosted_target: hosted.fetch("defaultTarget"),
30
+ method_names: method_set.fetch("capabilities").map { |item| item.fetch("wireOperation") }
31
+ )
32
+ rescue KeyError, NoMethodError, TypeError
33
+ raise ConfigError, "NEAR contract facts are unavailable", cause: nil
34
+ end
35
+ end
36
+
37
+ module NearTokenScanner
38
+ module_function
39
+
40
+ NUMBER = /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\z/.freeze
41
+
42
+ def known_integer_tokens!(text, method)
43
+ tokens = scan(text)
44
+ paths = case method
45
+ when "status"
46
+ [["result", "latest_protocol_version"], ["result", "protocol_version"], ["result", "sync_info", "latest_block_height"]]
47
+ when "block"
48
+ [["result", "header", "height"], ["result", "header", "timestamp"]]
49
+ when "query"
50
+ [["result", "storage_usage"], ["result", "block_height"], ["result", "storage_paid_at"], ["result", "bootstrap_nonce"]]
51
+ else
52
+ []
53
+ end
54
+ paths.each do |path|
55
+ raw = tokens[path]
56
+ next if raw.nil?
57
+ unless raw.match?(/\A(?:0|[1-9][0-9]*)\z/) && raw.to_i <= 18_446_744_073_709_551_615
58
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil
59
+ end
60
+ end
61
+ nil
62
+ end
63
+
64
+ def scan(text)
65
+ scanner = Scanner.new(text)
66
+ scanner.run
67
+ rescue InvalidResponseError
68
+ raise
69
+ rescue StandardError
70
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil
71
+ end
72
+
73
+ class Scanner
74
+ def initialize(text)
75
+ @text = text; @index = 0; @tokens = {}
76
+ end
77
+
78
+ def run
79
+ skip; value([]); skip
80
+ raise_invalid! unless @index == @text.length
81
+ @tokens
82
+ end
83
+
84
+ private
85
+
86
+ def value(path)
87
+ @tokens.delete_if { |candidate, _| candidate.length >= path.length && candidate.first(path.length) == path }
88
+ skip; raise_invalid! if @index >= @text.length
89
+ case @text[@index]
90
+ when '"' then string
91
+ when "{" then object(path)
92
+ when "[" then array(path)
93
+ when "-", /[0-9]/ then number(path)
94
+ when "t" then literal("true")
95
+ when "f" then literal("false")
96
+ when "n" then literal("null")
97
+ else raise_invalid!
98
+ end
99
+ end
100
+
101
+ def object(path)
102
+ @index += 1; skip
103
+ if @text[@index] == "}"
104
+ @index += 1; return
105
+ end
106
+ loop do
107
+ raise_invalid! unless @text[@index] == '"'
108
+ key_start = @index; string
109
+ key = JSON.parse(@text[key_start...@index], create_additions: false)
110
+ skip; raise_invalid! unless @text[@index] == ":"; @index += 1
111
+ value(path + [key]); skip
112
+ if @text[@index] == ","
113
+ @index += 1; skip
114
+ elsif @text[@index] == "}"
115
+ @index += 1; break
116
+ else
117
+ raise_invalid!
118
+ end
119
+ end
120
+ end
121
+
122
+ def array(path)
123
+ @index += 1; skip; position = 0
124
+ if @text[@index] == "]"
125
+ @index += 1; return
126
+ end
127
+ loop do
128
+ value(path + [position]); position += 1; skip
129
+ if @text[@index] == ","
130
+ @index += 1; skip
131
+ elsif @text[@index] == "]"
132
+ @index += 1; break
133
+ else
134
+ raise_invalid!
135
+ end
136
+ end
137
+ end
138
+
139
+ def string
140
+ raise_invalid! unless @text[@index] == '"'; @index += 1
141
+ while @index < @text.length
142
+ char = @text[@index]
143
+ if char == '"'
144
+ @index += 1; return
145
+ elsif char == "\\"
146
+ @index += 1; raise_invalid! if @index >= @text.length
147
+ if @text[@index] == "u"
148
+ 4.times { @index += 1; raise_invalid! if @index >= @text.length || !@text[@index].match?(/[0-9A-Fa-f]/) }
149
+ elsif !%w[" \\ / b f n r t].include?(@text[@index])
150
+ raise_invalid!
151
+ end
152
+ elsif char.ord < 0x20
153
+ raise_invalid!
154
+ end
155
+ @index += 1
156
+ end
157
+ raise_invalid!
158
+ end
159
+
160
+ def number(path)
161
+ start = @index
162
+ @index += 1 if @text[@index] == "-"
163
+ if @text[@index] == "0"
164
+ @index += 1; raise_invalid! if @text[@index]&.match?(/[0-9]/)
165
+ elsif @text[@index]&.match?(/[1-9]/)
166
+ @index += 1 while @text[@index]&.match?(/[0-9]/)
167
+ else
168
+ raise_invalid!
169
+ end
170
+ if @text[@index] == "."
171
+ @index += 1; start_fraction = @index; @index += 1 while @text[@index]&.match?(/[0-9]/); raise_invalid! if start_fraction == @index
172
+ end
173
+ if %w[e E].include?(@text[@index])
174
+ @index += 1; @index += 1 if %w[+ -].include?(@text[@index]); start_exp = @index
175
+ @index += 1 while @text[@index]&.match?(/[0-9]/); raise_invalid! if start_exp == @index
176
+ end
177
+ raw = @text[start...@index]
178
+ raise_invalid! unless raw.match?(NUMBER)
179
+ @tokens[path] = raw
180
+ end
181
+
182
+ def literal(value)
183
+ raise_invalid! unless @text[@index, value.length] == value
184
+ @index += value.length
185
+ end
186
+
187
+ def skip
188
+ @index += 1 while @index < @text.length && " \t\r\n".include?(@text[@index])
189
+ end
190
+
191
+ def raise_invalid!
192
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil
193
+ end
194
+ end
195
+ private_constant :Scanner
196
+ end
197
+
198
+ NEAR_TRANSPORT_FACTS = NearContract.facts_from_contract(RPC_NETWORK_CONTRACT)
199
+ NEAR_RPC_METHODS = NEAR_TRANSPORT_FACTS.method_names
200
+ private_constant :NEAR_TRANSPORT_FACTS
201
+
202
+ class NearHttpTransport
203
+ attr_reader :method_names
204
+
205
+ def initialize(facts:, api_key: nil, endpoint: nil, direct_endpoint: nil, headers: {}, timeout: DEFAULT_TIMEOUT,
206
+ adapter: NetHttpAdapter.new, namespace: "near.rpc")
207
+ @facts = facts
208
+ @method_names = facts.method_names
209
+ @api_key = api_key.is_a?(String) && !api_key.strip.empty? ? api_key.strip : nil
210
+ @timeout = timeout.to_f
211
+ raise ConfigError, "timeout must be positive", cause: nil unless @timeout.positive?
212
+ @adapter = adapter
213
+ @namespace = namespace.to_s.freeze
214
+ @mutex = Mutex.new
215
+ @next_id = 0
216
+ @available = false
217
+ @direct = !direct_endpoint.nil?
218
+
219
+ if direct_endpoint
220
+ unless direct_endpoint.is_a?(RpcEndpointConfig)
221
+ raise ConfigError, "near_rpc must use RpcEndpointConfig", cause: nil
222
+ end
223
+ raise ConfigError, "near_rpc does not support WebSocket URLs", cause: nil if direct_endpoint.websocket_url
224
+ @target = validate_url(direct_endpoint.http_url)
225
+ @headers = direct_endpoint.headers.dup
226
+ @redactions = Redaction.send(:direct_variants_with_path, http_url: direct_endpoint.http_url, headers: direct_endpoint.headers)
227
+ @available = true
228
+ elsif @api_key
229
+ @target = hosted_target(endpoint || facts.hosted_target)
230
+ @headers = headers.to_h.dup
231
+ @redactions = [@api_key]
232
+ @available = true
233
+ else
234
+ @target = nil
235
+ @headers = {}
236
+ @redactions = []
237
+ end
238
+ end
239
+
240
+ def endpoint
241
+ return "" unless @available
242
+
243
+ uri = URI.parse(@target)
244
+ uri.path = "/"
245
+ uri.query = nil
246
+ uri.fragment = nil
247
+ uri.to_s
248
+ rescue URI::InvalidURIError
249
+ ""
250
+ end
251
+
252
+ def inspect
253
+ "#<#{self.class} endpoint=#{endpoint.inspect} header_names=#{safe_header_names.inspect}>"
254
+ end
255
+
256
+ def request(method, params)
257
+ raise ConfigError, "NEAR method is not in the accepted read contract", cause: nil unless @method_names.include?(method)
258
+ raise NotConfiguredError, @namespace unless @available
259
+
260
+ id = @mutex.synchronize { @next_id += 1 }
261
+ body = JSON.generate("jsonrpc" => "2.0", "id" => id, "method" => method, "params" => params)
262
+ response = nil
263
+ Timeout.timeout(@timeout) do
264
+ response = @adapter.request(method: :post, url: @target, headers: request_headers, body: body, timeout: @timeout)
265
+ end
266
+ raise HttpError, response.status unless response.respond_to?(:status) && response.status.to_i.between?(200, 299)
267
+ decode(response.body.to_s, id, method)
268
+ rescue ::Timeout::Error
269
+ raise TimeoutError, @timeout, cause: nil
270
+ rescue InvalidResponseError, JsonRpcError, HttpError, NotConfiguredError, ConfigError, TimeoutError, TransportError
271
+ raise
272
+ rescue JSON::ParserError, JSON::GeneratorError, ArgumentError, TypeError
273
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil
274
+ rescue IOError, EOFError, SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
275
+ raise TransportError, "Unable to reach ERPC", cause: nil
276
+ end
277
+
278
+ private
279
+
280
+ def validate_url(value)
281
+ input = value.to_s
282
+ uri = URI.parse(input)
283
+ valid = uri.absolute? && uri.host && %w[http https].include?(uri.scheme) && !input.include?("#") && !input.match?(%r{://[^/?#]*@})
284
+ raise ConfigError, "HTTP endpoint must be an absolute HTTP(S) URL", cause: nil unless valid
285
+ input.freeze
286
+ rescue URI::InvalidURIError, ArgumentError
287
+ raise ConfigError, "HTTP endpoint must be an absolute HTTP(S) URL", cause: nil
288
+ end
289
+
290
+ def hosted_target(value)
291
+ uri = URI.parse(validate_url(value))
292
+ query = URI.decode_www_form(uri.query.to_s).reject { |key, _| key.casecmp?("api-key") }
293
+ query << ["api-key", @api_key]
294
+ uri.query = URI.encode_www_form(query)
295
+ uri.fragment = nil
296
+ uri.to_s
297
+ end
298
+
299
+ def request_headers
300
+ values = @headers.dup
301
+ values.delete_if { |key, _| %w[accept content-type].include?(key.to_s.downcase) }
302
+ values["accept"] = "application/json"
303
+ values["content-type"] = "application/json"
304
+ values
305
+ end
306
+
307
+ def safe_header_names
308
+ @headers.keys.map(&:to_s).sort
309
+ end
310
+
311
+ def decode(text, expected_id, method)
312
+ NearTokenScanner.known_integer_tokens!(text, method)
313
+ decimal_class = Class.new do
314
+ define_singleton_method(:new) do |raw|
315
+ value = BigDecimal(raw)
316
+ zero_token = raw.match?(/\A-?(?:0+\.?0*|0*\.0+)(?:[eE][+-]?\d+)?\z/)
317
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil unless value.finite? && (zero_token || !value.zero?)
318
+ value
319
+ rescue InvalidResponseError
320
+ raise
321
+ rescue StandardError
322
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil
323
+ end
324
+ end
325
+ response = JSON.parse(text, create_additions: false, allow_nan: false, decimal_class: decimal_class)
326
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil unless response.is_a?(Hash) && response["id"] == expected_id
327
+ if response.key?("error") && !response["error"].nil?
328
+ error = response["error"]
329
+ unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
330
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil
331
+ end
332
+ data = error.key?("data") ? Redaction.value(error["data"], @redactions) : nil
333
+ raise JsonRpcError.new(error["code"], Redaction.text(error["message"], @redactions), data), cause: nil
334
+ end
335
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil unless response.key?("result")
336
+ response["result"]
337
+ rescue InvalidResponseError, JsonRpcError
338
+ raise
339
+ rescue JSON::ParserError, JSON::NestingError, ArgumentError, TypeError, SystemStackError
340
+ raise InvalidResponseError.new("ERPC returned an invalid NEAR response"), cause: nil
341
+ end
342
+ end
343
+
344
+ private_constant :NearTransportFacts, :NearContract, :NearTokenScanner, :NearHttpTransport
345
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module ERPC
6
+ OPTIMISM_TRANSPORT_FACTS = SealedEvmContract.facts_from_contract(RPC_NETWORK_CONTRACT, "client-optimism-rpc")
7
+ OPTIMISM_RPC_METHODS = OPTIMISM_TRANSPORT_FACTS.method_names
8
+ private_constant :OPTIMISM_TRANSPORT_FACTS
9
+
10
+ class OptimismRpc < 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 OptimismClient
18
+ attr_reader :rpc
19
+
20
+ def initialize(transport)
21
+ @rpc = OptimismRpc.new(transport)
22
+ freeze
23
+ end
24
+ end
25
+
26
+ module Optimism
27
+ module_function
28
+
29
+ def create_client(transport)
30
+ OptimismClient.new(transport)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module ERPC
6
+ POLYGON_TRANSPORT_FACTS = SealedEvmContract.facts_from_contract(RPC_NETWORK_CONTRACT, "client-polygon-rpc")
7
+ POLYGON_RPC_METHODS = POLYGON_TRANSPORT_FACTS.method_names
8
+ private_constant :POLYGON_TRANSPORT_FACTS
9
+
10
+ class PolygonRpc < 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 PolygonClient
18
+ attr_reader :rpc
19
+
20
+ def initialize(transport)
21
+ @rpc = PolygonRpc.new(transport)
22
+ freeze
23
+ end
24
+ end
25
+
26
+ module Polygon
27
+ module_function
28
+
29
+ def create_client(transport)
30
+ PolygonClient.new(transport)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module ERPC
6
+ ROBINHOOD_TRANSPORT_FACTS = SealedEvmContract.facts_from_contract(RPC_NETWORK_CONTRACT, "client-robinhood-rpc")
7
+ ROBINHOOD_RPC_METHODS = ROBINHOOD_TRANSPORT_FACTS.method_names
8
+ private_constant :ROBINHOOD_TRANSPORT_FACTS
9
+
10
+ class RobinhoodRpc < 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 RobinhoodClient
18
+ attr_reader :rpc
19
+
20
+ def initialize(transport)
21
+ @rpc = RobinhoodRpc.new(transport)
22
+ freeze
23
+ end
24
+ end
25
+
26
+ module Robinhood
27
+ module_function
28
+
29
+ def create_client(transport)
30
+ RobinhoodClient.new(transport)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module ERPC
6
+ SONEIUM_TRANSPORT_FACTS = SealedEvmContract.facts_from_contract(RPC_NETWORK_CONTRACT, "client-soneium-rpc")
7
+ SONEIUM_RPC_METHODS = SONEIUM_TRANSPORT_FACTS.method_names
8
+ private_constant :SONEIUM_TRANSPORT_FACTS
9
+
10
+ class SoneiumRpc < 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 SoneiumClient
18
+ attr_reader :rpc
19
+
20
+ def initialize(transport)
21
+ @rpc = SoneiumRpc.new(transport)
22
+ freeze
23
+ end
24
+ end
25
+
26
+ module Soneium
27
+ module_function
28
+
29
+ def create_client(transport)
30
+ SoneiumClient.new(transport)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public Sui namespace entrypoint. The bounded transport, protobuf projection,
4
+ # and value objects remain private implementation details of sui_grpc_web.rb.
5
+ require_relative "sui_grpc_web"