block_given 0.1.0 → 0.2.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/README.md +92 -55
  4. data/lib/block_given/abi/codec/decoder.rb +111 -0
  5. data/lib/block_given/abi/codec.rb +170 -0
  6. data/lib/block_given/abi/coder.rb +122 -11
  7. data/lib/block_given/abi/custom_error.rb +33 -1
  8. data/lib/block_given/abi/event.rb +85 -3
  9. data/lib/block_given/abi/function.rb +94 -2
  10. data/lib/block_given/abi/interface.rb +99 -4
  11. data/lib/block_given/abi/parameter.rb +60 -3
  12. data/lib/block_given/abi/standards/erc1155.rb +41 -0
  13. data/lib/block_given/abi/standards/erc20.rb +33 -0
  14. data/lib/block_given/abi/standards/erc4626.rb +41 -0
  15. data/lib/block_given/abi/standards/erc721.rb +47 -0
  16. data/lib/block_given/abi/standards.rb +124 -0
  17. data/lib/block_given/abi/type.rb +138 -0
  18. data/lib/block_given/chain.rb +114 -2
  19. data/lib/block_given/client.rb +322 -20
  20. data/lib/block_given/configuration.rb +85 -3
  21. data/lib/block_given/connectors/alchemy.rb +38 -4
  22. data/lib/block_given/connectors/base.rb +32 -5
  23. data/lib/block_given/connectors/http.rb +91 -5
  24. data/lib/block_given/connectors/stub.rb +70 -4
  25. data/lib/block_given/contract.rb +422 -26
  26. data/lib/block_given/crypto/keccak.rb +152 -0
  27. data/lib/block_given/crypto/secp256k1.rb +168 -0
  28. data/lib/block_given/crypto.rb +22 -0
  29. data/lib/block_given/eip712.rb +199 -0
  30. data/lib/block_given/errors.rb +134 -14
  31. data/lib/block_given/event.rb +51 -1
  32. data/lib/block_given/normalizer.rb +27 -2
  33. data/lib/block_given/poller.rb +177 -15
  34. data/lib/block_given/receipt.rb +63 -3
  35. data/lib/block_given/rlp.rb +146 -0
  36. data/lib/block_given/signed_transaction.rb +148 -47
  37. data/lib/block_given/transaction.rb +103 -12
  38. data/lib/block_given/transaction_envelope/fields.rb +104 -0
  39. data/lib/block_given/transaction_envelope.rb +183 -0
  40. data/lib/block_given/utils.rb +143 -9
  41. data/lib/block_given/version.rb +2 -1
  42. data/lib/block_given/wallet.rb +216 -35
  43. data/lib/block_given.rb +67 -4
  44. metadata +19 -23
@@ -1,15 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BlockGiven
4
- # Public JSON-RPC client bound to a chain and a connector (viem's PublicClient).
4
+ # Public JSON-RPC client bound to a chain and a connector (the equivalent of viem's PublicClient).
5
5
  #
6
- # connector = BlockGiven::Connectors::Alchemy.new(api_key: "...")
6
+ # Every method wrapping a JSON-RPC call returns Ruby values rather than raw JSON: QUANTITY fields are
7
+ # decoded to Integer, object keys become snake_case Symbols ({Normalizer}), addresses are checksummed and
8
+ # transactions / receipts are wrapped in {Transaction} / {Receipt}.
9
+ #
10
+ # Arguments named `block:` accept a block number (Integer), a hex QUANTITY string ("0x10"), or one of the
11
+ # block tags `:latest`, `:earliest`, `:pending`, `:safe`, `:finalized` (Symbol or String); `nil` means
12
+ # `latest`. See {Utils.block_tag}.
13
+ #
14
+ # @example Reading from Base through Alchemy
15
+ # connector = BlockGiven::Connectors::Alchemy.new(api_key: ENV["ALCHEMY_API_KEY"])
7
16
  # client = BlockGiven::Client.new(chain: :base, connector: connector)
8
- # client.block_number
9
- # client.get_balance("0x...")
17
+ # client.block_number # => 12_345_678
18
+ # client.get_balance("0x...") # => 1_000_000_000_000_000_000 (wei)
10
19
  class Client
20
+ # @!attribute [r] chain
21
+ # @return [Chain] the chain this client talks to (resolved from the constructor argument or the global config)
22
+ # @!attribute [r] connector
23
+ # @return [Connectors::Base] the JSON-RPC transport used for every request
24
+
11
25
  attr_reader :chain, :connector
12
26
 
27
+ # Builds a client. Every argument falls back to the global configuration ({BlockGiven.config}).
28
+ #
29
+ # @param chain [Chain, Symbol, String, Integer, nil] chain, symbol (`:base`), name ("base-sepolia") or chain id;
30
+ # resolved with {Chains.resolve}. Defaults to `BlockGiven.config.chain`.
31
+ # @param connector [Connectors::Base, nil] JSON-RPC transport. Defaults to `BlockGiven.config.connector`.
32
+ # @param polling_interval [Numeric, nil] seconds between two polls (receipts, watchers). Defaults to
33
+ # `BlockGiven.config.polling_interval`.
34
+ # @param timeout [Numeric, nil] seconds before {#wait_for_transaction_receipt} gives up. Defaults to
35
+ # `BlockGiven.config.timeout`.
36
+ # @param logger [Logger, nil] logger receiving debug lines for each RPC round trip. Defaults to
37
+ # `BlockGiven.config.logger`.
38
+ # @raise [ConfigurationError] when no chain or no connector is given and none is configured globally
13
39
  def initialize(chain: nil, connector: nil, polling_interval: nil, timeout: nil, logger: nil)
