erpc-sdk 0.6.0 → 0.8.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.
@@ -23,57 +23,165 @@ module ERPC
23
23
  )
24
24
 
25
25
  class Client
26
- attr_reader :solana, :ethereum, :avalanche, :price, :account, :usage
26
+ attr_reader :solana, :ethereum, :avalanche, :price, :account, :usage, :swap
27
27
 
28
28
  def initialize(config, http_adapter: nil, websocket_factory: nil)
29
29
  adapter = http_adapter || NetHttpAdapter.new
30
- solana_transport = HttpJsonRpcTransport.new(
31
- api_key: config.api_key,
32
- endpoint: config.endpoint,
33
- headers: config.headers,
34
- timeout: config.timeout,
35
- adapter: adapter
36
- )
37
- ethereum_transport = HttpJsonRpcTransport.new(
38
- api_key: config.api_key,
39
- endpoint: URLs.with_path(config.endpoint, "/eth"),
40
- headers: config.headers,
41
- timeout: config.timeout,
42
- adapter: adapter
43
- )
44
- avalanche_transport = HttpJsonRpcTransport.new(
45
- api_key: config.api_key,
46
- endpoint: URLs.with_path(config.avalanche_endpoint, "/ava"),
47
- headers: config.headers,
48
- timeout: config.timeout,
49
- adapter: adapter
50
- )
51
- avalanche_index_transport = lambda do |path|
30
+ unavailable_http = lambda do |namespace|
52
31
  HttpJsonRpcTransport.new(
53
- api_key: config.api_key,
54
- endpoint: URLs.with_path(config.avalanche_endpoint, path),
55
- headers: config.headers,
32
+ endpoint: URLs.unavailable_endpoint(namespace),
33
+ headers: {},
56
34
  timeout: config.timeout,
57
- adapter: adapter
35
+ adapter: adapter,
36
+ unavailable_namespace: namespace
58
37
  )
59
38
  end
