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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +463 -0
- data/lib/block_given/abi/coder.rb +134 -0
- data/lib/block_given/abi/custom_error.rb +29 -0
- data/lib/block_given/abi/event.rb +87 -0
- data/lib/block_given/abi/function.rb +72 -0
- data/lib/block_given/abi/interface.rb +109 -0
- data/lib/block_given/abi/parameter.rb +57 -0
- data/lib/block_given/chain.rb +112 -0
- data/lib/block_given/client.rb +234 -0
- data/lib/block_given/configuration.rb +48 -0
- data/lib/block_given/connectors/alchemy.rb +36 -0
- data/lib/block_given/connectors/base.rb +26 -0
- data/lib/block_given/connectors/http.rb +150 -0
- data/lib/block_given/connectors/stub.rb +66 -0
- data/lib/block_given/contract.rb +287 -0
- data/lib/block_given/errors.rb +157 -0
- data/lib/block_given/event.rb +36 -0
- data/lib/block_given/normalizer.rb +37 -0
- data/lib/block_given/poller.rb +226 -0
- data/lib/block_given/railtie.rb +17 -0
- data/lib/block_given/receipt.rb +44 -0
- data/lib/block_given/signed_transaction.rb +145 -0
- data/lib/block_given/transaction.rb +85 -0
- data/lib/block_given/utils.rb +134 -0
- data/lib/block_given/version.rb +5 -0
- data/lib/block_given/wallet.rb +137 -0
- data/lib/block_given.rb +68 -0
- metadata +130 -0
|
@@ -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
|