14
40
  @chain = chain ? Chains.resolve(chain) : BlockGiven.config.chain!
15
41
  @connector = connector || BlockGiven.config.connector!
@@ -18,11 +44,33 @@ module BlockGiven
18
44
  @logger = logger
19
45
  end
20
46
 
47
+ # Seconds between two polls, falling back to `BlockGiven.config.polling_interval`.
48
+ #
49
+ # @return [Numeric]
21
50
  def polling_interval = @polling_interval || BlockGiven.config.polling_interval
51
+
52
+ # Seconds before a blocking wait gives up, falling back to `BlockGiven.config.timeout`.
53
+ #
54
+ # @return [Numeric]
22
55
  def timeout = @timeout || BlockGiven.config.timeout
56
+
57
+ # Logger used for RPC debug lines and watcher warnings, falling back to `BlockGiven.config.logger`.
58
+ #
59
+ # @return [Logger]
23
60
  def logger = @logger || BlockGiven.config.logger
24
61
 
25
- # Raw JSON-RPC call: client.request("eth_blockNumber") / client.request("eth_getBalance", addr, "latest")
62
+ # Performs a raw JSON-RPC call through the connector and returns the undecoded `result`.
63
+ #
64
+ # The method name and params are logged at debug level, as is the (truncated) result.
65
+ #
66
+ # @example
67
+ # client.request("eth_blockNumber") # => "0xbc614e"
68
+ # client.request("eth_getBalance", "0x...", "latest") # => "0xde0b6b3a7640000"
69
+ # @param method [String] JSON-RPC method name
70
+ # @param params [Array<Object>] positional JSON-RPC params, passed as-is
71
+ # @return [Object] the JSON-RPC `result` (String, Hash, Array, nil...) as returned by the node
72
+ # @raise [RpcError] when the node answers with a JSON-RPC error
73
+ # @raise [HttpError] when the transport fails (non-2xx status, network error, invalid JSON)
26
74
  def request(method, *params)
27
75
  logger.debug { "[block_given] -> #{method} #{params.inspect}" }
28
76
  result = connector.request(method, params, chain: chain)
@@ -30,29 +78,70 @@ module BlockGiven
30
78
  result
31
79
  end
32
80
 
33
- # Batched JSON-RPC: client.batch([["eth_blockNumber"], ["eth_chainId"]])
81
+ # Sends several JSON-RPC calls at once through the connector ({Connectors::Base#batch}).
82
+ #
83
+ # @example
84
+ # client.batch([["eth_blockNumber"], ["eth_chainId"]]) # => ["0xbc614e", "0x2105"]
85
+ # @param calls [Array<Array(String, Array)>] `[method, params]` pairs; params may be omitted
86
+ # @return [Array<Object, RpcError>] one raw result per call, in order; a failed call is returned (not raised)
87
+ # as an {RpcError} instance
88
+ # @raise [HttpError] when the whole batch request fails at the transport level
34
89
  def batch(calls) = connector.batch(calls, chain: chain)
35
90
 
36
91
  # --- Chain / blocks -----------------------------------------------------
37
92
 
93
+ # Chain id reported by the node (`eth_chainId`).
94
+ #
95
+ # @return [Integer] the chain id, e.g. 8453 for Base
38
96
  def chain_id = Utils.hex_to_int(request("eth_chainId"))
97
+
98
+ # Number of the most recent block (`eth_blockNumber`).
99
+ #
100
+ # @return [Integer] the current head block number
39
101
  def block_number = Utils.hex_to_int(request("eth_blockNumber"))
40
102
 
103
+ # Fetches a block by number, tag or hash (`eth_getBlockByNumber`, or `eth_getBlockByHash` when `block`
104
+ # is a 32-byte hex hash).
105
+ #
106
+ # @param block [Integer, Symbol, String] block number, hex QUANTITY, block tag (`:latest`, `:earliest`,
107
+ # `:pending`, `:safe`, `:finalized`) or a 66-character `0x` block hash
108
+ # @param include_transactions [Boolean] when true the `:transactions` key holds full transaction objects
109
+ # instead of transaction hashes
110
+ # @return [Hash{Symbol => Object}, nil] the block with snake_case Symbol keys and Integer quantities
111
+ # (`:number`, `:timestamp`, `:gas_used`, `:gas_limit`, `:base_fee_per_gas`, ...), or nil when the node does
112
+ # not know the block
113
+ # @raise [InvalidArgumentError] when `block` is neither a number, a known tag nor a hex string
41
114
  def get_block(block = :latest, include_transactions: false)
42
115
  method = Utils.hex?(block.to_s) && block.to_s.length == 66 ? "eth_getBlockByHash" : "eth_getBlockByNumber"
43
116
  raw = request(method, block_param(block), include_transactions)
44
117
  raw && Normalizer.normalize(raw)
45
118
  end
46
119
 
