erpc-sdk 0.7.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.
@@ -27,53 +27,161 @@ module ERPC
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"
65
143
  )
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
144
+ avalanche_avax_transport = legacy_http.call(
145
+ URLs.with_path(config.avalanche_endpoint, "/ava"),
146
+ "avalanche.avax"
71
147
  )
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
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"
180
+ )
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,33 +212,33 @@ 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
  )
@@ -142,31 +250,58 @@ module ERPC
142
250
  avalanche_transport: avalanche_transport
143
251
  )
144
252
  @price = PriceClient.new(
145
- RestTransport.new(
146
- credential: config.api_key,
147
- endpoint: config.endpoint,
148
- headers: config.headers,
149
- timeout: config.timeout,
150
- adapter: adapter
151
- )
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
152
269
  )
153
270
  @account = AccountClient.new(
154
- RestTransport.new(
155
- credential: config.api_key,
156
- endpoint: config.account_endpoint,
157
- headers: config.headers,
158
- timeout: config.timeout,
159
- adapter: adapter
160
- )
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
161
287
  )
162
288
  @usage = UsageClient.new(
163
- RestTransport.new(
164
- credential: config.api_key,
165
- endpoint: config.user_endpoint,
166
- headers: config.headers,
167
- timeout: config.timeout,
168
- adapter: adapter
169
- )
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
170
305
  )
171
306
  @closed = false
172
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
 
@@ -1,11 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "uri"
4
+ require "base64"
4
5
 
5
6
  module ERPC
6
7
  class Error < StandardError; end
7
8
 
8
9
  class ConfigError < Error; end
10
+ class NotConfiguredError < Error
11
+ attr_reader :namespace
12
+
13
+ def initialize(namespace)
14
+ @namespace = namespace.to_s.freeze
15
+ super("ERPC namespace #{@namespace.inspect} is not configured")
16
+ end
17
+ end
9
18
  class BatchPolicyError < Error; end
10
19
  class InvalidResponseError < Error; end
11
20
  class TransportError < Error; end
@@ -41,21 +50,30 @@ module ERPC
41
50
  module Redaction
42
51
  module_function
43
52
 
53
+ PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/.freeze
54
+
44
55
  def text(value, credential)
45
56
  result = value.to_s
46
- variants(credential).each { |secret| result = result.gsub(secret, "[REDACTED]") unless secret.empty? }
57
+ variants(credential).each do |secret|
58
+ result = result.gsub(secret, "[REDACTED]") unless secret.empty?
59
+ next unless secret.match?(PERCENT_ESCAPE)
60
+
61
+ result = result.gsub(percent_escape_pattern(secret), "[REDACTED]")
62
+ end
47
63
  result
48
64
  end
49
65
 
50
- def value(value, credential)
66
+ def value(value, credential, depth = 0)
67
+ return "[REDACTED]" if depth >= 32
68
+
51
69
  case value
52
70
  when String
53
71
  text(value, credential)
54
72
  when Array
55
- value.map { |item| self.value(item, credential) }
73
+ value.map { |item| self.value(item, credential, depth + 1) }
56
74
  when Hash
57
75
  value.to_h do |key, item|
58
- [text(key, credential), self.value(item, credential)]
76
+ [key.is_a?(String) ? text(key, credential) : key, self.value(item, credential, depth + 1)]
59
77
  end
60
78
  else
61
79
  value
@@ -63,12 +81,85 @@ module ERPC
63
81
  end
64
82
 
65
83
  def variants(credential)
66
- percent_encoded = credential.to_s.b.each_byte.map do |byte|
67
- character = byte.chr
68
- character.match?(/[A-Za-z0-9_.~-]/) ? character : format("%%%02X", byte)
69
- end.join
70
- [credential.to_s, URI.encode_www_form_component(credential.to_s), percent_encoded].uniq
84
+ values = credential.is_a?(Array) ? credential : [credential]
85
+ encoded = values.flat_map do |value|
86
+ next [] if value.nil? || value.to_s.empty?
87
+
88
+ text = value.to_s
89
+ percent_encoded = text.b.each_byte.map do |byte|
90
+ character = byte.chr
91
+ character.match?(/[A-Za-z0-9_.~-]/) ? character : format("%%%02X", byte)
92
+ end.join
93
+ [
94
+ text,
95
+ URI.encode_www_form_component(text),
96
+ percent_encoded
97
+ ]
98
+ end
99
+ encoded.uniq.sort_by { |value| [-value.length, value] }
71
100
  end
