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,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require "uri"
5
+
6
+ require_relative "sui_proto"
7
+
8
+ module ERPC
9
+ class SuiGrpcWebEndpointConfig
10
+ attr_reader :base_url, :headers
11
+
12
+ def initialize(base_url:, headers: {})
13
+ @base_url = validate_url(base_url)
14
+ values = headers.to_h
15
+ raise ConfigError, "sui_grpc_web headers must contain string names and values", cause: nil unless values.all? { |name, value| name.is_a?(String) && value.is_a?(String) }
16
+ @headers = values.dup.freeze
17
+ freeze
18
+ end
19
+
20
+ def inspect
21
+ uri = URI.parse(base_url); uri.path = "/"; uri.query = nil; uri.fragment = nil
22
+ "#<#{self.class} base_url=#{uri.to_s.inspect} header_names=#{headers.keys.inspect}>"
23
+ rescue URI::InvalidURIError
24
+ "#<#{self.class} base_url=\"[REDACTED]\" header_names=#{headers.keys.inspect}>"
25
+ end
26
+
27
+ alias to_s inspect
28
+
29
+ private
30
+
31
+ def validate_url(value)
32
+ input = value.to_s
33
+ uri = URI.parse(input)
34
+ valid = uri.absolute? && uri.host && %w[http https].include?(uri.scheme) && !input.include?("#") && !input.match?(%r{://[^/?#]*@})
35
+ raise ConfigError, "sui_grpc_web base_url must be an absolute HTTP(S) URL", cause: nil unless valid
36
+ input.freeze
37
+ rescue URI::InvalidURIError, ArgumentError
38
+ raise ConfigError, "sui_grpc_web base_url must be an absolute HTTP(S) URL", cause: nil
39
+ end
40
+ end
41
+
42
+ SuiTransportFacts = Struct.new(:hosted_target, :method_names, :public_methods, keyword_init: true) do
43
+ def initialize(hosted_target:, method_names:, public_methods: {})
44
+ super(hosted_target: hosted_target.to_s.freeze,
45
+ method_names: method_names.map { |name| name.to_s.dup.freeze }.freeze,
46
+ public_methods: public_methods.each_with_object({}) do |(name, wire), result|
47
+ result[name.to_s.dup.freeze] = wire.to_s.dup.freeze
48
+ end.freeze)
49
+ freeze
50
+ end
51
+ end
52
+
53
+ module SuiContract
54
+ module_function
55
+
56
+ def facts_from_contract(contract)
57
+ client = contract.fetch("clientContracts").find { |value| value["clientContractId"] == "client-sui-grpc" }
58
+ method_set_id = client.fetch("methodSetId")
59
+ hosted = client.fetch("endpointModes").find { |value| value["mode"] == "hosted" }
60
+ method_set = contract.fetch("methodSets").find { |value| value["methodSetId"] == method_set_id }
61
+ capabilities = method_set.fetch("capabilities")
62
+ selected = capabilities.each_with_object({}) do |item, result|
63
+ public_name = case item.fetch("capabilityId")
64
+ when "sui-get-service-info" then "get_service_info"
65
+ when "sui-get-object" then "get_object"
66
+ when "sui-get-balance" then "get_balance"
67
+ else raise KeyError
68
+ end
69
+ result[public_name] = item.fetch("wireOperation")
70
+ end
71
+ SuiTransportFacts.new(hosted_target: hosted.fetch("defaultTarget"), method_names: selected.values, public_methods: selected)
72
+ rescue KeyError, NoMethodError, TypeError
73
+ raise ConfigError, "Sui contract facts are unavailable", cause: nil
74
+ end
75
+ end
76
+
77
+ SUI_TRANSPORT_FACTS = SuiContract.facts_from_contract(RPC_NETWORK_CONTRACT)
78
+ SUI_GRPC_WEB_METHODS = %w[get_service_info get_object get_balance].map { |name| SUI_TRANSPORT_FACTS.public_methods.fetch(name) }.freeze
79
+ private_constant :SUI_TRANSPORT_FACTS
80
+
81
+ class SuiGrpcWebTransport
82
+ attr_reader :endpoint
83
+
84
+ def initialize(facts:, api_key: nil, endpoint: nil, direct_endpoint: nil, headers: {}, timeout: DEFAULT_TIMEOUT,
85
+ adapter: NetHttpAdapter.new, namespace: "sui.grpc")
86
+ @facts = facts
87
+ @api_key = api_key.is_a?(String) && !api_key.strip.empty? ? api_key.strip : nil
88
+ @timeout = timeout.to_f
89
+ raise ConfigError, "timeout must be positive", cause: nil unless @timeout.positive?
90
+ @adapter = adapter
91
+ @namespace = namespace.to_s.freeze
92
+ @mutex = Mutex.new
93
+ @next_id = 0
94
+ @direct = !direct_endpoint.nil?
95
+ if direct_endpoint
96
+ config = direct_endpoint.is_a?(SuiGrpcWebEndpointConfig) ? direct_endpoint : SuiGrpcWebEndpointConfig.new(base_url: direct_endpoint.http_url, headers: direct_endpoint.headers)
97
+ @target_prefix = config.base_url
98
+ @headers = config.headers.dup
99
+ @redactions = Redaction.send(:direct_variants_with_path, http_url: config.base_url, headers: config.headers)
100
+ @available = true
101
+ elsif @api_key
102
+ @target_prefix = endpoint || facts.hosted_target
103
+ @headers = headers.to_h.dup
104
+ @redactions = [@api_key]
105
+ @available = true
106
+ else
107
+ @target_prefix = nil
108
+ @headers = {}
109
+ @redactions = []
110
+ @available = false
111
+ end
112
+ @endpoint = if @available
113
+ uri = URI.parse(@target_prefix); uri.path = "/"; uri.query = nil; uri.fragment = nil; uri.to_s
114
+ else
115
+ ""
116
+ end
117
+ rescue URI::InvalidURIError
118
+ raise ConfigError, "sui_grpc_web endpoint is invalid", cause: nil
119
+ end
120
+
121
+ def inspect
122
+ "#<#{self.class} endpoint=#{endpoint.inspect} header_names=#{safe_header_names.inspect}>"
123
+ end
124
+
125
+ def request(operation, payload, decoder)
126
+ raise NotConfiguredError, @namespace unless @available
127
+ path = SUI_GRPC_WEB_METHODS.fetch(operation)
128
+ target = join(path)
129
+ result = nil
130
+ Timeout.timeout(@timeout) do
131
+ yielded = false
132
+ @adapter.request_stream(method: :post, url: target, headers: request_headers, body: payload, timeout: @timeout) do |status, chunks, response_headers|
133
+ yielded = true
134
+ raise HttpError, status unless status.to_i == 200
135
+ content_types = Array(response_headers && (response_headers["content-type"] || response_headers["Content-Type"])).flatten
136
+ raise InvalidResponseError.new("ERPC returned an invalid Sui response"), cause: nil unless content_types.length == 1 && content_types.first.to_s == SUI_GRPC_WEB_CONTENT_TYPE
137
+ result = decoder.call(SuiProto.decode_response(chunks))
138
+ end
139
+ raise TransportError, "Unable to reach ERPC", cause: nil unless yielded
140
+ end
141
+ result
142
+ rescue ::Timeout::Error
143
+ raise TimeoutError, @timeout, cause: nil
144
+ rescue InvalidResponseError, JsonRpcError, HttpError, NotConfiguredError, ConfigError, TimeoutError, TransportError
145
+ raise
146
+ rescue IOError, EOFError, SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
147
+ raise TransportError, "Unable to reach ERPC", cause: nil
148
+ end
149
+
150
+ private
151
+
152
+ def join(path)
153
+ uri = URI.parse(@target_prefix)
154
+ base = uri.path.to_s.sub(%r{/+\z}, "")
155
+ uri.path = "#{base}/#{path.sub(%r{\A/+}, "")}"
156
+ if @direct
157
+ uri.to_s
158
+ else
159
+ query = URI.decode_www_form(uri.query.to_s).reject { |key, _| key.casecmp?("api-key") }
160
+ uri.query = URI.encode_www_form(query)
161
+ uri.fragment = nil
162
+ uri.to_s
163
+ end
164
+ end
165
+
166
+ def request_headers
167
+ values = @headers.dup
168
+ unless @direct
169
+ values.delete_if { |key, _| key.to_s.casecmp?("authorization") }
170
+ values["authorization"] = "Bearer #{@api_key}"
171
+ end
172
+ values.delete_if { |key, _| %w[accept content-type x-grpc-web].include?(key.to_s.downcase) }
173
+ values["accept"] = SUI_GRPC_WEB_CONTENT_TYPE
174
+ values["content-type"] = SUI_GRPC_WEB_CONTENT_TYPE
175
+ values["x-grpc-web"] = "1"
176
+ values
177
+ end
178
+
179
+ def safe_header_names
180
+ @headers.keys.map(&:to_s).sort
181
+ end
182
+ end
183
+
184
+ class SuiGrpcWebPendingRequest
185
+ def initialize(&block); @block = block; end
186
+ def send; @block.call; end
187
+ end
188
+
189
+ class SuiGrpcWebClient
190
+ attr_reader :endpoint
191
+
192
+ def initialize(transport)
193
+ @transport = transport
194
+ @endpoint = transport.endpoint
195
+ end
196
+
197
+ def inspect
198
+ "#<#{self.class} endpoint=#{endpoint.inspect}>"
199
+ end
200
+
201
+ def get_service_info
202
+ SuiGrpcWebPendingRequest.new { @transport.request(0, SuiProto.service_info_request, SuiProto.method(:parse_service_info)) }
203
+ end
204
+
205
+ def get_object(object_id, version = nil)
206
+ SuiGrpcWebPendingRequest.new { @transport.request(1, SuiProto.object_request(object_id, version), SuiProto.method(:parse_object_response)) }
207
+ end
208
+
209
+ def get_balance(owner, coin_type)
210
+ SuiGrpcWebPendingRequest.new { @transport.request(2, SuiProto.balance_request(owner, coin_type), SuiProto.method(:parse_balance_response)) }
211
+ end
212
+ end
213
+
214
+ class SuiClient
215
+ attr_reader :grpc
216
+ def initialize(transport); @grpc = SuiGrpcWebClient.new(transport); freeze; end
217
+ end
218
+
219
+ module Sui
220
+ module_function
221
+ def create_client(transport); SuiClient.new(transport); end
222
+ end
223
+
224
+ private_constant :SuiTransportFacts, :SuiContract, :SUI_TRANSPORT_FACTS,
225
+ :SuiGrpcWebTransport, :SuiGrpcWebPendingRequest
226
+ end
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) Mysten Labs, Inc.
4
+ # Adapted handwritten bounded Sui schema projection.
5
+ #
6
+ # Source provenance: MystenLabs/sui-apis commit
7
+ # aa9ba2c13b2a9348059b38110e64af39849a0377; selected source paths are
8
+ # proto/sui/rpc/v2/ledger_service.proto, state_service.proto, object.proto,
9
+ # and proto/google/protobuf/timestamp.proto. This file implements only the
10
+ # three read-only unary messages used by this SDK; unrelated schema fields and
11
+ # general protobuf reflection/runtime support are intentionally omitted.
12
+ # SPDX-License-Identifier: Apache-2.0 AND BSD-3-Clause
13
+
14
+ module ERPC
15
+ module SuiProto
16
+ module_function
17
+
18
+ MAX_U64 = 18_446_744_073_709_551_615
19
+ MAX_I64 = 9_223_372_036_854_775_807
20
+ MIN_I64 = -9_223_372_036_854_775_808
21
+ MAX_REQUEST_BYTES = 1_048_576
22
+ MAX_RESPONSE_BYTES = 8_388_608
23
+ MAX_TRAILER_BYTES = 8_192
24
+ MAX_RECURSION = 100
25
+
26
+ def validate_u64(value, message = "Sui value must be a u64")
27
+ unless value.is_a?(Integer) && !value.is_a?(TrueClass) && !value.is_a?(FalseClass) && value.between?(0, MAX_U64)
28
+ raise ConfigError, message, cause: nil
29
+ end
30
+ value
31
+ end
32
+
33
+ def validate_input_string(value, label)
34
+ unless value.is_a?(String)
35
+ raise ConfigError, "#{label} must be a non-empty UTF-8 string", cause: nil
36
+ end
37
+
38
+ bytes = value.dup.force_encoding(Encoding::BINARY)
39
+ utf8 = bytes.dup.force_encoding(Encoding::UTF_8)
40
+ unless !bytes.empty? && utf8.valid_encoding? && !utf8.match?(/[\x00-\x1f\x7f]/)
41
+ raise ConfigError, "#{label} must be a non-empty UTF-8 string", cause: nil
42
+ end
43
+ utf8
44
+ end
45
+
46
+ def encode_varint(value)
47
+ validate_u64(value)
48
+ output = +"".b
49
+ loop do
50
+ byte = value & 0x7f
51
+ value >>= 7
52
+ output << (value.zero? ? byte : byte | 0x80)
53
+ break if value.zero?
54
+ end
55
+ output
56
+ end
57
+
58
+ def encode_key(field, wire)
59
+ encode_varint((field << 3) | wire)
60
+ end
61
+
62
+ def encode_string(field, value)
63
+ bytes = value.to_s.encode(Encoding::UTF_8).b
64
+ encode_key(field, 2) + encode_varint(bytes.bytesize) + bytes
65
+ end
66
+
67
+ def encode_u64(field, value)
68
+ encode_key(field, 0) + encode_varint(value)
69
+ end
70
+
71
+ def frame(payload)
72
+ raise ConfigError, "Sui request exceeds the 1 MiB framed limit", cause: nil if payload.bytesize + 5 > MAX_REQUEST_BYTES
73
+ "\x00".b + [payload.bytesize].pack("N") + payload
74
+ end
75
+
76
+ def service_info_request
77
+ frame("".b)
78
+ end
79
+
80
+ def object_request(object_id, version = nil)
81
+ object_id = validate_input_string(object_id, "object_id")
82
+ payload = encode_string(1, object_id)
83
+ payload << encode_u64(2, validate_u64(version, "version must be a u64")) unless version.nil?
84
+ frame(payload)
85
+ end
86
+
87
+ def balance_request(owner, coin_type)
88
+ payload = encode_string(1, validate_input_string(owner, "owner"))
89
+ payload << encode_string(2, validate_input_string(coin_type, "coin_type"))
90
+ frame(payload)
91
+ end
92
+
93
+ def decode_response(chunks)
94
+ total = 0
95
+ body = +"".b
96
+ enum = if chunks.respond_to?(:each)
97
+ chunks
98
+ else
99
+ raise InvalidResponseError.new("ERPC returned an invalid Sui response"), cause: nil
100
+ end
101
+ enum.each do |chunk|
102
+ bytes = chunk.to_s.b
103
+ raise InvalidResponseError.new("ERPC returned an invalid Sui response"), cause: nil if total + bytes.bytesize > MAX_RESPONSE_BYTES
104
+ body << bytes
105
+ total += bytes.bytesize
106
+ end
107
+ decode_frames(body)
108
+ rescue InvalidResponseError
109
+ raise
110
+ rescue TransportError, TimeoutError
111
+ raise
112
+ rescue IOError, EOFError, SocketError, SystemCallError
113
+ raise TransportError, "Unable to reach ERPC", cause: nil
114
+ rescue ::Timeout::Error
115
+ raise
116
+ rescue StandardError
117
+ raise InvalidResponseError.new("ERPC returned an invalid Sui response"), cause: nil
118
+ end
119
+
120
+ def decode_frames(body)
121
+ cursor = 0
122
+ data = nil
123
+ trailer = nil
124
+ until cursor == body.bytesize
125
+ raise_invalid! if body.bytesize - cursor < 5
126
+ marker = body.getbyte(cursor)
127
+ length = body.byteslice(cursor + 1, 4).unpack1("N")
128
+ cursor += 5
129
+ raise_invalid! if length > body.bytesize - cursor
130
+ payload = body.byteslice(cursor, length)
131
+ cursor += length
132
+ case marker
133
+ when 0x00
134
+ raise_invalid! unless data.nil? && trailer.nil?
135
+ data = payload
136
+ when 0x80
137
+ raise_invalid! unless !data.nil? && trailer.nil?
138
+ trailer = payload
139
+ else
140
+ raise_invalid!
141
+ end
142
+ end
143
+ raise_invalid! unless data && trailer
144
+ raise_invalid! if trailer.bytesize > MAX_TRAILER_BYTES
145
+ validate_trailer(trailer)
146
+ data
147
+ end
148
+
149
+ def validate_trailer(payload)
150
+ text = payload.dup.force_encoding(Encoding::UTF_8)
151
+ raise_invalid! unless text.valid_encoding? && text.end_with?("\r\n")
152
+ statuses = []
153
+ lines = text.split("\r\n", -1)
154
+ lines.pop
155
+ lines.each do |line|
156
+ name, separator, value = line.partition(":")
157
+ raise_invalid! if separator.empty? || name.empty? || !name.match?(/\A[a-z0-9!#\$%&'\*+\-.\^_`|~]+\z/)
158
+ raise_invalid! if value.match?(/[\x00-\x08\x0a\x0b\x0c\x0d\x0e-\x1f\x7f]/)
159
+ trimmed = value.sub(/\A[ \t]*/, "").sub(/[ \t]*\z/, "")
160
+ statuses << trimmed if name == "grpc-status"
161
+ end
162
+ raise_invalid! unless statuses.length == 1 && statuses.first == "0"
163
+ nil
164
+ end
165
+
166
+ def parse_service_info(payload)
167
+ fields = parse_message(payload, 0, { 1 => 2, 2 => 2, 3 => 0, 4 => 0, 5 => 2, 6 => 0, 7 => 0, 8 => 2 },
168
+ repeated_fields: [5], string_fields: [1, 2, 8])
169
+ required = [1, 2, 3, 4, 5, 6, 7]
170
+ raise_invalid! unless required.all? { |field| fields.key?(field) }
171
+ timestamp = parse_timestamp_fields(merge_embedded(fields.fetch(5), 1, { 1 => 0, 2 => 0 }))
172
+ SuiServiceInfo.new(
173
+ chain_id: string_field(fields.fetch(1)), chain: string_field(fields.fetch(2)), epoch: u64_field(fields.fetch(3)),
174
+ checkpoint_height: u64_field(fields.fetch(4)), timestamp_seconds: timestamp.fetch(:seconds), timestamp_nanos: timestamp.fetch(:nanos),
175
+ lowest_available_checkpoint: u64_field(fields.fetch(6)), lowest_available_checkpoint_objects: u64_field(fields.fetch(7)),
176
+ server: fields.key?(8) ? string_field(fields.fetch(8)) : nil
177
+ )
178
+ end
179
+
180
+ def parse_object_response(payload)
181
+ outer = parse_message(payload, 0, { 1 => 2 }, repeated_fields: [1]); raise_invalid! unless outer.key?(1)
182
+ fields = merge_embedded(outer.fetch(1), 1, { 2 => 2, 3 => 0, 4 => 2 }, string_fields: [2, 4])
183
+ required = [2, 3, 4]; raise_invalid! unless required.all? { |field| fields.key?(field) }
184
+ SuiObjectSummary.new(object_id: string_field(fields.fetch(2)), version: u64_field(fields.fetch(3)), digest: string_field(fields.fetch(4)))
185
+ end
186
+
187
+ def parse_balance_response(payload)
188
+ outer = parse_message(payload, 0, { 1 => 2 }, repeated_fields: [1]); raise_invalid! unless outer.key?(1)
189
+ fields = merge_embedded(outer.fetch(1), 1, { 1 => 2, 3 => 0, 4 => 0, 5 => 0 }, string_fields: [1])
190
+ required = [1, 3, 4, 5]; raise_invalid! unless required.all? { |field| fields.key?(field) }
191
+ balance = u64_field(fields.fetch(3)); address_balance = u64_field(fields.fetch(4)); coin_balance = u64_field(fields.fetch(5))
192
+ raise_invalid! unless address_balance <= MAX_U64 - coin_balance && address_balance + coin_balance == balance
193
+ SuiBalance.new(coin_type: string_field(fields.fetch(1)), balance: balance, address_balance: address_balance, coin_balance: coin_balance)
194
+ end
195
+
196
+ # Protocol Buffers - Google's data interchange format
197
+ # Copyright 2008 Google Inc. All rights reserved.
198
+ # https://developers.google.com/protocol-buffers/
199
+ #
200
+ # Redistribution and use in source and binary forms, with or without
201
+ # modification, are permitted provided that the following conditions are
202
+ # met:
203
+ #
204
+ # * Redistributions of source code must retain the above copyright
205
+ # notice, this list of conditions and the following disclaimer.
206
+ # * Redistributions in binary form must reproduce the above
207
+ # copyright notice, this list of conditions and the following disclaimer
208
+ # in the documentation and/or other materials provided with the
209
+ # distribution.
210
+ # * Neither the name of Google Inc. nor the names of its
211
+ # contributors may be used to endorse or promote products derived from
212
+ # this software without specific prior written permission.
213
+ #
214
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
215
+ # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
216
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
217
+ # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
218
+ # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
219
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
220
+ # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
221
+ # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
222
+ # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
223
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
224
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
225
+ def parse_timestamp(payload, depth = 0)
226
+ fields = parse_message(payload, depth, { 1 => 0, 2 => 0 })
227
+ parse_timestamp_fields(fields)
228
+ end
229
+
230
+ def parse_timestamp_fields(fields)
231
+ seconds = fields.key?(1) ? signed_i64(last_scalar(fields.fetch(1))) : 0
232
+ nanos = fields.key?(2) ? signed_i32(last_scalar(fields.fetch(2))) : 0
233
+ raise_invalid! unless nanos.between?(0, 999_999_999)
234
+ { seconds: seconds, nanos: nanos }
235
+ end
236
+
237
+ def parse_message(payload, depth = 0, schema = nil, repeated_fields: [], string_fields: [])
238
+ raise_invalid! if depth > MAX_RECURSION
239
+ fields = {}
240
+ index = 0
241
+ while index < payload.bytesize
242
+ key, index = read_varint(payload, index)
243
+ field = key >> 3; wire = key & 0x07
244
+ raise_invalid! if field.zero? || field > 536_870_911
245
+ raise_invalid! if schema && schema.key?(field) && schema.fetch(field) != wire
246
+ case wire
247
+ when 0
248
+ value, index = read_varint(payload, index); fields[field] = value
249
+ when 1
250
+ raise_invalid! if index + 8 > payload.bytesize
251
+ index += 8
252
+ when 2
253
+ length, index = read_varint(payload, index); raise_invalid! if length > payload.bytesize - index
254
+ value = payload.byteslice(index, length); index += length
255
+ validate_utf8_fragment(value) if string_fields.include?(field)
256
+ if repeated_fields.include?(field)
257
+ fields[field] ||= []
258
+ fields[field] << value
259
+ else
260
+ fields[field] = value
261
+ end
262
+ when 3
263
+ index = skip_group(payload, index, field, depth + 1)
264
+ when 4
265
+ raise_invalid!
266
+ when 5
267
+ raise_invalid! if index + 4 > payload.bytesize
268
+ index += 4
269
+ else
270
+ raise_invalid!
271
+ end
272
+ end
273
+ fields
274
+ end
275
+
276
+ def merge_embedded(value, depth, schema, string_fields: [])
277
+ fragments = value.is_a?(Array) ? value : [value]
278
+ fragments.each_with_object({}) do |fragment, merged|
279
+ parsed = parse_message(fragment, depth, schema, string_fields: string_fields)
280
+ parsed.each { |field, field_value| merged[field] = field_value }
281
+ end
282
+ end
283
+
284
+ def skip_group(payload, index, group_field, depth)
285
+ raise_invalid! if depth > MAX_RECURSION
286
+ while index < payload.bytesize
287
+ key, index = read_varint(payload, index); field = key >> 3; wire = key & 0x07
288
+ raise_invalid! if field.zero? || field > 536_870_911
289
+ return index if wire == 4 && field == group_field
290
+ case wire
291
+ when 0 then _value, index = read_varint(payload, index)
292
+ when 1 then raise_invalid! if index + 8 > payload.bytesize; index += 8
293
+ when 2 then length, index = read_varint(payload, index); raise_invalid! if length > payload.bytesize - index; index += length
294
+ when 3 then index = skip_group(payload, index, field, depth + 1)
295
+ when 5 then raise_invalid! if index + 4 > payload.bytesize; index += 4
296
+ else raise_invalid!
297
+ end
298
+ end
299
+ raise_invalid!
300
+ end
301
+
302
+ def read_varint(payload, index)
303
+ value = 0; shift = 0; count = 0
304
+ loop do
305
+ raise_invalid! if index >= payload.bytesize || count >= 10
306
+ byte = payload.getbyte(index); index += 1; count += 1
307
+ if count == 10 && (byte & 0x7e) != 0 then raise_invalid! end
308
+ value |= (byte & 0x7f) << shift
309
+ return [value, index] if (byte & 0x80).zero?
310
+ shift += 7
311
+ end
312
+ end
313
+
314
+ def signed_i64(value)
315
+ value > MAX_I64 ? value - (1 << 64) : value
316
+ end
317
+
318
+ def signed_i32(value)
319
+ raw = value & 0xffff_ffff
320
+ raw >= 0x8000_0000 ? raw - (1 << 32) : raw
321
+ end
322
+
323
+ def string_field(value)
324
+ value = last_scalar(value)
325
+ text = value.dup.force_encoding(Encoding::UTF_8)
326
+ raise_invalid! unless text.valid_encoding?
327
+ text
328
+ end
329
+
330
+ def validate_utf8_fragment(value)
331
+ text = value.dup.force_encoding(Encoding::UTF_8)
332
+ raise_invalid! unless text.valid_encoding?
333
+ value
334
+ end
335
+
336
+ def u64_field(value)
337
+ value = last_scalar(value)
338
+ raise_invalid! unless value.is_a?(Integer) && value.between?(0, MAX_U64)
339
+ value
340
+ end
341
+
342
+ def last_scalar(value)
343
+ value.is_a?(Array) ? value.last : value
344
+ end
345
+
346
+ def raise_invalid!
347
+ raise InvalidResponseError.new("ERPC returned an invalid Sui response"), cause: nil
348
+ end
349
+ private_class_method :raise_invalid!
350
+ end
351
+
352
+ SUI_GRPC_WEB_CONTENT_TYPE = "application/grpc-web+proto".freeze
353
+
354
+ SuiServiceInfo = Struct.new(:chain_id, :chain, :epoch, :checkpoint_height, :timestamp_seconds, :timestamp_nanos,
355
+ :lowest_available_checkpoint, :lowest_available_checkpoint_objects, :server, keyword_init: true) do
356
+ def initialize(**values); super; freeze; end
357
+ end
358
+ class SuiObjectSummary
359
+ attr_reader :version, :digest
360
+ def initialize(object_id:, version:, digest:)
361
+ @sui_object_id = object_id; @version = version; @digest = digest; freeze
362
+ end
363
+ def sui_object_id; @sui_object_id; end
364
+ end
365
+ SuiBalance = Struct.new(:coin_type, :balance, :address_balance, :coin_balance, keyword_init: true) do
366
+ def initialize(**values); super; freeze; end
367
+ end
368
+
369
+ private_constant :SuiProto
370
+ end
@@ -9,6 +9,7 @@ require "uri"
9
9
 
10
10
  module ERPC
11
11
  HttpResponse = Struct.new(:status, :body, keyword_init: true)
12
+ HttpStreamResponse = Struct.new(:status, :body, :close, keyword_init: true)
12
13
 
13
14
  class NetHttpAdapter
14
15
  def request(method:, url:, headers:, body: nil, timeout: DEFAULT_TIMEOUT)
@@ -44,6 +45,33 @@ module ERPC
44
45
  end
45
46
  end
46
47
 
48
+ # Execute a POST and yield the HTTP status plus an incremental body
49
+ # enumerator while Net::HTTP still owns the open response. Consumers must
50
+ # drain the enumerator inside the block; returning from the block closes
51
+ # the response and connection. This capability is intentionally additive;
52
+ # request and stream retain their released behavior.
53
+ def request_stream(method:, url:, headers:, body:, timeout: DEFAULT_TIMEOUT)
54
+ raise ArgumentError, "request_stream requires a block" unless block_given?
55
+
56
+ uri = URI.parse(url)
57
+ request = request_class(method).new(uri.request_uri, headers)
58
+ request.body = body unless body.nil?
59
+ result = nil
60
+ start(uri, timeout) do |http|
61
+ http.request(request) do |response|
62
+ chunks = Enumerator.new do |yielder|
63
+ response.read_body { |chunk| yielder << chunk }
64
+ end
65
+ result = yield(response.code.to_i, chunks, response.to_hash)
66
+ end
67
+ end
68
+ result
69
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
70
+ raise TimeoutError, timeout, cause: nil
71
+ rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError
72
+ raise TransportError, "Unable to reach ERPC", cause: nil
73
+ end
74
+
47
75
  private
48
76
 
49
77
  def start(uri, timeout, &block)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module ERPC
6
+ TRON_TRANSPORT_FACTS = SealedEvmContract.facts_from_contract(RPC_NETWORK_CONTRACT, "client-tron-rpc")
7
+ TRON_RPC_METHODS = TRON_TRANSPORT_FACTS.method_names
8
+ private_constant :TRON_TRANSPORT_FACTS
9
+
10
+ class TronRpc < 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 TronClient
18
+ attr_reader :rpc
19
+
20
+ def initialize(transport)
21
+ @rpc = TronRpc.new(transport)
22
+ freeze
23
+ end
24
+ end
25
+
26
+ module Tron
27
+ module_function
28
+
29
+ def create_client(transport)
30
+ TronClient.new(transport)
31
+ end
32
+ end
33
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ERPC
4
- VERSION = "0.9.0"
4
+ VERSION = "0.11.0"
5
5
  end