120
+ # Legacy gas price suggested by the node (`eth_gasPrice`).
121
+ #
122
+ # @return [Integer] gas price in wei
47
123
  def gas_price = Utils.hex_to_int(request("eth_gasPrice"))
48
124
 
125
+ # Priority fee (tip) suggested by the node (`eth_maxPriorityFeePerGas`).
126
+ #
127
+ # Not every node implements the method: on an {RpcError} the value falls back to 1 gwei.
128
+ #
129
+ # @return [Integer] max priority fee per gas in wei
49
130
  def max_priority_fee_per_gas
50
131
  Utils.hex_to_int(request("eth_maxPriorityFeePerGas"))
51
132
  rescue RpcError
52
133
  Utils.parse_gwei("1") # method not supported by every node
53
134
  end
54
135
 
55
- # EIP-1559 fee estimation (viem semantics: baseFee * multiplier + priority fee).
136
+ # Estimates EIP-1559 fees with viem's semantics: `max_fee_per_gas = baseFee * multiplier + priority fee`.
137
+ #
138
+ # The base fee comes from the latest block (`eth_getBlockByNumber`), falling back to `eth_gasPrice` on
139
+ # chains without EIP-1559; the priority fee comes from {#max_priority_fee_per_gas}.
140
+ #
141
+ # @param base_fee_multiplier [Numeric, nil] safety margin applied to the base fee. Defaults to
142
+ # `BlockGiven.config.base_fee_multiplier` (1.2).
143
+ # @return [Hash{Symbol => Integer}] `:base_fee_per_gas`, `:max_priority_fee_per_gas` and `:max_fee_per_gas`,
144
+ # all in wei
56
145
  def estimate_fees_per_gas(base_fee_multiplier: nil)
57
146
  multiplier = base_fee_multiplier || BlockGiven.config.base_fee_multiplier
58
147
  block = get_block(:latest)
@@ -67,54 +156,154 @@ module BlockGiven
67
156
 
68
157
  # --- Accounts -----------------------------------------------------------
69
158
 
159
+ # Native currency balance of an account (`eth_getBalance`).
160
+ #
161
+ # @param address [String, #address] `0x` address (checksummed before being sent), or an object responding
162
+ # to `address` such as a {Wallet} or a {Contract}
163
+ # @param block [Integer, Symbol, String, nil] block number, hex QUANTITY or tag (`:latest`, `:earliest`,
164
+ # `:pending`, `:safe`, `:finalized`)
165
+ # @return [Integer] balance in wei
166
+ # @raise [InvalidAddressError] when `address` is not a valid 20-byte hex address
70
167
  def get_balance(address, block: :latest)
71
168
  Utils.hex_to_int(request("eth_getBalance", Utils.checksum_address(address), Utils.block_tag(block)))
72
169
  end
73
170
 
171
+ # Number of transactions sent from an account, i.e. its next nonce (`eth_getTransactionCount`).
172
+ #
173
+ # Defaults to the `pending` tag so that queued transactions are taken into account.
174
+ #
175
+ # @param address [String, #address] `0x` address (checksummed before being sent)
176
+ # @param block [Integer, Symbol, String, nil] block number, hex QUANTITY or tag (`:latest`, `:earliest`,
177
+ # `:pending`, `:safe`, `:finalized`)
178
+ # @return [Integer] the transaction count
179
+ # @raise [InvalidAddressError] when `address` is not a valid 20-byte hex address
74
180
  def get_transaction_count(address, block: :pending)
75
181
  Utils.hex_to_int(request("eth_getTransactionCount", Utils.checksum_address(address), Utils.block_tag(block)))
76
182
  end
77
183
 
184
+ # Bytecode deployed at an address (`eth_getCode`).
185
+ #
186
+ # @param address [String, #address] `0x` address (checksummed before being sent)
187
+ # @param block [Integer, Symbol, String, nil] block number, hex QUANTITY or tag (`:latest`, `:earliest`,
188
+ # `:pending`, `:safe`, `:finalized`)
189
+ # @return [String] the code as a `0x` hex string; `"0x"` when the address holds no code
190
+ # @raise [InvalidAddressError] when `address` is not a valid 20-byte hex address
78
191
  def get_code(address, block: :latest)
79
192
  request("eth_getCode", Utils.checksum_address(address), Utils.block_tag(block))
80
193
  end
81
194
 
195
+ # Whether an address holds bytecode at the latest block (see {#get_code}).
196
+ #
197
+ # @param address [String, #address] `0x` address
198
+ # @return [Boolean] true for a contract, false for an externally owned account
199
+ # @raise [InvalidAddressError] when `address` is not a valid 20-byte hex address
82
200
  def contract?(address) = get_code(address) != "0x"
83
201
 
202
+ # Reads a raw storage slot of a contract (`eth_getStorageAt`).
203
+ #
204
+ # @param address [String, #address] contract address (checksummed before being sent)
205
+ # @param slot [Integer, String] storage slot, as an Integer or a hex string
206
+ # @param block [Integer, Symbol, String, nil] block number, hex QUANTITY or tag (`:latest`, `:earliest`,
207
+ # `:pending`, `:safe`, `:finalized`)
208
+ # @return [String] the 32-byte slot value as a `0x` hex string
209
+ # @raise [InvalidAddressError] when `address` is not a valid 20-byte hex address
210
+ # @raise [InvalidArgumentError] when `slot` is neither an Integer nor a String
84
211
  def get_storage_at(address, slot, block: :latest)
