erpc-sdk 0.4.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e0b83f0a7e0881001a42033b8c7116ea6407f1e024cd258e9cf7df546eb0d875
4
+ data.tar.gz: 48a8098b1739371b431ba2e534644002647ffff6fa4c57889cf0723ffeecfdc1
5
+ SHA512:
6
+ metadata.gz: 6badad47dbad416e9cea863d849de82beefd7fbf9448acce21b9a146ebf4f13e1260aad6591b94bd33a71f21cb832640a66bb3767c8a40f3785ab2629d9f1a1c
7
+ data.tar.gz: d9c6f52fbfc4ca654e8540640a50dc6fdda24773d6bb3ed54752cd8d6ba99fdf33db19a92522acdef95c5761aa9d4e702ff024f773fd2fff06196c523504eab3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ELSOUL LABO B.V.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # ERPC SDK for Ruby
2
+
3
+ Synchronous Ruby client for ERPC. It covers standard Solana and Ethereum
4
+ JSON-RPC, indexed data, analytics, WebSocket subscriptions, price REST and
5
+ server-sent events, account usage, and scoped Cloud reads.
6
+
7
+ ```bash
8
+ gem install erpc-sdk
9
+ ```
10
+
11
+ Ruby 3.1 or newer is required. The gem has no runtime dependencies.
12
+
13
+ ## Quick start
14
+
15
+ ```ruby
16
+ require "erpc_sdk"
17
+
18
+ begin
19
+ config = ERPC::ClientConfig.new(api_key: ENV.fetch("ERPC_API_KEY"))
20
+ erpc = ERPC::Client.new(config)
21
+
22
+ slot = erpc.solana.rpc.get_slot.send
23
+ chain_id = erpc.ethereum.rpc.eth_chain_id.send
24
+ puts({ slot: slot, chain_id: chain_id })
25
+ ensure
26
+ erpc&.close
27
+ end
28
+ ```
29
+
30
+ Both exact wire names (`getSlot`, `eth_chainId`) and idiomatic snake-case
31
+ aliases (`get_slot`, `eth_chain_id`) create inert requests. Network I/O starts
32
+ only when `send` is called. `request` restricts calls to the namespace catalog;
33
+ `raw` is the forward-compatible escape hatch.
34
+
35
+ ## Namespaces
36
+
37
+ | Namespace | Purpose |
38
+ | --- | --- |
39
+ | `erpc.solana.rpc` | Standard Solana JSON-RPC |
40
+ | `erpc.solana.das` | Indexed assets and tokens |
41
+ | `erpc.solana.history` | Address transactions and transfers |
42
+ | `erpc.solana.leaders` | Leader slots and validator information |
43
+ | `erpc.solana.analytics` | Epoch, slot, program, and TPS analytics |
44
+ | `erpc.solana.subscriptions` | Enhanced WebSocket subscriptions |
45
+ | `erpc.ethereum.rpc` | Standard Ethereum JSON-RPC |
46
+ | `erpc.ethereum.subscriptions` | Ethereum WebSocket subscriptions |
47
+ | `erpc.price` | Price metadata, updates, and SSE streams |
48
+ | `erpc.account` | Token balance |
49
+ | `erpc.usage` | Masked monthly API-key usage |
50
+
51
+ ## Intact batches
52
+
53
+ ```ruby
54
+ results = erpc.solana.rpc.batch([
55
+ { method: "getSlot", params: [] },
56
+ { method: "getBlockHeight", params: [] }
57
+ ]).send
58
+ ```
59
+
60
+ The SDK sends one caller batch as one server batch, restores caller order,
61
+ accepts at most 256 calls, and rejects invalid mixed or unsupported batches
62
+ locally. It never silently splits a batch.
63
+
64
+ ## Subscriptions and price streams
65
+
66
+ ```ruby
67
+ heads = erpc.ethereum.subscriptions.subscribe("newHeads")
68
+ header = heads.next
69
+ heads.unsubscribe
70
+
71
+ erpc.price.stream_price_updates(ids: [feed_id]).each do |event|
72
+ puts event.fetch("data")
73
+ end
74
+ ```
75
+
76
+ Subscriptions use a lazy persistent WebSocket connection. `unsubscribe` is
77
+ idempotent, and `close` closes both network subscription transports.
78
+
79
+ ## Cloud reads
80
+
81
+ ```ruby
82
+ cloud = ERPC::CloudClient.new(
83
+ ERPC::CloudClientConfig.new(access_token: access_token)
84
+ )
85
+
86
+ offerings = cloud.catalog.list
87
+ resources = cloud.resources.list
88
+ ```
89
+
90
+ Cloud configuration accepts HTTPS endpoints and localhost HTTP endpoints for
91
+ testing. It retains only the access token supplied by the caller and does not
92
+ implement interactive authorization or refresh-credential storage.
93
+
94
+ ## Safety boundaries
95
+
96
+ - Credentials are redacted from configuration inspection, public endpoints,
97
+ transport errors, and JSON-RPC error data.
98
+ - Requests are attempted once. State-changing calls are never retried.
99
+ - REST, JSON-RPC, and caller batch boundaries remain explicit.
100
+ - Unknown methods remain available through `raw` without being included in the
101
+ compatibility catalog.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERPC
4
+ SolanaClient = Struct.new(:rpc, :das, :history, :leaders, :analytics, :subscriptions, keyword_init: true)
5
+ EthereumClient = Struct.new(:rpc, :subscriptions, keyword_init: true)
6
+
7
+ class Client
8
+ attr_reader :solana, :ethereum, :price, :account, :usage
9
+
10
+ def initialize(config, http_adapter: nil, websocket_factory: nil)
11
+ adapter = http_adapter || NetHttpAdapter.new
12
+ solana_transport = HttpJsonRpcTransport.new(
13
+ api_key: config.api_key,
14
+ endpoint: config.endpoint,
15
+ headers: config.headers,
16
+ timeout: config.timeout,
17
+ adapter: adapter
18
+ )
19
+ ethereum_transport = HttpJsonRpcTransport.new(
20
+ api_key: config.api_key,
21
+ endpoint: URLs.with_path(config.endpoint, "/eth"),
22
+ headers: config.headers,
23
+ timeout: config.timeout,
24
+ adapter: adapter
25
+ )
26
+ solana_ws = WebSocketJsonRpcTransport.new(
27
+ URLs.websocket(config.endpoint, config.api_key),
28
+ config.api_key,
29
+ config.timeout,
30
+ connection_factory: websocket_factory
31
+ )
32
+ ethereum_ws = WebSocketJsonRpcTransport.new(
33
+ URLs.websocket(config.endpoint, config.api_key, "/eth"),
34
+ config.api_key,
35
+ config.timeout,
36
+ connection_factory: websocket_factory
37
+ )
38
+
39
+ @solana = SolanaClient.new(
40
+ rpc: RpcNamespace.new(
41
+ solana_transport,
42
+ SOLANA_RPC_METHODS,
43
+ parameter_mode: :positional,
44
+ batch_policy: :solana_standard
45
+ ),
46
+ das: RpcNamespace.new(solana_transport, SOLANA_DAS_METHODS, parameter_mode: :named),
47
+ history: RpcNamespace.new(solana_transport, SOLANA_HISTORY_METHODS, parameter_mode: :positional),
48
+ leaders: RpcNamespace.new(
49
+ solana_transport,
50
+ SOLANA_LEADER_METHODS,
51
+ parameter_mode: :positional,
52
+ batch_policy: :unsupported
53
+ ),
54
+ analytics: RpcNamespace.new(
55
+ solana_transport,
56
+ SOLANA_ANALYTICS_METHODS,
57
+ parameter_mode: :positional
58
+ ),
59
+ subscriptions: SolanaSubscriptions.new(solana_ws)
60
+ )
61
+ @ethereum = EthereumClient.new(
62
+ rpc: RpcNamespace.new(ethereum_transport, ETHEREUM_RPC_METHODS, parameter_mode: :positional),
63
+ subscriptions: EthereumSubscriptions.new(ethereum_ws)
64
+ )
65
+ @price = PriceClient.new(
66
+ RestTransport.new(
67
+ credential: config.api_key,
68
+ endpoint: config.endpoint,
69
+ headers: config.headers,
70
+ timeout: config.timeout,
71
+ adapter: adapter
72
+ )
73
+ )
74
+ @account = AccountClient.new(
75
+ RestTransport.new(
76
+ credential: config.api_key,
77
+ endpoint: config.account_endpoint,
78
+ headers: config.headers,
79
+ timeout: config.timeout,
80
+ adapter: adapter
81
+ )
82
+ )
83
+ @usage = UsageClient.new(
84
+ RestTransport.new(
85
+ credential: config.api_key,
86
+ endpoint: config.user_endpoint,
87
+ headers: config.headers,
88
+ timeout: config.timeout,
89
+ adapter: adapter
90
+ )
91
+ )
92
+ @closed = false
93
+ end
94
+
95
+ def close
96
+ return if @closed
97
+
98
+ @closed = true
99
+ solana.subscriptions.close
100
+ ethereum.subscriptions.close
101
+ nil
102
+ end
103
+ end
104
+
105
+ class CloudClient
106
+ attr_reader :catalog, :credit, :resources, :usage
107
+
108
+ def initialize(config, http_adapter: nil)
109
+ transport = RestTransport.new(
110
+ credential: config.access_token,
111
+ endpoint: config.endpoint,
112
+ headers: config.headers,
113
+ timeout: config.timeout,
114
+ adapter: http_adapter || NetHttpAdapter.new
115
+ )
116
+ @catalog = CloudCatalogClient.new(transport)
117
+ @credit = CloudCreditClient.new(transport)
118
+ @resources = CloudResourcesClient.new(transport)
119
+ @usage = UsageClient.new(transport)
120
+ end
121
+
122
+ def close
123
+ nil
124
+ end
125
+ end
126
+
127
+ module_function
128
+
129
+ def create_client(config, **options)
130
+ Client.new(config, **options)
131
+ end
132
+
133
+ def create_cloud_client(config, **options)
134
+ CloudClient.new(config, **options)
135
+ end
136
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ERPC
6
+ DEFAULT_ENDPOINT = "https://edge.erpc.global"
7
+ DEFAULT_ACCOUNT_ENDPOINT = "https://solana-rpc.erpc.global"
8
+ DEFAULT_USER_ENDPOINT = "https://user-api.erpc.global"
9
+ DEFAULT_TIMEOUT = 30.0
10
+
11
+ module URLs
12
+ module_function
13
+
14
+ def normalize_endpoint(value, local_http_only: false)
15
+ uri = URI.parse(value.to_s)
16
+ unless uri.absolute? && uri.host && %w[http https].include?(uri.scheme)
17
+ raise ConfigError, "endpoint must be an absolute HTTP(S) URL"
18
+ end
19
+
20
+ local = %w[127.0.0.1 ::1 localhost].include?(uri.hostname)
21
+ if local_http_only && uri.scheme != "https" && !(uri.scheme == "http" && local)
22
+ raise ConfigError, "endpoint must use HTTPS except on localhost"
23
+ end
24
+
25
+ uri.query = nil
26
+ uri.fragment = nil
27
+ uri.path = uri.path.sub(%r{/+\z}, "")
28
+ uri.path = "/" if uri.path.empty?
29
+ uri.to_s
30
+ rescue URI::InvalidURIError, ArgumentError
31
+ raise ConfigError, "endpoint must be an absolute HTTP(S) URL"
32
+ end
33
+
34
+ def with_path(endpoint, path)
35
+ uri = URI.parse(endpoint)
36
+ base = uri.path.sub(%r{/+\z}, "")
37
+ uri.path = "#{base}/#{path.sub(%r{\A/+}, "")}"
38
+ uri.query = nil
39
+ uri.fragment = nil
40
+ uri.to_s
41
+ end
42
+
43
+ def websocket(endpoint, api_key, path = "")
44
+ uri = URI.parse(with_path(endpoint, path))
45
+ uri.scheme = uri.scheme == "https" ? "wss" : "ws"
46
+ uri.query = URI.encode_www_form("api-key" => api_key)
47
+ uri.to_s
48
+ end
49
+
50
+ def escape_path(value)
51
+ value.to_s.b.each_byte.map do |byte|
52
+ character = byte.chr
53
+ character.match?(/[A-Za-z0-9_.~-]/) ? character : format("%%%02X", byte)
54
+ end.join
55
+ end
56
+ end
57
+
58
+ class ClientConfig
59
+ attr_reader :api_key, :endpoint, :account_endpoint, :user_endpoint, :headers, :timeout
60
+
61
+ def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, account_endpoint: DEFAULT_ACCOUNT_ENDPOINT,
62
+ user_endpoint: DEFAULT_USER_ENDPOINT, headers: {}, timeout: DEFAULT_TIMEOUT)
63
+ @api_key = api_key.to_s.strip
64
+ raise ConfigError, "api_key must not be empty" if @api_key.empty?
65
+ raise ConfigError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
66
+
67
+ @endpoint = URLs.normalize_endpoint(endpoint)
68
+ @account_endpoint = URLs.normalize_endpoint(account_endpoint)
69
+ @user_endpoint = URLs.normalize_endpoint(user_endpoint)
70
+ @headers = headers.to_h.transform_keys(&:to_s).transform_values(&:to_s).freeze
71
+ @timeout = timeout.to_f
72
+ end
73
+
74
+ def inspect
75
+ "#<#{self.class} api_key=[REDACTED] endpoint=#{endpoint.inspect} " \
76
+ "account_endpoint=#{account_endpoint.inspect} user_endpoint=#{user_endpoint.inspect} " \
77
+ "header_names=#{headers.keys.inspect} timeout=#{timeout.inspect}>"
78
+ end
79
+ end
80
+
81
+ class CloudClientConfig
82
+ attr_reader :access_token, :endpoint, :headers, :timeout
83
+
84
+ def initialize(access_token:, endpoint: DEFAULT_USER_ENDPOINT, headers: {}, timeout: DEFAULT_TIMEOUT)
85
+ @access_token = access_token.to_s.strip
86
+ raise ConfigError, "access_token must not be empty" if @access_token.empty?
87
+ raise ConfigError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
88
+
89
+ @endpoint = URLs.normalize_endpoint(endpoint, local_http_only: true)
90
+ @headers = headers.to_h.transform_keys(&:to_s).transform_values(&:to_s).freeze
91
+ @timeout = timeout.to_f
92
+ end
93
+
94
+ def inspect
95
+ "#<#{self.class} access_token=[REDACTED] endpoint=#{endpoint.inspect} " \
96
+ "header_names=#{headers.keys.inspect} timeout=#{timeout.inspect}>"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ERPC
6
+ class Error < StandardError; end
7
+
8
+ class ConfigError < Error; end
9
+ class BatchPolicyError < Error; end
10
+ class InvalidResponseError < Error; end
11
+ class TransportError < Error; end
12
+
13
+ class TimeoutError < TransportError
14
+ attr_reader :timeout
15
+
16
+ def initialize(timeout)
17
+ @timeout = timeout
18
+ super("ERPC request timed out after #{timeout} seconds")
19
+ end
20
+ end
21
+
22
+ class HttpError < Error
23
+ attr_reader :status
24
+
25
+ def initialize(status)
26
+ @status = status
27
+ super("ERPC returned HTTP status #{status}")
28
+ end
29
+ end
30
+
31
+ class JsonRpcError < Error
32
+ attr_reader :code, :data
33
+
34
+ def initialize(code, message, data = nil)
35
+ @code = code
36
+ @data = data
37
+ super(message)
38
+ end
39
+ end
40
+
41
+ module Redaction
42
+ module_function
43
+
44
+ def text(value, credential)
45
+ result = value.to_s
46
+ variants(credential).each { |secret| result = result.gsub(secret, "[REDACTED]") unless secret.empty? }
47
+ result
48
+ end
49
+
50
+ def value(value, credential)
51
+ case value
52
+ when String
53
+ text(value, credential)
54
+ when Array
55
+ value.map { |item| self.value(item, credential) }
56
+ when Hash
57
+ value.to_h do |key, item|
58
+ [text(key, credential), self.value(item, credential)]
59
+ end
60
+ else
61
+ value
62
+ end
63
+ end
64
+
65
+ 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
71
+ end
72
+ private_class_method :variants
73
+ end
74
+ end