72
101
  private_class_method :variants
102
+
103
+ def percent_escape_pattern(value)
104
+ escaped = Regexp.escape(value.to_s)
105
+ pattern = escaped.gsub(PERCENT_ESCAPE) do |escape|
106
+ "%#{percent_character_class(escape[1])}#{percent_character_class(escape[2])}"
107
+ end
108
+ Regexp.new(pattern)
109
+ end
110
+ private_class_method :percent_escape_pattern
111
+
112
+ def percent_character_class(value)
113
+ "[#{[value.upcase, value.downcase].uniq.join}]"
114
+ end
115
+ private_class_method :percent_character_class
116
+
117
+ def direct_variants(http_url:, websocket_url: nil, headers: {})
118
+ values = []
119
+ [http_url, websocket_url].compact.each do |endpoint|
120
+ query = URI.parse(endpoint).query.to_s
121
+ query.split("&").each do |component|
122
+ next if component.empty?
123
+
124
+ raw_value = component.include?("=") ? component.split("=", 2).last : component
125
+ next if raw_value.empty?
126
+
127
+ values << raw_value
128
+ begin
129
+ values << URI.decode_www_form_component(raw_value)
130
+ rescue ArgumentError
131
+ # The endpoint validator rejects invalid percent escapes. Keep
132
+ # the raw value as a defensive fallback for caller-built objects.
133
+ end
134
+ end
135
+ end
136
+
137
+ headers.each do |name, value|
138
+ next unless value.is_a?(String) && !value.empty?
139
+
140
+ values << value
141
+ next unless %w[authorization proxy-authorization].include?(name.to_s.downcase)
142
+
143
+ parts = value.strip.split(/\s+/, 2)
144
+ next unless parts.length == 2 && %w[bearer basic].include?(parts.first.downcase)
145
+
146
+ credential = parts.last
147
+ values << credential
148
+ next unless parts.first.casecmp?("basic")
149
+
150
+ begin
151
+ decoded = Base64.strict_decode64(credential).force_encoding(Encoding::UTF_8)
152
+ values << decoded
153
+ if decoded.include?(":")
154
+ username, password = decoded.split(":", 2)
155
+ values << username << password
156
+ end
157
+ rescue ArgumentError, EncodingError
158
+ # Raw and encoded forms above still cover malformed Basic values.
159
+ end
160
+ end
161
+
162
+ variants(values)
163
+ end
73
164
  end