85
212
  request("eth_getStorageAt", Utils.checksum_address(address), Utils.to_hex(slot), Utils.block_tag(block))
86
213
  end
87
214
 
88
215
  # --- Calls --------------------------------------------------------------
89
216
 
90
- # Executes a read-only call. Returns the raw hex result.
217
+ # Executes a read-only call against a contract (`eth_call`) and returns the raw ABI-encoded result.
218
+ #
219
+ # Higher-level decoding lives in {Contract}; use this when you already hold encoded calldata.
220
+ #
221
+ # @example Reading `totalSupply()` of an ERC20
222
+ # client.call(to: usdc_address, data: "0x18160ddd")
223
+ # # => "0x000000000000000000000000000000000000000000000000000001c6bf526340"
224
+ # @param to [String, #address] contract address (checksummed before being sent)
225
+ # @param data [String] ABI-encoded calldata as a hex string (`0x` prefix optional); omitted when empty
226
+ # @param from [String, #address, nil] sender address, for calls whose result depends on `msg.sender`
227
+ # @param value [Integer, nil] wei sent along with the call; omitted when nil or 0
228
+ # @param gas [Integer, nil] gas limit for the call
229
+ # @param block [Integer, Symbol, String, nil] block number, hex QUANTITY or tag (`:latest`, `:earliest`,
230
+ # `:pending`, `:safe`, `:finalized`)
231
+ # @return [String] the return data as a `0x` hex string
232
+ # @raise [ContractRevertError] when the EVM reverts (a subclass of {RpcError}, carrying the revert data)
233
+ # @raise [RpcError] for any other JSON-RPC error
234
+ # @raise [InvalidAddressError] when `to` or `from` is not a valid 20-byte hex address
91
235
  def call(to:, data:, from: nil, value: nil, gas: nil, block: :latest)
92
236
  request("eth_call", call_object(to: to, data: data, from: from, value: value, gas: gas), Utils.block_tag(block))
93
237
  end
94
238
 
239
+ # Estimates the gas needed by a transaction (`eth_estimateGas`).
240
+ #
241
+ # @param to [String, #address] recipient / contract address (checksummed before being sent)
242
+ # @param data [String, nil] ABI-encoded calldata as a hex string; omitted when nil or empty
243
+ # @param from [String, #address, nil] sender address
244
+ # @param value [Integer, nil] wei sent along with the transaction; omitted when nil or 0
245
+ # @return [Integer] the estimated gas units
246
+ # @raise [ContractRevertError] when the simulated execution reverts
247
+ # @raise [RpcError] for any other JSON-RPC error
248
+ # @raise [InvalidAddressError] when `to` or `from` is not a valid 20-byte hex address
95
249
  def estimate_gas(to:, data: nil, from: nil, value: nil)
96
250
  Utils.hex_to_int(request("eth_estimateGas", call_object(to: to, data: data, from: from, value: value)))
97
251
  end
98
252
 
99
253
  # --- Transactions -------------------------------------------------------
100
254
 
255
+ # Broadcasts a signed transaction (`eth_sendRawTransaction`).
256
+ #
257
+ # @param raw [String] RLP-encoded signed transaction as a hex string (`0x` prefix optional)
258
+ # @return [Transaction] handle on the broadcast transaction, bound to this client so it can `wait`
259
+ # @raise [ContractRevertError] when the node rejects the transaction because it would revert
260
+ # @raise [RpcError] for any other JSON-RPC error (nonce too low, underpriced, ...)
101
261
  def send_raw_transaction(raw)
102
262
  Transaction.new(request("eth_sendRawTransaction", Utils.prefix_hex(raw)), client: self)
103
263
  end
104
264
 
265
+ # Wraps an already known transaction hash in a {Transaction} handle. No RPC call is made.
266
+ #
267
+ # @param hash [String] `0x` transaction hash
268
+ # @return [Transaction] handle bound to this client
105
269
  def transaction(hash) = Transaction.new(hash, client: self)
106
270
 
271
+ # Fetches a transaction object by hash (`eth_getTransactionByHash`).
272
+ #
273
+ # @param hash [String] `0x` transaction hash
274
+ # @return [Hash{Symbol => Object}, nil] the transaction with snake_case Symbol keys and Integer quantities
275
+ # (`:nonce`, `:value`, `:gas`, `:block_number`, `:max_fee_per_gas`, ...; `:block_number` is nil while
276
+ # pending), or nil when the node does not know the hash
107
277
  def get_transaction(hash)
108
278
  raw = request("eth_getTransactionByHash", hash)
109
279
  raw && Normalizer.normalize(raw)
110
280
  end
111
281
 
282
+ # Fetches the receipt of a mined transaction (`eth_getTransactionReceipt`).
283
+ #
284
+ # @param hash [String] `0x` transaction hash
285
+ # @return [Receipt, nil] the receipt, or nil while the transaction is not mined (or unknown)
112
286
  def get_transaction_receipt(hash)
113
287
  raw = request("eth_getTransactionReceipt", hash)
114
288
  raw && Receipt.new(raw)
115
289
  end
116
290
 
