block_given 0.1.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,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # Public JSON-RPC client bound to a chain and a connector (viem's PublicClient).
5
+ #
6
+ # connector = BlockGiven::Connectors::Alchemy.new(api_key: "...")
7
+ # client = BlockGiven::Client.new(chain: :base, connector: connector)
8
+ # client.block_number
9
+ # client.get_balance("0x...")
10
+ class Client
11
+ attr_reader :chain, :connector
12
+
13
+ def initialize(chain: nil, connector: nil, polling_interval: nil, timeout: nil, logger: nil)
14
+ @chain = chain ? Chains.resolve(chain) : BlockGiven.config.chain!
15
+ @connector = connector || BlockGiven.config.connector!
16
+ @polling_interval = polling_interval
17
+ @timeout = timeout
18
+ @logger = logger
19
+ end
20
+
21
+ def polling_interval = @polling_interval || BlockGiven.config.polling_interval
22
+ def timeout = @timeout || BlockGiven.config.timeout
23
+ def logger = @logger || BlockGiven.config.logger
24
+
25
+ # Raw JSON-RPC call: client.request("eth_blockNumber") / client.request("eth_getBalance", addr, "latest")
26
+ def request(method, *params)
27
+ logger.debug { "[block_given] -> #{method} #{params.inspect}" }
28
+ result = connector.request(method, params, chain: chain)
29
+ logger.debug { "[block_given] <- #{method} #{result.inspect[0, 200]}" }
30
+ result
31
+ end
32
+
33
+ # Batched JSON-RPC: client.batch([["eth_blockNumber"], ["eth_chainId"]])
34
+ def batch(calls) = connector.batch(calls, chain: chain)
35
+
36
+ # --- Chain / blocks -----------------------------------------------------
37
+
38
+ def chain_id = Utils.hex_to_int(request("eth_chainId"))
39
+ def block_number = Utils.hex_to_int(request("eth_blockNumber"))
40
+
41
+ def get_block(block = :latest, include_transactions: false)
42
+ method = Utils.hex?(block.to_s) && block.to_s.length == 66 ? "eth_getBlockByHash" : "eth_getBlockByNumber"
43
+ raw = request(method, block_param(block), include_transactions)
44
+ raw && Normalizer.normalize(raw)
45
+ end
46
+
47
+ def gas_price = Utils.hex_to_int(request("eth_gasPrice"))
48
+
49
+ def max_priority_fee_per_gas
50
+ Utils.hex_to_int(request("eth_maxPriorityFeePerGas"))
51
+ rescue RpcError
52
+ Utils.parse_gwei("1") # method not supported by every node
53
+ end
54
+
55
+ # EIP-1559 fee estimation (viem semantics: baseFee * multiplier + priority fee).
56
+ def estimate_fees_per_gas(base_fee_multiplier: nil)
57
+ multiplier = base_fee_multiplier || BlockGiven.config.base_fee_multiplier
58
+ block = get_block(:latest)
59
+ base_fee = block[:base_fee_per_gas] || gas_price
60
+ priority = max_priority_fee_per_gas
61
+ {
62
+ base_fee_per_gas: base_fee,
63
+ max_priority_fee_per_gas: priority,
64
+ max_fee_per_gas: (base_fee * multiplier).ceil + priority
65
+ }
66
+ end
67
+
68
+ # --- Accounts -----------------------------------------------------------
69
+
70
+ def get_balance(address, block: :latest)
71
+ Utils.hex_to_int(request("eth_getBalance", Utils.checksum_address(address), Utils.block_tag(block)))
72
+ end
73
+
74
+ def get_transaction_count(address, block: :pending)
75
+ Utils.hex_to_int(request("eth_getTransactionCount", Utils.checksum_address(address), Utils.block_tag(block)))
76
+ end
77
+
78
+ def get_code(address, block: :latest)
79
+ request("eth_getCode", Utils.checksum_address(address), Utils.block_tag(block))
80
+ end
81
+
82
+ def contract?(address) = get_code(address) != "0x"
83
+
84
+ def get_storage_at(address, slot, block: :latest)
85
+ request("eth_getStorageAt", Utils.checksum_address(address), Utils.to_hex(slot), Utils.block_tag(block))
86
+ end
87
+
88
+ # --- Calls --------------------------------------------------------------
89
+
90
+ # Executes a read-only call. Returns the raw hex result.
91
+ def call(to:, data:, from: nil, value: nil, gas: nil, block: :latest)
92
+ request("eth_call", call_object(to: to, data: data, from: from, value: value, gas: gas), Utils.block_tag(block))
93
+ end
94
+
95
+ def estimate_gas(to:, data: nil, from: nil, value: nil)
96
+ Utils.hex_to_int(request("eth_estimateGas", call_object(to: to, data: data, from: from, value: value)))
97
+ end
98
+
99
+ # --- Transactions -------------------------------------------------------
100
+
101
+ def send_raw_transaction(raw)
102
+ Transaction.new(request("eth_sendRawTransaction", Utils.prefix_hex(raw)), client: self)
103
+ end
104
+
105
+ def transaction(hash) = Transaction.new(hash, client: self)
106
+
107
+ def get_transaction(hash)
108
+ raw = request("eth_getTransactionByHash", hash)
109
+ raw && Normalizer.normalize(raw)
110
+ end
111
+
112
+ def get_transaction_receipt(hash)
113
+ raw = request("eth_getTransactionReceipt", hash)
114
+ raw && Receipt.new(raw)
115
+ end
116
+
117
+ # Polls until the receipt is available (and confirmed by N blocks).
118
+ def wait_for_transaction_receipt(hash, confirmations: nil, timeout: nil, polling_interval: nil)
119
+ confirmations ||= BlockGiven.config.confirmations
120
+ interval = polling_interval || self.polling_interval
121
+ Poller.poll(interval: interval, timeout: timeout || self.timeout, description: "receipt of #{hash}") do
122
+ receipt = get_transaction_receipt(hash)
123
+ next nil unless receipt&.block_number
124
+ next receipt if confirmations <= 1
125
+
126
+ block_number - receipt.block_number + 1 >= confirmations ? receipt : nil
127
+ end
128
+ end
129
+
130
+ # --- Logs ---------------------------------------------------------------
131
+
132
+ def get_logs(address: nil, topics: nil, from_block: :latest, to_block: :latest, block_hash: nil)
133
+ filter = {}
134
+ filter[:address] = Array(address).map { |a| Utils.checksum_address(a) } if address
135
+ filter[:address] = filter[:address].first if filter[:address]&.size == 1
136
+ filter[:topics] = topics if topics
137
+ if block_hash
138
+ filter[:blockHash] = block_hash
139
+ else
140
+ filter[:fromBlock] = Utils.block_tag(from_block)
141
+ filter[:toBlock] = Utils.block_tag(to_block)
142
+ end
143
+ Normalizer.normalize(request("eth_getLogs", filter))
144
+ end
145
+
146
+ # --- Watchers (background polling) --------------------------------------
147
+
148
+ # Yields each new block number. With emit_missed: true every block between two
149
+ # polls is yielded, otherwise only the latest one.
150
+ def watch_block_number(polling_interval: nil, emit_missed: false, id: nil, &block)
151
+ last = nil
152
+ watcher("block_number", polling_interval, id: id) do
153
+ current = block_number
154
+ next if last && current <= last
155
+
156
+ if emit_missed && last
157
+ ((last + 1)..current).each { |n| block.call(n) }
158
+ else
159
+ block.call(current)
160
+ end
161
+ last = current
162
+ end
163
+ end
164
+
165
+ def watch_blocks(polling_interval: nil, include_transactions: false, id: nil, &block)
166
+ watch_block_number(polling_interval: polling_interval, emit_missed: true, id: id) do |number|
167
+ block.call(get_block(number, include_transactions: include_transactions))
168
+ end
169
+ end
170
+
171
+ # get_logs over a large range, split in chunks of max_block_range blocks so provider
172
+ # limits are respected. Yields each chunk's logs when a block is given, else returns them all.
173
+ def get_logs_in_chunks(from_block:, address: nil, topics: nil, to_block: :latest, max_block_range: nil)
174
+ size = max_block_range || BlockGiven.config.max_block_range
175
+ to_block = block_number if to_block.nil? || Utils::BLOCK_TAGS.include?(to_block.to_s)
176
+ collected = []
177
+ from = from_block
178
+ while from <= to_block
179
+ to = [from + size - 1, to_block].min
180
+ logs = get_logs(address: address, topics: topics, from_block: from, to_block: to)
181
+ block_given? ? yield(logs, from, to) : collected.concat(logs)
182
+ from = to + 1
183
+ end
184
+ collected
185
+ end
186
+
187
+ # Yields new logs matching the filter, one Array per processed block range.
188
+ #
189
+ # from_block: resume from this block (catch-up is chunked by max_block_range)
190
+ # confirmations: stay this many blocks behind the head to dodge reorgs (default 0)
191
+ # on_progress: ->(from, to) called after each range is processed: persist `to` as your cursor
192
+ # watcher.cursor: last processed block number
193
+ # id: stable identifier for BlockGiven::Watcher.find / stop (default: auto-generated)
194
+ def watch_logs(address: nil, topics: nil, from_block: nil, polling_interval: nil, max_block_range: nil,
195
+ confirmations: 0, on_progress: nil, id: nil, name: nil, &block)
196
+ last = from_block ? from_block - 1 : block_number - confirmations
197
+ watcher(name || "logs@#{Array(address).first || '*'}", polling_interval, id: id) do |watcher|
198
+ watcher.cursor ||= last
199
+ head = block_number - confirmations
200
+ while last < head && !watcher.stopped?
201
+ to = [last + (max_block_range || BlockGiven.config.max_block_range), head].min
202
+ logs = get_logs(address: address, topics: topics, from_block: last + 1, to_block: to)
203
+ block.call(logs) unless logs.empty?
204
+ on_progress&.call(last + 1, to)
205
+ last = watcher.cursor = to
206
+ end
207
+ end
208
+ end
209
+
210
+ # Generic background watcher on this client.
211
+ def watcher(name, polling_interval = nil, id: nil, &tick)
212
+ Watcher.new(interval: polling_interval || self.polling_interval, name: name, id: id, logger: logger, &tick).start
213
+ end
214
+
215
+ def inspect = "#<BlockGiven::Client chain=#{chain} connector=#{connector.inspect}>"
216
+
217
+ private
218
+
219
+ def block_param(block)
220
+ return block if Utils.hex?(block.to_s) && block.to_s.length == 66
221
+
222
+ Utils.block_tag(block)
223
+ end
224
+
225
+ def call_object(to:, data:, from:, value:, gas: nil)
226
+ obj = { to: Utils.checksum_address(to) }
227
+ obj[:data] = Utils.prefix_hex(data) if data && data != ""
228
+ obj[:from] = Utils.checksum_address(from) if from
229
+ obj[:value] = Utils.to_hex(value) if value && value != 0
230
+ obj[:gas] = Utils.to_hex(gas) if gas
231
+ obj
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module BlockGiven
6
+ # Global configuration set through `BlockGiven.configure { |c| ... }`.
7
+ class Configuration
8
+ attr_accessor :connector, :polling_interval, :timeout, :gas_multiplier, :base_fee_multiplier,
9
+ :confirmations, :abi_path, :max_block_range
10
+ attr_reader :chain, :logger
11
+
12
+ def initialize
13
+ @connector = nil
14
+ @chain = nil
15
+ @polling_interval = 2.0 # seconds between two polls (receipts, blocks, events)
16
+ @timeout = 180 # seconds before wait_for_transaction_receipt gives up
17
+ @gas_multiplier = 1.2 # safety margin applied on top of eth_estimateGas
18
+ @base_fee_multiplier = 1.2 # viem default: maxFeePerGas = baseFee * 1.2 + priorityFee
19
+ @confirmations = 1
20
+ @max_block_range = 2_000 # eth_getLogs ranges are split in chunks of this many blocks
21
+ @abi_path = nil # directory abi_file resolves relative paths against (e.g. Rails.root.join("abis"))
22
+ @logger = Logger.new($stderr, level: Logger::WARN, progname: "block_given")
23
+ @logger_configured = false
24
+ end
25
+
26
+ def logger=(logger)
27
+ @logger = logger
28
+ @logger_configured = true
29
+ end
30
+
31
+ # true once an application set its own logger (the Rails railtie respects it).
32
+ def logger_configured? = @logger_configured
33
+
34
+ # Accepts a BlockGiven::Chain, a symbol (:base), a name ("base-sepolia") or a chain id (8453).
35
+ def chain=(value)
36
+ @chain = value.nil? ? nil : Chains.resolve(value)
37
+ end
38
+
39
+ def connector!
40
+ connector || raise(ConfigurationError, "no connector configured: set BlockGiven.config.connector " \
41
+ "(e.g. BlockGiven::Connectors::Alchemy.new(api_key: ...))")
42
+ end
43
+
44
+ def chain!
45
+ chain || raise(ConfigurationError, "no chain configured: set BlockGiven.config.chain (e.g. :base)")
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Connectors
5
+ # Alchemy JSON-RPC connector. The endpoint is derived from the chain, so one
6
+ # connector instance can serve any Alchemy-supported network.
7
+ #
8
+ # BlockGiven::Connectors::Alchemy.new(api_key: ENV["ALCHEMY_API_KEY"])
9
+ class Alchemy < Http
10
+ attr_reader :api_key
11
+
12
+ def initialize(api_key:, timeout: 30, retries: 3, retry_delay: 0.5, logger: nil, headers: {})
13
+ raise ConfigurationError, "Alchemy api_key is required" if api_key.nil? || api_key.to_s.empty?
14
+
15
+ @api_key = api_key.to_s
16
+ super(url: nil, headers: headers, timeout: timeout, retries: retries, retry_delay: retry_delay, logger: logger)
17
+ end
18
+
19
+ def endpoint(chain)
20
+ raise ConfigurationError, "Alchemy connector needs a chain" if chain.nil?
21
+ raise ConfigurationError, "#{chain.name} is not available on Alchemy" unless chain.alchemy_network
22
+
23
+ "https://#{chain.alchemy_network}.g.alchemy.com/v2/#{api_key}"
24
+ end
25
+
26
+ def inspect = "#<BlockGiven::Connectors::Alchemy api_key=#{redacted_key}>"
27
+
28
+ # Keeps the network host visible, masks the key: https://base-mainnet.g.alchemy.com/v2/abcd…
29
+ def redact(endpoint) = endpoint.to_s.sub(api_key, redacted_key)
30
+
31
+ private
32
+
33
+ def redacted_key = "#{api_key[0, 4]}…"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Connectors
5
+ # A connector is a JSON-RPC transport. Subclasses implement #request; the
6
+ # chain is passed so multi-network providers (Alchemy) can pick the endpoint.
7
+ class Base
8
+ # @return the JSON-RPC `result` (raw JSON value). Raises BlockGiven::RpcError on error.
9
+ def request(method, params = [], chain: nil)
10
+ raise NotImplementedError, "#{self.class}#request"
11
+ end
12
+
13
+ # Naive batch: one request per call. Transports with real batching override it.
14
+ # Returns an array of results; failed calls are returned as RpcError instances.
15
+ def batch(calls, chain: nil)
16
+ calls.map do |(method, params)|
17
+ request(method, params || [], chain: chain)
18
+ rescue RpcError => e
19
+ e
20
+ end
21
+ end
22
+
23
+ def name = self.class.name.split("::").last.downcase
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module BlockGiven
8
+ module Connectors
9
+ # Generic JSON-RPC over HTTP(S) transport with retries and exponential backoff.
10
+ #
11
+ # BlockGiven::Connectors::Http.new(url: "http://127.0.0.1:8545")
12
+ # BlockGiven::Connectors::Http.new # -> falls back to chain.rpc_urls.first
13
+ class Http < Base
14
+ RETRIABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
15
+ RETRIABLE_RPC_CODES = [-32_005, -32_603, 429].freeze # rate limited / internal error
16
+ RETRIABLE_EXCEPTIONS = [Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED,
17
+ Errno::EHOSTUNREACH, EOFError, SocketError, OpenSSL::SSL::SSLError].freeze
18
+
19
+ attr_reader :url, :headers, :timeout, :retries, :retry_delay
20
+
21
+ def initialize(url: nil, headers: {}, timeout: 30, retries: 3, retry_delay: 0.5, logger: nil)
22
+ super()
23
+ @url = url
24
+ user_agent = "block_given/#{BlockGiven::VERSION}"
25
+ @headers = { "Content-Type" => "application/json", "User-Agent" => user_agent }.merge(headers)
26
+ @timeout = timeout
27
+ @retries = retries
28
+ @retry_delay = retry_delay
29
+ @logger = logger
30
+ @id = 0
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ def endpoint(chain)
35
+ return url if url
36
+
37
+ chain&.rpc_urls&.first ||
38
+ raise(ConfigurationError, "no RPC url: pass url: to the connector or use a chain with rpc_urls")
39
+ end
40
+
41
+ def request(method, params = [], chain: nil)
42
+ payload = { jsonrpc: "2.0", id: next_id, method: method, params: params }
43
+ body = with_retries(method) { post(endpoint(chain), payload) }
44
+ handle_single(body, method)
45
+ end
46
+
47
+ def batch(calls, chain: nil)
48
+ return [] if calls.empty?
49
+
50
+ payload = calls.map { |(method, params)| { jsonrpc: "2.0", id: next_id, method: method, params: params || [] } }
51
+ body = with_retries("batch") { post(endpoint(chain), payload) }
52
+ raise RpcError, "batch response is not an array: #{body.inspect}" unless body.is_a?(Array)
53
+
54
+ by_id = body.to_h { |entry| [entry["id"], entry] }
55
+ payload.map do |req|
56
+ entry = by_id[req[:id]] || { "error" => { "message" => "missing response for id #{req[:id]}" } }
57
+ if entry.key?("error")
58
+ RpcError.from_payload(entry["error"], rpc_method: req[:method])
59
+ else
60
+ entry["result"]
61
+ end
62
+ end
63
+ end
64
+
65
+ def inspect = "#<#{self.class.name} url=#{url ? redact(url).inspect : 'chain default'}>"
66
+
67
+ # Endpoint as it may appear in logs and error messages. RPC URLs usually carry
68
+ # the API key in their path (Infura, QuickNode, ...), so only scheme and host are kept.
69
+ def redact(endpoint)
70
+ uri = URI.parse(endpoint.to_s)
71
+ host = uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
72
+ path = uri.path.to_s.delete_prefix("/").empty? ? "" : "/…"
73
+ "#{uri.scheme}://#{host}#{path}"
74
+ rescue URI::InvalidURIError
75
+ "<invalid url>"
76
+ end
77
+
78
+ private
79
+
80
+ def logger = @logger || BlockGiven.config.logger
81
+
82
+ def next_id
83
+ @mutex.synchronize { @id += 1 }
84
+ end
85
+
86
+ def handle_single(body, method)
87
+ raise RpcError.new("unexpected JSON-RPC response: #{body.inspect}", rpc_method: method) unless body.is_a?(Hash)
88
+
89
+ if body.key?("error")
90
+ error = RpcError.from_payload(body["error"], rpc_method: method)
91
+ raise error unless RETRIABLE_RPC_CODES.include?(error.code) && !error.is_a?(ContractRevertError)
92
+
93
+ raise Retry, error
94
+ end
95
+ body["result"]
96
+ end
97
+
98
+ # Internal signal used to retry on retriable JSON-RPC errors.
99
+ class Retry < StandardError
100
+ attr_reader :cause_error
101
+
102
+ def initialize(cause_error)
103
+ @cause_error = cause_error
104
+ super(cause_error.message)
105
+ end
106
+ end
107
+
108
+ def with_retries(method)
109
+ attempt = 0
110
+ loop do
111
+ begin
112
+ return yield
113
+ rescue Retry => e
114
+ raise e.cause_error if attempt >= retries
115
+ rescue HttpError => e
116
+ raise unless RETRIABLE_STATUSES.include?(e.status) && attempt < retries
117
+ rescue *RETRIABLE_EXCEPTIONS => e
118
+ raise HttpError, "#{e.class}: #{e.message}" unless attempt < retries
119
+ end
120
+ attempt += 1
121
+ delay = retry_delay * (2**(attempt - 1))
122
+ logger.debug { "[block_given] retrying #{method} (attempt #{attempt}/#{retries}) in #{delay}s" }
123
+ sleep(delay)
124
+ end
125
+ end
126
+
127
+ def post(endpoint, payload)
128
+ uri = URI.parse(endpoint)
129
+ http = Net::HTTP.new(uri.host, uri.port)
130
+ http.use_ssl = uri.scheme == "https"
131
+ http.open_timeout = timeout
132
+ http.read_timeout = timeout
133
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
134
+
135
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
136
+ request.body = JSON.generate(payload)
137
+ response = http.request(request)
138
+
139
+ unless response.is_a?(Net::HTTPSuccess)
140
+ raise HttpError.new("HTTP #{response.code} from #{redact(endpoint)}: #{response.body.to_s[0, 200]}",
141
+ status: response.code.to_i, body: response.body)
142
+ end
143
+
144
+ JSON.parse(response.body)
145
+ rescue JSON::ParserError => e
146
+ raise HttpError.new("invalid JSON from RPC endpoint: #{e.message}", body: response&.body)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Connectors
5
+ # In-memory connector for tests. Responses can be static values, sequences
6
+ # (consumed in order, last value repeats) or procs receiving the params.
7
+ #
8
+ # stub = BlockGiven::Connectors::Stub.new(
9
+ # "eth_blockNumber" => BlockGiven::Connectors::Stub.sequence("0x10", "0x11"),
10
+ # "eth_call" => ->(params) { "0x" + "0" * 63 + "1" }
11
+ # )
12
+ # stub.calls # => [["eth_blockNumber", []], ...]
13
+ class Stub < Base
14
+ class Sequence
15
+ def initialize(values)
16
+ @values = values.dup
17
+ end
18
+
19
+ def next
20
+ @values.size > 1 ? @values.shift : @values.first
21
+ end
22
+ end
23
+
24
+ def self.sequence(*values) = Sequence.new(values)
25
+
26
+ attr_reader :calls
27
+
28
+ def initialize(responses = {}, &handler)
29
+ super()
30
+ @responses = {}
31
+ @handler = handler
32
+ @calls = []
33
+ responses.each { |method, value| stub(method, value) }
34
+ end
35
+
36
+ def stub(method, value = nil, &block)
37
+ @responses[method.to_s] = block || value
38
+ self
39
+ end
40
+
41
+ def request(method, params = [], chain: nil)
42
+ @calls << [method.to_s, params]
43
+ value = resolve(method.to_s, params, chain)
44
+ raise value if value.is_a?(Exception)
45
+
46
+ value
47
+ end
48
+
49
+ def calls_for(method) = calls.select { |(m, _)| m == method.to_s }.map(&:last)
50
+ def reset! = @calls.clear
51
+
52
+ private
53
+
54
+ def resolve(method, params, chain)
55
+ if @responses.key?(method)
56
+ value = @responses[method]
57
+ value = value.next if value.is_a?(Sequence)
58
+ return value.is_a?(Proc) ? value.call(params) : value
59
+ end
60
+ return @handler.call(method, params, chain) if @handler
61
+
62
+ raise RpcError.new("no stub for #{method}", code: -32_601, rpc_method: method)
63
+ end
64
+ end
65
+ end
66
+ end