60
- solana_ws = WebSocketJsonRpcTransport.new(
61
- URLs.websocket(config.endpoint, config.api_key),
62
- config.api_key,
63
- config.timeout,
64
- connection_factory: websocket_factory
39
+ legacy_http = lambda do |endpoint, namespace|
40
+ if config.api_key.nil?
41
+ unavailable_http.call(namespace)
42
+ else
43
+ HttpJsonRpcTransport.new(
44
+ api_key: config.api_key,
45
+ endpoint: endpoint,
46
+ headers: config.headers,
47
+ timeout: config.timeout,
48
+ adapter: adapter
49
+ )
50
+ end
51
+ end
52
+ direct_http = lambda do |override|
53
+ HttpJsonRpcTransport.new(
54
+ endpoint: override.http_url,
55
+ headers: override.headers,
56
+ timeout: config.timeout,
57
+ adapter: adapter,
58
+ direct: true,
59
+ redactions: Redaction.direct_variants(
60
+ http_url: override.http_url,
61
+ websocket_url: override.websocket_url,
62
+ headers: override.headers
63
+ )
64
+ )
65
+ end
66
+ selected_http = lambda do |override, endpoint, namespace|
67
+ override.nil? ? legacy_http.call(endpoint, namespace) : direct_http.call(override)
68
+ end
69
+
70
+ unavailable_websocket = lambda do |namespace|
71
+ WebSocketJsonRpcTransport.new(
72
+ URLs.unavailable_endpoint(namespace),
73
+ nil,
74
+ config.timeout,
75
+ connection_factory: websocket_factory,
76
+ unavailable_namespace: namespace
77
+ )
78
+ end
79
+ legacy_websocket = lambda do |endpoint, namespace|
80
+ if config.api_key.nil?
81
+ unavailable_websocket.call(namespace)
82
+ else
83
+ WebSocketJsonRpcTransport.new(
84
+ endpoint,
85
+ config.api_key,
86
+ config.timeout,
87
+ connection_factory: websocket_factory
88
+ )
89
+ end
90
+ end
91
+ direct_websocket = lambda do |override, namespace|
92
+ if override.websocket_url.nil?
93
+ unavailable_websocket.call(namespace)
94
+ else
95
+ WebSocketJsonRpcTransport.new(
96
+ override.websocket_url,
97
+ nil,
98
+ config.timeout,
99
+ connection_factory: websocket_factory,
100
+ direct: true,
101
+ redactions: Redaction.direct_variants(
102
+ http_url: override.http_url,
103
+ websocket_url: override.websocket_url,
104
+ headers: override.headers
105
+ )
106
+ )
107
+ end
108
+ end
109
+ selected_websocket = lambda do |override, endpoint, namespace|
110
+ override.nil? ? legacy_websocket.call(endpoint, namespace) : direct_websocket.call(override, namespace)
111
+ end
112
+
113
+ solana_transport = selected_http.call(config.solana_rpc, config.endpoint, "solana.rpc")
114
+ solana_aux_transport = if config.solana_rpc || config.api_key
115
+ solana_transport
116
+ else
117
+ unavailable_http.call("solana.das")
118
+ end
119
+ solana_history_transport = if config.solana_rpc || config.api_key
120
+ solana_transport
121
+ else
122
+ unavailable_http.call("solana.history")
123
+ end
124
+ solana_leaders_transport = if config.solana_rpc || config.api_key
125
+ solana_transport
126
+ else
127
+ unavailable_http.call("solana.leaders")
128
+ end
129
+ solana_analytics_transport = if config.solana_rpc || config.api_key
130
+ solana_transport
131
+ else
132
+ unavailable_http.call("solana.analytics")
133
+ end
134
+ ethereum_transport = selected_http.call(
135
+ config.ethereum_rpc,
136
+ URLs.with_path(config.endpoint, "/eth"),
137
+ "ethereum.rpc"
138
+ )
139
+ avalanche_transport = selected_http.call(
140
+ config.avalanche_c_rpc,
141
+ URLs.with_path(config.avalanche_endpoint, "/ava"),
142
+ "avalanche.rpc"
143
+ )
144
+ avalanche_avax_transport = legacy_http.call(
145
+ URLs.with_path(config.avalanche_endpoint, "/ava"),
146
+ "avalanche.avax"
65
147
  )
66
- ethereum_ws = WebSocketJsonRpcTransport.new(
67
- URLs.websocket(config.endpoint, config.api_key, "/eth"),
68
- config.api_key,
69
- config.timeout,
70
- connection_factory: websocket_factory
148
+ avalanche_x_chain_transport = if config.api_key
149
+ avalanche_avax_transport
150
+ else
151
+ unavailable_http.call("avalanche.x_chain")
152
+ end
153
+ avalanche_p_chain_transport = if config.api_key
154
+ avalanche_avax_transport
155
+ else
156
+ unavailable_http.call("avalanche.p_chain")
157
+ end
158
+ avalanche_proposer_vm_transport = if config.api_key
159
+ avalanche_avax_transport
160
+ else
161
+ unavailable_http.call("avalanche.proposer_vm")
162
+ end
163
+ avalanche_info_transport = if config.api_key
164
+ avalanche_avax_transport
165
+ else
166
+ unavailable_http.call("avalanche.info")
167
+ end
168
+ avalanche_index_transport = lambda do |path, namespace|
169
+ legacy_http.call(URLs.with_path(config.avalanche_endpoint, path), namespace)
170
+ end
171
+ solana_ws = selected_websocket.call(
172
+ config.solana_rpc,
173
+ config.api_key.nil? ? URLs.unavailable_endpoint("solana.subscriptions") : URLs.websocket(config.endpoint, config.api_key, ""),
174
+ "solana.subscriptions"
175
+ )
176
+ ethereum_ws = selected_websocket.call(
177
+ config.ethereum_rpc,
178
+ config.api_key.nil? ? URLs.unavailable_endpoint("ethereum.subscriptions") : URLs.websocket(config.endpoint, config.api_key, "/eth"),
179
+ "ethereum.subscriptions"
71
180
  )
72
- avalanche_ws = WebSocketJsonRpcTransport.new(
73
- URLs.websocket(config.avalanche_endpoint, config.api_key, "/ava-ws"),
74
- config.api_key,
75
- config.timeout,
76
- connection_factory: websocket_factory
181
+ avalanche_ws = selected_websocket.call(
182
+ config.avalanche_c_rpc,
183
+ config.api_key.nil? ? URLs.unavailable_endpoint("avalanche.subscriptions") : URLs.websocket(config.avalanche_endpoint, config.api_key, "/ava-ws"),
184
+ "avalanche.subscriptions"
77
185
  )
78
186
 
79
187
  @solana = SolanaClient.new(
@@ -83,16 +191,16 @@ module ERPC
83
191
  parameter_mode: :positional,
84
192
  batch_policy: :solana_standard
85
193
  ),
86
- das: RpcNamespace.new(solana_transport, SOLANA_DAS_METHODS, parameter_mode: :named),
87
- history: RpcNamespace.new(solana_transport, SOLANA_HISTORY_METHODS, parameter_mode: :positional),
194
+ das: RpcNamespace.new(solana_aux_transport, SOLANA_DAS_METHODS, parameter_mode: :named),
195
+ history: RpcNamespace.new(solana_history_transport, SOLANA_HISTORY_METHODS, parameter_mode: :positional),
88
196
  leaders: RpcNamespace.new(
89
- solana_transport,
197
+ solana_leaders_transport,
90
198
  SOLANA_LEADER_METHODS,
91
199
  parameter_mode: :positional,
92
200
  batch_policy: :unsupported
93
201
  ),
94
202
  analytics: RpcNamespace.new(
95
- solana_transport,
203
+ solana_analytics_transport,
96
204
  SOLANA_ANALYTICS_METHODS,
97
205
  parameter_mode: :positional
98
206
  ),
@@ -104,65 +212,96 @@ module ERPC
104
212
  )
105
213
  @avalanche = AvalancheClient.new(
106
214
  rpc: RpcNamespace.new(avalanche_transport, ETHEREUM_RPC_METHODS, parameter_mode: :positional),
107
- avax: avalanche_namespace(avalanche_transport, AVALANCHE_AVAX_METHODS, "avax"),
108
- x_chain: avalanche_namespace(avalanche_transport, AVALANCHE_X_CHAIN_METHODS, "avm"),
109
- p_chain: avalanche_namespace(avalanche_transport, AVALANCHE_P_CHAIN_METHODS, "platform"),
215
+ avax: avalanche_namespace(avalanche_avax_transport, AVALANCHE_AVAX_METHODS, "avax"),
216
+ x_chain: avalanche_namespace(avalanche_x_chain_transport, AVALANCHE_X_CHAIN_METHODS, "avm"),
217
+ p_chain: avalanche_namespace(avalanche_p_chain_transport, AVALANCHE_P_CHAIN_METHODS, "platform"),
110
218
  proposer_vm: avalanche_namespace(
111
- avalanche_transport,
219
+ avalanche_proposer_vm_transport,
112
220
  AVALANCHE_PROPOSER_VM_METHODS,
113
221
  "proposervm"
114
222
  ),
115
- info: avalanche_namespace(avalanche_transport, AVALANCHE_INFO_METHODS, "info"),
223
+ info: avalanche_namespace(avalanche_info_transport, AVALANCHE_INFO_METHODS, "info"),
116
224
  index: AvalancheIndexClient.new(
117
225
  c_chain_blocks: avalanche_namespace(
118
- avalanche_index_transport.call("/ava/ext/index/C/block"),
226
+ avalanche_index_transport.call("/ava/ext/index/C/block", "avalanche.index.c_chain_blocks"),
119
227
  AVALANCHE_INDEX_METHODS,
120
228
  "index"
121
229
  ),
122
230
  p_chain_blocks: avalanche_namespace(
123
- avalanche_index_transport.call("/ava/ext/index/P/block"),
231
+ avalanche_index_transport.call("/ava/ext/index/P/block", "avalanche.index.p_chain_blocks"),
124
232
  AVALANCHE_INDEX_METHODS,
125
233
  "index"
126
234
  ),
127
235
  x_chain_blocks: avalanche_namespace(
128
- avalanche_index_transport.call("/ava/ext/index/X/block"),
236
+ avalanche_index_transport.call("/ava/ext/index/X/block", "avalanche.index.x_chain_blocks"),
129
237
  AVALANCHE_INDEX_METHODS,
130
238
  "index"
131
239
  ),
132
240
  x_chain_transactions: avalanche_namespace(
133
- avalanche_index_transport.call("/ava/ext/index/X/tx"),
241
+ avalanche_index_transport.call("/ava/ext/index/X/tx", "avalanche.index.x_chain_transactions"),
134
242
  AVALANCHE_INDEX_METHODS,
135
243
  "index"
136
244
  )
137
245
  ),
138
246
  subscriptions: EthereumSubscriptions.new(avalanche_ws)
139
247
  )
248
+ @swap = SwapClient.new(
249
+ ethereum_transport: ethereum_transport,
250
+ avalanche_transport: avalanche_transport
251
+ )
140
252
  @price = PriceClient.new(
141
- RestTransport.new(
142
- credential: config.api_key,
143
- endpoint: config.endpoint,
144
- headers: config.headers,
145
- timeout: config.timeout,
146
- adapter: adapter
147
- )
253
+ if config.api_key.nil?
254
+ RestTransport.new(
255
+ endpoint: URLs.unavailable_endpoint("price"),
256
+ timeout: config.timeout,
257
+ adapter: adapter,
258
+ unavailable_namespace: "price"
259
+ )
260
+ else
261
+ RestTransport.new(
262
+ credential: config.api_key,
263
+ endpoint: config.endpoint,
264
+ headers: config.headers,
265
+ timeout: config.timeout,
266
+ adapter: adapter
267
+ )
268
+ end
148
269
  )
149
270
  @account = AccountClient.new(
150
- RestTransport.new(
151
- credential: config.api_key,
152
- endpoint: config.account_endpoint,
153
- headers: config.headers,
154
- timeout: config.timeout,
155
- adapter: adapter
156
- )
271
+ if config.api_key.nil?
272
+ RestTransport.new(
273
+ endpoint: URLs.unavailable_endpoint("account"),
274
+ timeout: config.timeout,
275
+ adapter: adapter,
276
+ unavailable_namespace: "account"
277
+ )
278
+ else
279
+ RestTransport.new(
280
+ credential: config.api_key,
281
+ endpoint: config.account_endpoint,
282
+ headers: config.headers,
283
+ timeout: config.timeout,
284
+ adapter: adapter
285
+ )
286
+ end
157
287
  )
158
288
  @usage = UsageClient.new(
159
- RestTransport.new(
160
- credential: config.api_key,
161
- endpoint: config.user_endpoint,
162
- headers: config.headers,
163
- timeout: config.timeout,
164
- adapter: adapter
165
- )
289
+ if config.api_key.nil?
290
+ RestTransport.new(
291
+ endpoint: URLs.unavailable_endpoint("usage"),
292
+ timeout: config.timeout,
293
+ adapter: adapter,
294
+ unavailable_namespace: "usage"
295
+ )
296
+ else
297
+ RestTransport.new(
298
+ credential: config.api_key,
299
+ endpoint: config.user_endpoint,
300
+ headers: config.headers,
301
+ timeout: config.timeout,
302
+ adapter: adapter
303
+ )
304
+ end
166
305
  )
167
306
  @closed = false
168
307
  end
@@ -12,6 +12,33 @@ module ERPC
12
12
  module URLs
13
13
  module_function
14
14
 
15
+ DIRECT_HTTP_SCHEMES = %w[http https].freeze
16
+ DIRECT_WEBSOCKET_SCHEMES = %w[ws wss].freeze
17
+
18
+ def direct_endpoint(value, kind:, namespace: "direct endpoint")
19
+ schemes = kind == :websocket ? DIRECT_WEBSOCKET_SCHEMES : DIRECT_HTTP_SCHEMES
20
+ unless value.is_a?(String)
21
+ raise ConfigError, "#{namespace} must be an absolute #{kind == :websocket ? 'WS(S)' : 'HTTP(S)'} URL", cause: nil
22
+ end
23
+
24
+ input = value.strip
25
+ scheme_match = input.match?(/\A[a-z][a-z\d+.-]*:\/\//i)
26
+ authority = input[/\A[a-z][a-z\d+.-]*:\/\/([^\/?#]*)/i, 1]
27
+ valid = !input.empty? && scheme_match && !input.include?("#") && authority &&
28
+ !authority.empty? && !authority.include?("@")
29
+ begin
30
+ uri = URI.parse(input)
31
+ valid &&= uri.absolute? && !uri.host.to_s.empty? && schemes.include?(uri.scheme)
32
+ rescue URI::InvalidURIError, ArgumentError
33
+ valid = false
34
+ end
35
+ unless valid
36
+ raise ConfigError, "#{namespace} must be an absolute #{kind == :websocket ? 'WS(S)' : 'HTTP(S)'} URL", cause: nil
37
+ end
38
+
39
+ input.freeze
40
+ end
41
+
15
42
  def normalize_endpoint(value, local_http_only: false)
16
43
  uri = URI.parse(value.to_s)
17
44
  unless uri.absolute? && uri.host && %w[http https].include?(uri.scheme)
@@ -29,7 +56,22 @@ module ERPC
29
56
  uri.path = "/" if uri.path.empty?
30
57
  uri.to_s
31
58
  rescue URI::InvalidURIError, ArgumentError
32
- raise ConfigError, "endpoint must be an absolute HTTP(S) URL"
59
+ raise ConfigError, "endpoint must be an absolute HTTP(S) URL", cause: nil
60
+ end
61
+
62
+ def public_endpoint(endpoint)
63
+ uri = endpoint.is_a?(URI::Generic) ? endpoint.dup : URI.parse(endpoint.to_s)
64
+ uri.query = nil
65
+ uri.fragment = nil
66
+ uri.user = nil if uri.respond_to?(:user=)
67
+ uri.to_s
68
+ rescue URI::InvalidURIError, ArgumentError
69
+ "[REDACTED]"
70
+ end
71
+
72
+ def unavailable_endpoint(namespace)
73
+ safe = namespace.to_s.gsub(/[^a-z\d._-]+/i, "-")
74
+ "https://unconfigured.invalid/#{safe}"
33
75
  end
34
76
 
35
77
  def with_path(endpoint, path)
@@ -56,14 +98,65 @@ module ERPC
56
98
  end
57
99
  end
58
100
 
101
+ class RpcEndpointConfig
102
+ attr_reader :http_url, :websocket_url, :headers
103
+
104
+ def initialize(http_url:, websocket_url: nil, headers: {})
105
+ @http_url = URLs.direct_endpoint(http_url, kind: :http, namespace: "http_url")
106
+ @websocket_url = if websocket_url.nil?
107
+ nil
108
+ else
109
+ URLs.direct_endpoint(websocket_url, kind: :websocket, namespace: "websocket_url")
110
+ end
111
+ unless headers.is_a?(Hash) || headers.respond_to?(:to_h)
112
+ raise ConfigError, "direct endpoint headers must be a mapping"
113
+ end
114
+
115
+ begin
116
+ values = headers.to_h
117
+ rescue StandardError
118
+ raise ConfigError, "direct endpoint headers must be a mapping", cause: nil
119
+ end
120
+ unless values.all? { |name, value| name.is_a?(String) && value.is_a?(String) }
121
+ raise ConfigError, "direct endpoint headers must contain string names and values"
122
+ end
123
+
124
+ @headers = values.dup.freeze
125
+ freeze
126
+ end
127
+
128
+ def inspect
129
+ websocket = websocket_url.nil? ? "nil" : URLs.public_endpoint(websocket_url).inspect
130
+ "#<#{self.class} http_url=#{URLs.public_endpoint(http_url).inspect} " \
131
+ "websocket_url=#{websocket} " \
132
+ "header_names=#{headers.keys.inspect}>"
133
+ end
134
+
135
+ alias to_s inspect
136
+ end
137
+
59
138
  class ClientConfig
60
- attr_reader :api_key, :endpoint, :account_endpoint, :user_endpoint, :headers, :timeout, :avalanche_endpoint
139
+ attr_reader :api_key, :endpoint, :account_endpoint, :user_endpoint, :headers, :timeout,
140
+ :avalanche_endpoint, :solana_rpc, :ethereum_rpc, :avalanche_c_rpc
61
141
 
62
- def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, account_endpoint: DEFAULT_ACCOUNT_ENDPOINT,
142
+ def initialize(api_key: nil, endpoint: DEFAULT_ENDPOINT, account_endpoint: DEFAULT_ACCOUNT_ENDPOINT,
63
143
  user_endpoint: DEFAULT_USER_ENDPOINT, headers: {}, timeout: DEFAULT_TIMEOUT,
64
- avalanche_endpoint: DEFAULT_AVALANCHE_ENDPOINT)
65
- @api_key = api_key.to_s.strip
66
- raise ConfigError, "api_key must not be empty" if @api_key.empty?
144
+ avalanche_endpoint: DEFAULT_AVALANCHE_ENDPOINT, solana_rpc: nil,
145
+ ethereum_rpc: nil, avalanche_c_rpc: nil)
146
+ direct_endpoints = [solana_rpc, ethereum_rpc, avalanche_c_rpc]
147
+ unless direct_endpoints.all? { |value| value.nil? || value.is_a?(RpcEndpointConfig) }
148
+ raise ConfigError, "direct RPC overrides must use RpcEndpointConfig"
149
+ end
150
+
151
+ @api_key = if api_key.nil?
152
+ nil
153
+ elsif api_key.is_a?(String)
154
+ api_key.strip
155
+ else
156
+ raise ConfigError, "api_key must be a string"
157
+ end
158
+ @api_key = nil if @api_key&.empty?
159
+ raise ConfigError, "api_key must not be empty" if @api_key.nil? && direct_endpoints.compact.empty?
67
160
  raise ConfigError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
68
161
 
69
162
  @endpoint = URLs.normalize_endpoint(endpoint)
@@ -72,13 +165,19 @@ module ERPC
72
165
  @user_endpoint = URLs.normalize_endpoint(user_endpoint)
73
166
  @headers = headers.to_h.transform_keys(&:to_s).transform_values(&:to_s).freeze
74
167
  @timeout = timeout.to_f
168
+ @solana_rpc = solana_rpc
169
+ @ethereum_rpc = ethereum_rpc
170
+ @avalanche_c_rpc = avalanche_c_rpc
75
171
  end
76
172
 
77
173
  def inspect
78
- "#<#{self.class} api_key=[REDACTED] endpoint=#{endpoint.inspect} " \
174
+ key = api_key.nil? ? nil : "[REDACTED]"
175
+ "#<#{self.class} api_key=#{key.inspect} endpoint=#{endpoint.inspect} " \
79
176
  "avalanche_endpoint=#{avalanche_endpoint.inspect} " \
80
177
  "account_endpoint=#{account_endpoint.inspect} user_endpoint=#{user_endpoint.inspect} " \
81
- "header_names=#{headers.keys.inspect} timeout=#{timeout.inspect}>"
178
+ "header_names=#{headers.keys.inspect} timeout=#{timeout.inspect} " \
179
+ "solana_rpc=#{solana_rpc.inspect} ethereum_rpc=#{ethereum_rpc.inspect} " \
180
+ "avalanche_c_rpc=#{avalanche_c_rpc.inspect}>"
82
181
  end
83
182
  end
84
183