117
- # Polls until the receipt is available (and confirmed by N blocks).
291
+ # Blocks until the transaction is mined and confirmed by `confirmations` blocks, then returns its receipt.
292
+ #
293
+ # Polls `eth_getTransactionReceipt` (and `eth_blockNumber` when more than one confirmation is required)
294
+ # through {Poller.poll}. A transaction mined in the current head block has exactly 1 confirmation.
295
+ # The receipt is returned whatever its status: check {Receipt#success?} or use {Transaction#wait!}.
296
+ #
297
+ # @example
298
+ # receipt = client.wait_for_transaction_receipt(tx.hash, confirmations: 3, timeout: 300)
299
+ # receipt.status # => :success
300
+ # @param hash [String] `0x` transaction hash
301
+ # @param confirmations [Integer, nil] blocks that must include or follow the transaction (1 = mined).
302
+ # Defaults to `BlockGiven.config.confirmations`.
303
+ # @param timeout [Numeric, nil] seconds before giving up. Defaults to {#timeout}.
304
+ # @param polling_interval [Numeric, nil] seconds between two polls. Defaults to {#polling_interval}.
305
+ # @return [Receipt] the mined (and confirmed) receipt
306
+ # @raise [TimeoutError] when the receipt is still missing or unconfirmed after `timeout` seconds
118
307
  def wait_for_transaction_receipt(hash, confirmations: nil, timeout: nil, polling_interval: nil)
119
308
  confirmations ||= BlockGiven.config.confirmations
120
309
  interval = polling_interval || self.polling_interval
@@ -129,6 +318,29 @@ module BlockGiven
129
318
 
130
319
  # --- Logs ---------------------------------------------------------------
131
320
 
321
+ # Fetches event logs matching a filter (`eth_getLogs`).
322
+ #
323
+ # Providers cap the block range a single `eth_getLogs` may span; use {#get_logs_in_chunks} for large
324
+ # ranges and {Contract#get_events} to decode the logs against an ABI.
325
+ #
326
+ # @example Transfer logs of one contract over 100 blocks
327
+ # transfer = BlockGiven::Utils.keccak256("Transfer(address,address,uint256)")
328
+ # client.get_logs(address: usdc, topics: [transfer], from_block: 20_000_000, to_block: 20_000_099)
329
+ # # => [{ address: "0x...", topics: [...], data: "0x...", block_number: 20_000_001, log_index: 3, ... }]
330
+ # @param address [String, #address, Array<String, #address>, nil] one or several contract addresses
331
+ # (each checksummed before being sent); nil matches every address
332
+ # @param topics [Array<String, Array<String>, nil>, nil] positional topic filter as expected by the node:
333
+ # a `0x` 32-byte topic, an Array of alternatives (OR), or nil as a wildcard at that position
334
+ # @param from_block [Integer, Symbol, String, nil] start of the range: block number, hex QUANTITY or tag
335
+ # (`:latest`, `:earliest`, `:pending`, `:safe`, `:finalized`); ignored when `block_hash` is given
336
+ # @param to_block [Integer, Symbol, String, nil] end of the range (inclusive), same forms as `from_block`
337
+ # @param block_hash [String, nil] restrict the query to a single block by hash instead of a block range
338
+ # @return [Array<Hash{Symbol => Object}>] raw logs with snake_case Symbol keys: `:address`, `:topics`
339
+ # (Array of `0x` strings), `:data`, `:block_number`, `:transaction_index` and `:log_index` (Integer),
340
+ # `:block_hash`, `:transaction_hash` and `:removed`
341
+ # @raise [InvalidAddressError] when an address is not a valid 20-byte hex address
342
+ # @raise [InvalidArgumentError] when a block argument is neither a number, a known tag nor a hex string
343
+ # @raise [RpcError] when the node rejects the filter (range too large, too many results, ...)
132
344
  def get_logs(address: nil, topics: nil, from_block: :latest, to_block: :latest, block_hash: nil)
133
345
  filter = {}
134
346
  filter[:address] = Array(address).map { |a| Utils.checksum_address(a) } if address
@@ -145,8 +357,20 @@ module BlockGiven
145
357
 
146
358
  # --- Watchers (background polling) --------------------------------------
147
359
 
148
- # Yields each new block number. With emit_missed: true every block between two
149
- # polls is yielded, otherwise only the latest one.
360
+ # Starts a background {Watcher} yielding the head block number as it advances (polls `eth_blockNumber`).
361
+ #
362
+ # The first tick yields the current head. Later ticks yield only when the head moved forward: by default
363
+ # just the new head, with `emit_missed: true` every block number between the previous head (excluded) and
364
+ # the new one (included), in order.
365
+ #
366
+ # @param polling_interval [Numeric, nil] seconds between two polls. Defaults to {#polling_interval}.
367
+ # @param emit_missed [Boolean] yield every block skipped between two polls instead of only the latest one
368
+ # @param id [String, nil] stable identifier for {Watcher.find} / {Watcher.stop}. Defaults to a generated one.
369
+ # @yield [number] for each new block number, from the watcher thread
370
+ # @yieldparam number [Integer] block number
371
+ # @yieldreturn [void]
372
+ # @return [Watcher] the started watcher; call {Watcher#stop} to end it
373
+ # @raise [InvalidArgumentError] when a watcher with the same `id` is already running
150
374
  def watch_block_number(polling_interval: nil, emit_missed: false, id: nil, &block)