74
165
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by registry/generate-bridge-capabilities.mjs. Do not edit.
4
+
5
+ module ERPC
6
+ BRIDGE_CAPABILITIES_JSON = '[{"bridgeCapabilityId":"bridge-mayan-swift-v2-eurc-eth-sol","providerId":"mayan-swift-v2","capabilityKind":"external-provider-dynamic","sourceChainId":"eip155:1","destinationChainId":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","sourceTokenDeploymentId":"deployment-0011","destinationTokenDeploymentId":"deployment-0013","sourceTokenAddress":"0x1abaea1f7c830bd89acc67ec4af516284b1bc33c","destinationTokenAddress":"HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr","sourceTokenStandard":"erc20","destinationTokenStandard":"spl-token","sourceTokenDecimals":6,"destinationTokenDecimals":6,"sourceProviderChainName":"ethereum","destinationProviderChainName":"solana","sourceProviderChainId":1,"destinationProviderChainId":0,"sourceWormholeChainId":2,"destinationWormholeChainId":1,"sourceUsdcDeploymentId":"deployment-0008","sourceUsdcAddress":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","sourceUsdcStandard":"erc20","sourceUsdcDecimals":6,"swiftContract":"0x40ffe85a28dc9993541449464d7529a922142960","forwarderAddress":"0x337685fdab40d39bd02028545a4ffa7d287cc3e2","forwarderFunctionSelector":"0x30dedc57","jupiterProgramAddress":null,"builderEndpoint":"https://tx-builder.mayan.finance","explorerEndpoint":"https://explorer-api.mayan.finance/v3","dependencies":["mayan-hosted-quote-api","mayan-hosted-transaction-builder","mayan-hosted-source-swap-builder","swift-auction-solvers","relayers","wormhole-guardian-messaging","mayan-explorer-indexer"],"status":"active"},{"bridgeCapabilityId":"bridge-mayan-swift-v2-eurc-sol-eth","providerId":"mayan-swift-v2","capabilityKind":"external-provider-dynamic","sourceChainId":"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp","destinationChainId":"eip155:1","sourceTokenDeploymentId":"deployment-0013","destinationTokenDeploymentId":"deployment-0011","sourceTokenAddress":"HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr","destinationTokenAddress":"0x1abaea1f7c830bd89acc67ec4af516284b1bc33c","sourceTokenStandard":"spl-token","destinationTokenStandard":"erc20","sourceTokenDecimals":6,"destinationTokenDecimals":6,"sourceProviderChainName":"solana","destinationProviderChainName":"ethereum","sourceProviderChainId":0,"destinationProviderChainId":1,"sourceWormholeChainId":1,"destinationWormholeChainId":2,"sourceUsdcDeploymentId":"deployment-0010","sourceUsdcAddress":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","sourceUsdcStandard":"spl-token","sourceUsdcDecimals":6,"swiftContract":"mayan34VedncxdK2XobtvWFDXQASUTBXhUVzt2kKgny","forwarderAddress":null,"forwarderFunctionSelector":null,"jupiterProgramAddress":"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4","builderEndpoint":"https://tx-builder.mayan.finance","explorerEndpoint":"https://explorer-api.mayan.finance/v3","dependencies":["mayan-hosted-quote-api","mayan-hosted-transaction-builder","mayan-hosted-source-swap-builder","swift-auction-solvers","relayers","wormhole-guardian-messaging","mayan-explorer-indexer","jupiter-v6-source-swap"],"status":"active"}]'.freeze
7
+ BRIDGE_CAPABILITIES_CONTENT_DIGEST = 'fe698f99218057f8cb40b3e24421d1d78e4e2cd7508cf6e3eb2457b329e5141b'.freeze
8
+ BRIDGE_CAPABILITIES_AS_OF_DATE = '2026-09-16'.freeze
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by registry/generate-swap-execution-capabilities.mjs. Do not edit.
4
+
5
+ module ERPC
6
+ SWAP_EXECUTION_CAPABILITIES_JSON = '[{"swapExecutionCapabilityId":"swap-execution-0001","chainId":"eip155:1","dexDeploymentId":"dex-deployment-0001","poolDefinitionId":"pool-0001","factoryAddress":"0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f","routerAddress":"0x7a250d5630b4cf539739df2c5dacb4c659f2488d","routerKind":"uniswap-v2-router02","adapterKind":"evm-constant-product-v2","token0DeploymentId":"deployment-0008","token0Address":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","token0Standard":"erc20","token1DeploymentId":"deployment-0002","token1Address":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","token1Standard":"erc20","wrappedNativeTokenDeploymentId":"deployment-0002","wrappedNativeTokenAddress":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","wrappedNativeFunctionSelector":"0xad5c4648","functionKind":"exact-input-erc20-to-erc20","functionSignature":"swapExactTokensForTokens(uint256,uint256,address[],address,uint256)","functionSelector":"0x38ed1739","status":"active"},{"swapExecutionCapabilityId":"swap-execution-0002","chainId":"eip155:43114","dexDeploymentId":"dex-deployment-0002","poolDefinitionId":"pool-0002","factoryAddress":"0x9ad6c38be94206ca50bb0d90783181662f0cfa10","routerAddress":"0x60ae616a2155ee3d9a68541ba4544862310933d4","routerKind":"joe-v1-router02","adapterKind":"evm-constant-product-v2","token0DeploymentId":"deployment-0004","token0Address":"0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7","token0Standard":"erc20","token1DeploymentId":"deployment-0009","token1Address":"0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e","token1Standard":"erc20","wrappedNativeTokenDeploymentId":"deployment-0004","wrappedNativeTokenAddress":"0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7","wrappedNativeFunctionSelector":"0x73b295c2","functionKind":"exact-input-erc20-to-erc20","functionSignature":"swapExactTokensForTokens(uint256,uint256,address[],address,uint256)","functionSelector":"0x38ed1739","status":"active"}]'.freeze
7
+ SWAP_EXECUTION_CAPABILITIES_CONTENT_DIGEST = '84424dc48687e2cb1dd0c9a2446f820efd2c311e71ce5acacfeb6e882811875f'.freeze
8
+ SWAP_EXECUTION_CAPABILITIES_AS_OF_DATE = '2026-09-16'.freeze
9
+ SWAP_EXECUTION_FUNCTION_SIGNATURE = 'swapExactTokensForTokens(uint256,uint256,address[],address,uint256)'.freeze
10
+ SWAP_EXECUTION_FUNCTION_SELECTOR = '0x38ed1739'.freeze
11
+ end