151
375
  last = nil
152
376
  watcher("block_number", polling_interval, id: id) do
@@ -162,14 +386,44 @@ module BlockGiven
162
386
  end
163
387
  end
164
388
 
389
+ # Starts a background {Watcher} yielding every new block object (`eth_getBlockByNumber` for each block
390
+ # number reported by {#watch_block_number} with `emit_missed: true`).
391
+ #
392
+ # @param polling_interval [Numeric, nil] seconds between two polls. Defaults to {#polling_interval}.
393
+ # @param include_transactions [Boolean] when true blocks carry full transaction objects instead of hashes
394
+ # @param id [String, nil] stable identifier for {Watcher.find} / {Watcher.stop}. Defaults to a generated one.
395
+ # @yield [block] for each new block, in order, from the watcher thread
396
+ # @yieldparam block [Hash{Symbol => Object}] normalized block as returned by {#get_block}
397
+ # @yieldreturn [void]
398
+ # @return [Watcher] the started watcher; call {Watcher#stop} to end it
399
+ # @raise [InvalidArgumentError] when a watcher with the same `id` is already running
165
400
  def watch_blocks(polling_interval: nil, include_transactions: false, id: nil, &block)
166
401
  watch_block_number(polling_interval: polling_interval, emit_missed: true, id: id) do |number|
167
402
  block.call(get_block(number, include_transactions: include_transactions))
168
403
  end
169
404
  end
170
405
 
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.
406
+ # Runs {#get_logs} over a large block range, split in consecutive chunks of at most `max_block_range`
407
+ # blocks so that provider limits are respected.
408
+ #
409
+ # With a block, each chunk's logs are yielded as soon as they are fetched (and the return value is an
410
+ # empty Array); without a block every log is collected and returned at once.
411
+ #
412
+ # @param from_block [Integer] first block of the range (inclusive)
413
+ # @param address [String, #address, Array<String, #address>, nil] address filter, see {#get_logs}
414
+ # @param topics [Array<String, Array<String>, nil>, nil] topic filter, see {#get_logs}
415
+ # @param to_block [Integer, Symbol, String, nil] last block of the range (inclusive). A block tag
416
+ # (`:latest`, `:earliest`, `:pending`, `:safe`, `:finalized`) or nil is replaced by the current head
417
+ # (`eth_blockNumber`) before chunking.
418
+ # @param max_block_range [Integer, nil] chunk size in blocks. Defaults to `BlockGiven.config.max_block_range`
419
+ # (2000).
420
+ # @yield [logs, from, to] once per chunk, in ascending block order
421
+ # @yieldparam logs [Array<Hash{Symbol => Object}>] logs of the chunk (possibly empty), see {#get_logs}
422
+ # @yieldparam from [Integer] first block of the chunk
423
+ # @yieldparam to [Integer] last block of the chunk (inclusive)
424
+ # @yieldreturn [void]
425
+ # @return [Array<Hash{Symbol => Object}>] every log of the range when no block is given, `[]` otherwise
426
+ # @raise [RpcError] when a chunk is rejected by the node
173
427
  def get_logs_in_chunks(from_block:, address: nil, topics: nil, to_block: :latest, max_block_range: nil)
174
428
  size = max_block_range || BlockGiven.config.max_block_range
175
429
  to_block = block_number if to_block.nil? || Utils::BLOCK_TAGS.include?(to_block.to_s)
@@ -184,13 +438,46 @@ module BlockGiven
184
438
  collected
185
439
  end
186
440
 
187
- # Yields new logs matching the filter, one Array per processed block range.
441
+ # Starts a background {Watcher} yielding new logs matching a filter, one Array per processed block range.
442
+ #
443
+ # On every tick the watcher reads the head (`eth_blockNumber`), subtracts `confirmations`, and walks from
444
+ # the last processed block (excluded) up to that point in ranges of at most `max_block_range` blocks. For
445
+ # each range it calls `eth_getLogs`, yields the logs (only when there are some), calls `on_progress`, then
446
+ # advances {Watcher#cursor} to the range's last block. The cursor is therefore always the last block whose
447
+ # logs were handed to the block: if the block raises, the cursor is not advanced, the error goes through
448
+ # the watcher's error handling and the same range is retried on the next tick.
449
+ #
450
+ # Without `from_block` the watcher starts at the current head (minus `confirmations`) and only reports
451
+ # logs emitted afterwards. With `from_block` it first catches up from that block, chunk by chunk, before
452
+ # following the head; persist the `to` value received by `on_progress` and pass it back as `from_block + 1`
453
+ # to resume after a restart. See the README section "How watchers behave".
188
454
  #
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)
455
+ # @example Following Transfer events and persisting a cursor
456
+ # transfer = BlockGiven::Utils.keccak256("Transfer(address,address,uint256)")
457
+ # watcher = client.watch_logs(
458
+ # address: usdc, topics: [transfer], from_block: Cursor.last + 1, confirmations: 2, id: "usdc-transfers",
459
+ # on_progress: ->(_from, to) { Cursor.save(to) }
460
+ # ) { |logs| logs.each { |log| Transfers.ingest(log) } }
461
+ # watcher.cursor # => last processed block number
462
+ # BlockGiven::Watcher.stop("usdc-transfers", join: 5)
463
+ # @param address [String, #address, Array<String, #address>, nil] address filter, see {#get_logs}
464
+ # @param topics [Array<String, Array<String>, nil>, nil] topic filter, see {#get_logs}
465
+ # @param from_block [Integer, nil] first block to process (inclusive); nil starts at the current head
466
+ # @param polling_interval [Numeric, nil] seconds between two ticks. Defaults to {#polling_interval}.
467
+ # @param max_block_range [Integer, nil] maximum blocks per `eth_getLogs` call. Defaults to
468
+ # `BlockGiven.config.max_block_range` (2000).
469
+ # @param confirmations [Integer] number of blocks to stay behind the head, to avoid processing logs that a
470
+ # reorg could remove (0 processes up to the head)
471
+ # @param on_progress [#call, nil] callable invoked with `(from, to)` after each range was yielded and
472
+ # before the cursor moves; `to` is the last processed block, suited for persistence
473
+ # @param id [String, nil] stable identifier for {Watcher.find} / {Watcher.stop}. Defaults to a generated one.
474
+ # @param name [String, nil] human-readable name shown by {Watcher#inspect}. Defaults to
475
+ # `"logs@<first address>"` (or `"logs@*"` without address filter).
476
+ # @yield [logs] for each processed range that contains at least one matching log, from the watcher thread
477
+ # @yieldparam logs [Array<Hash{Symbol => Object}>] non-empty normalized logs, see {#get_logs}
478
+ # @yieldreturn [void]
479
+ # @return [Watcher] the started watcher; {Watcher#cursor} holds the last processed block number
480
+ # @raise [InvalidArgumentError] when a watcher with the same `id` is already running
194
481
  def watch_logs(address: nil, topics: nil, from_block: nil, polling_interval: nil, max_block_range: nil,
195
482
  confirmations: 0, on_progress: nil, id: nil, name: nil, &block)
196
483
  last = from_block ? from_block - 1 : block_number - confirmations
@@ -207,11 +494,26 @@ module BlockGiven
207
494
  end
208
495
  end
209
496
 
210
- # Generic background watcher on this client.
497
+ # Builds and starts a generic background {Watcher} bound to this client's polling interval and logger.
498
+ #
499
+ # Every `watch_*` helper goes through this method so that watchers share the registry, the thread naming
500
+ # and the error handling of {Watcher}.
501
+ #
502
+ # @param name [String] human-readable name, also used to generate the id when none is given
503
+ # @param polling_interval [Numeric, nil] seconds between two ticks. Defaults to {#polling_interval}.
504
+ # @param id [String, nil] stable identifier for {Watcher.find} / {Watcher.stop}. Defaults to a generated one.
505
+ # @yield [watcher] on every tick, from the watcher thread
506
+ # @yieldparam watcher [Watcher] the watcher itself (to read {Watcher#stopped?} or set {Watcher#cursor})
507
+ # @yieldreturn [void]
508
+ # @return [Watcher] the started watcher
509
+ # @raise [InvalidArgumentError] when a watcher with the same `id` is already running
211
510
  def watcher(name, polling_interval = nil, id: nil, &tick)
212
511
  Watcher.new(interval: polling_interval || self.polling_interval, name: name, id: id, logger: logger, &tick).start
213
512
  end
214
513
 
514
+ # Short description of the client; the connector's `inspect` never exposes credentials.
515
+ #
516
+ # @return [String]
215
517
  def inspect = "#<BlockGiven::Client chain=#{chain} connector=#{connector.inspect}>"
216
518
 
217
519
  private
@@ -3,12 +3,74 @@
3
3
  require "logger"
4
4
 
5
5
  module BlockGiven
6
- # Global configuration set through `BlockGiven.configure { |c| ... }`.
6
+ # Global configuration set through {BlockGiven.configure}.
7
+ #
8
+ # Every option has a default, so an application only has to provide a {#connector} and a {#chain}. Values
9
+ # are read lazily by {Client} (polling interval, timeout, confirmations, fee multipliers, block range),
10
+ # {Wallet} ({#gas_multiplier}), {Contract.abi_file} ({#abi_path}) and the Rails railtie ({#logger}), so
11
+ # changing them affects clients that were already built.
12
+ #
13
+ # @example
14
+ # BlockGiven.configure do |c|
15
+ # c.connector = BlockGiven::Connectors::Alchemy.new(api_key: ENV["ALCHEMY_API_KEY"])
16
+ # c.chain = :base
17
+ # c.abi_path = Rails.root.join("abis")
18
+ # c.confirmations = 2
19
+ # end
20
+ #
21
+ # @!attribute [rw] connector
22
+ # JSON-RPC transport used by the default client and by contracts without an explicit connector
23
+ # (default `nil`, required). Any {Connectors::Base} implementation: {Connectors::Alchemy},
24
+ # {Connectors::Http}, or {Connectors::Stub} in tests.
25
+ # @return [BlockGiven::Connectors::Base, nil]
26
+ # @!attribute [rw] polling_interval
27
+ # Seconds between two polls when waiting for a receipt, watching blocks or watching events
28
+ # (default `2.0`).
29
+ # @return [Float, Integer]
30
+ # @!attribute [rw] timeout
31
+ # Seconds before {Client#wait_for_transaction_receipt} gives up with a {TimeoutError} (default `180`).
32
+ # `nil` waits forever.
33
+ # @return [Float, Integer, nil]
34
+ # @!attribute [rw] gas_multiplier
35
+ # Safety margin applied on top of `eth_estimateGas` when a wallet fills in the gas limit of a
36
+ # transaction: `gas = ceil(estimate * gas_multiplier)` (default `1.2`).
37
+ # @return [Float]
38
+ # @!attribute [rw] base_fee_multiplier
39
+ # Multiplier applied to the latest block base fee when {Client#estimate_fees_per_gas} computes
40
+ # `maxFeePerGas = baseFee * base_fee_multiplier + maxPriorityFeePerGas` (default `1.2`, viem's default).
41
+ # @return [Float]
42
+ # @!attribute [rw] confirmations
43
+ # Number of blocks, including the mining block, a receipt must have before
44
+ # {Client#wait_for_transaction_receipt} and {Transaction#confirmed?} consider it final (default `1`, i.e. as
45
+ # soon as the transaction is mined).
46
+ # @return [Integer]
47
+ # @!attribute [rw] abi_path
48
+ # Directory {Contract.abi_file} resolves relative paths against, e.g. `Rails.root.join("abis")`
49
+ # (default `nil`: relative paths are resolved from the current working directory). ABIs belong to the
50
+ # application, the gem ships none.
51
+ # @return [String, Pathname, nil]
52
+ # @!attribute [rw] max_block_range
53
+ # Maximum number of blocks per `eth_getLogs` request; {Client#get_logs_in_chunks} and
54
+ # {Client#watch_logs} split wider ranges into chunks of this size to respect provider caps
55
+ # (default `2_000`).
56
+ # @return [Integer]
57
+ # @!attribute [r] chain
58
+ # Network the default client talks to (default `nil`, required). Assigned through {#chain=}, which accepts
59
+ # anything {Chains.resolve} understands.
60
+ # @return [BlockGiven::Chain, nil]
61
+ # @!attribute [r] logger
62
+ # Logger used by clients, connectors and watchers (default: a `Logger` writing to `$stderr` at `WARN`
63
+ # level with progname `"block_given"`). Assigning one through {#logger=} marks it as application-provided,
64
+ # which stops the Rails railtie from installing `Rails.logger`.
65
+ # @return [Logger]
7
66
  class Configuration
8
67
  attr_accessor :connector, :polling_interval, :timeout, :gas_multiplier, :base_fee_multiplier,
9
68
  :confirmations, :abi_path, :max_block_range
10
69
  attr_reader :chain, :logger
11
70
 
71
+ # Build a configuration holding the defaults documented on each attribute.
72
+ #
73
+ # @return [Configuration]
12
74
  def initialize
13
75
  @connector = nil
14
76
  @chain = nil
@@ -23,24 +85,44 @@ module BlockGiven
23
85
  @logger_configured = false
24
86
  end
25
87
 
88
+ # Set the logger and remember that the application provided it (see {#logger_configured?}).
89
+ #
90
+ # @param logger [Logger] any object responding to `debug`, `info`, `warn` and `error`
91
+ # @return [Logger] the assigned logger
26
92
  def logger=(logger)
27
93
  @logger = logger
28
94
  @logger_configured = true
29
95
  end
30
96
 
31
- # true once an application set its own logger (the Rails railtie respects it).
97
+ # Whether an application set its own logger through {#logger=}.
98
+ #
99
+ # The Rails railtie only installs `Rails.logger` when this is false, so an explicit logger always wins.
100
+ #
101
+ # @return [Boolean]
32
102
  def logger_configured? = @logger_configured
33
103
 
34
- # Accepts a BlockGiven::Chain, a symbol (:base), a name ("base-sepolia") or a chain id (8453).
104
+ # Set the chain from a {Chain}, a symbol (`:base`), a network name (`"base-sepolia"`) or a chain id (`8453`).
105
+ #
106
+ # @param value [BlockGiven::Chain, Symbol, String, Integer, nil] nil clears the chain
107
+ # @return [BlockGiven::Chain, nil] the resolved chain
108
+ # @raise [BlockGiven::ConfigurationError] when the value does not match a known chain (see {Chains.resolve})
35
109
  def chain=(value)
36
110
  @chain = value.nil? ? nil : Chains.resolve(value)
37
111
  end
38
112
 
113
+ # Return the connector or raise when none was configured.
114
+ #
115
+ # @return [BlockGiven::Connectors::Base]
116
+ # @raise [BlockGiven::ConfigurationError] when {#connector} is nil
39
117
  def connector!
40
118
  connector || raise(ConfigurationError, "no connector configured: set BlockGiven.config.connector " \
41
119
  "(e.g. BlockGiven::Connectors::Alchemy.new(api_key: ...))")
42
120
  end
43
121
 
122
+ # Return the chain or raise when none was configured.
123
+ #
124
+ # @return [BlockGiven::Chain]
125
+ # @raise [BlockGiven::ConfigurationError] when {#chain} is nil
44
126
  def chain!
45
127
  chain || raise(ConfigurationError, "no chain configured: set BlockGiven.config.chain (e.g. :base)")
46
128
  end