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,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # Base class for typed contracts. Declare the ABI (and optionally a default
5
+ # address / chain) and every ABI function becomes a Ruby method:
6
+ #
7
+ # class Usdc < BlockGiven::Contract
8
+ # abi_file "abis/erc20.json" # your app's ABI file (see BlockGiven.config.abi_path)
9
+ # address "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
10
+ # end
11
+ #
12
+ # usdc = Usdc.new(wallet: BlockGiven::Wallet.new(private_key: "0x..."))
13
+ # usdc.balance_of(wallet.address) # eth_call, decoded
14
+ # tx = usdc.transfer(to: "0x...", value: 1e6) # signed + broadcast -> BlockGiven::Transaction
15
+ # tx.wait! # polls the receipt
16
+ #
17
+ # Transaction / call overrides go in the reserved `tx:` keyword:
18
+ # vault.deposit(amount, tx: { value: BlockGiven::Utils.parse_ether("0.1"), gas: 200_000 })
19
+ # token.balance_of(addr, tx: { block: 18_000_000 })
20
+ class Contract
21
+ TX_OPTIONS = %i[value gas nonce max_fee_per_gas max_priority_fee_per_gas gas_price from block].freeze
22
+
23
+ class << self
24
+ attr_reader :interface
25
+
26
+ # Sets (or returns) the ABI. Accepts an Array, an artifact Hash, or a JSON String.
27
+ def abi(source = nil)
28
+ return interface&.raw if source.nil?
29
+
30
+ @interface = Abi::Interface.parse(source)
31
+ define_abi_methods!
32
+ @interface
33
+ end
34
+
35
+ # Loads the ABI from a JSON file. Relative paths are resolved against
36
+ # BlockGiven.config.abi_path when set (ABIs live in your app, not in the gem).
37
+ def abi_file(path)
38
+ base = BlockGiven.config.abi_path
39
+ path = File.join(base.to_s, path.to_s) if base && !File.absolute_path?(path.to_s)
40
+ raise AbiError, "ABI file not found: #{path}" unless File.file?(path)
41
+
42
+ abi(File.read(path))
43
+ end
44
+
45
+ # Default address for instances (can be overridden with .new(address: ...) / .at(...)).
46
+ def address(value = nil)
47
+ return @default_address if value.nil?
48
+
49
+ @default_address = Utils.checksum_address(value)
50
+ end
51
+ alias default_address address
52
+
53
+ def chain(value = nil)
54
+ return @chain if value.nil?
55
+
56
+ @chain = Chains.resolve(value)
57
+ end
58
+
59
+ def at(address, **options) = new(address: address, **options)
60
+
61
+ def functions = interface&.functions || []
62
+ def events = interface&.events || []
63
+ def errors = interface&.errors || []
64
+
65
+ def inherited(subclass)
66
+ super
67
+ subclass.instance_variable_set(:@interface, @interface)
68
+ subclass.instance_variable_set(:@default_address, @default_address)
69
+ subclass.instance_variable_set(:@chain, @chain)
70
+ subclass.send(:define_abi_methods!) if @interface
71
+ end
72
+
73
+ private
74
+
75
+ # One Ruby method per ABI function name (snake_case). Names clashing with
76
+ # existing methods (send, class, address...) are skipped: use #read / #write.
77
+ def define_abi_methods!
78
+ interface.function_names.each do |ruby_name|
79
+ if Contract.method_defined?(ruby_name) || Contract.private_method_defined?(ruby_name)
80
+ BlockGiven.config.logger.debug do
81
+ "[block_given] #{name}: skipping ##{ruby_name} (reserved), use read/write"
82
+ end
83
+ next
84
+ end
85
+
86
+ define_method(ruby_name) do |*args, tx: {}, **kwargs|
87
+ function = self.class.interface.function(ruby_name, args: args, kwargs: kwargs)
88
+ if function.read?
89
+ read(function, *args, tx: tx, **kwargs)
90
+ else
91
+ write(function, *args, tx: tx, **kwargs)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ attr_reader :address, :wallet
99
+
100
+ def initialize(address: nil, wallet: nil, client: nil, chain: nil)
101
+ raise AbiError, "#{self.class.name} has no ABI: declare it with `abi [...]` or `abi_file`" unless interface
102
+
103
+ resolved = address || self.class.default_address
104
+ raise InvalidArgumentError, "#{self.class.name}: address is required" if resolved.nil?
105
+
106
+ @address = Utils.checksum_address(resolved)
107
+ @wallet = wallet
108
+ @client = client
109
+ @chain = chain
110
+ end
111
+
112
+ def interface = self.class.interface
113
+
114
+ def client
115
+ @client ||= begin
116
+ chain = @chain || self.class.chain
117
+ if wallet && (chain.nil? || wallet.client.chain == Chains.resolve(chain))
118
+ wallet.client
119
+ elsif chain
120
+ Client.new(chain: chain)
121
+ else
122
+ BlockGiven.client
123
+ end
124
+ end
125
+ end
126
+
127
+ def chain = client.chain
128
+ def with_wallet(wallet) = self.class.new(address: address, wallet: wallet, client: @client, chain: @chain)
129
+
130
+ # --- Reads / writes -----------------------------------------------------
131
+
132
+ # eth_call + decode. Overrides: tx: { block:, from: }.
133
+ def read(name, *args, tx: {}, **kwargs)
134
+ function = resolve(name, args, kwargs)
135
+ data = function.encode(args, kwargs)
136
+ options = tx_options(tx)
137
+ raw = with_decoded_errors do
138
+ client.call(to: address, data: data, from: options[:from] || wallet&.address, block: options[:block] || :latest)
139
+ end
140
+ function.decode_output(raw)
141
+ end
142
+
143
+ # Signs and broadcasts. Returns a BlockGiven::Transaction. Overrides: tx: { value:, gas:, nonce:, fees... }.
144
+ def write(name, *args, tx: {}, **kwargs)
145
+ prepare_write(name, *args, tx: tx, **kwargs).broadcast
146
+ end
147
+
148
+ # Signs without broadcasting. Returns a BlockGiven::SignedTransaction whose #hash and
149
+ # #nonce are known before any network call: persist them, then call #broadcast. The signed
150
+ # transaction keeps this contract's ABI, so reverts raised by #broadcast are decoded too.
151
+ def prepare_write(name, *args, tx: {}, **kwargs)
152
+ function = resolve(name, args, kwargs)
153
+ unless wallet
154
+ raise WalletRequiredError,
155
+ "#{self.class.name}##{function.ruby_name} needs a wallet (pass wallet: to .new)"
156
+ end
157
+
158
+ options = tx_options(tx)
159
+ value = options[:value] || 0
160
+ if value != 0 && !function.payable?
161
+ raise InvalidArgumentError, "#{function.name} is not payable, cannot send value"
162
+ end
163
+
164
+ data = function.encode(args, kwargs)
165
+ with_decoded_errors do
166
+ wallet.signed_transaction(
167
+ to: address, data: data, value: value, gas: options[:gas], nonce: options[:nonce],
168
+ max_fee_per_gas: options[:max_fee_per_gas], max_priority_fee_per_gas: options[:max_priority_fee_per_gas],
169
+ gas_price: options[:gas_price]
170
+ ).with_interface(interface)
171
+ end
172
+ end
173
+
174
+ # Dry-runs a write with eth_call from the wallet address and returns the decoded
175
+ # result. Raises BlockGiven::ContractRevertError with the decoded reason on failure.
176
+ def simulate(name, *args, tx: {}, **kwargs)
177
+ function = resolve(name, args, kwargs)
178
+ options = tx_options(tx)
179
+ data = function.encode(args, kwargs)
180
+ raw = with_decoded_errors do
181
+ client.call(to: address, data: data, from: options[:from] || wallet&.address, value: options[:value],
182
+ gas: options[:gas], block: options[:block] || :latest)
183
+ end
184
+ function.decode_output(raw)
185
+ end
186
+
187
+ def estimate_gas(name, *args, tx: {}, **kwargs)
188
+ function = resolve(name, args, kwargs)
189
+ options = tx_options(tx)
190
+ data = function.encode(args, kwargs)
191
+ with_decoded_errors do
192
+ client.estimate_gas(to: address, data: data, from: options[:from] || wallet&.address, value: options[:value])
193
+ end
194
+ end
195
+
196
+ def encode_function_data(name, *args, **kwargs)
197
+ resolve(name, args, kwargs).encode(args, kwargs)
198
+ end
199
+
200
+ def decode_function_result(name, hex)
201
+ interface.function(name).decode_output(hex)
202
+ end
203
+
204
+ # --- Events -------------------------------------------------------------
205
+
206
+ # Fetches past events. `args` filters on indexed parameters.
207
+ # usdc.get_events(:Transfer, from_block: 18_000_000, to_block: :latest, args: { to: wallet.address })
208
+ # Pass max_block_range: to split a large range into several eth_getLogs calls.
209
+ def get_events(name = nil, from_block:, to_block: :latest, args: {}, max_block_range: nil)
210
+ topics = name ? interface.event(name).encode_topics(args) : nil
211
+ logs = if max_block_range
212
+ client.get_logs_in_chunks(address: address, topics: topics, from_block: from_block,
213
+ to_block: to_block, max_block_range: max_block_range)
214
+ else
215
+ client.get_logs(address: address, topics: topics, from_block: from_block, to_block: to_block)
216
+ end
217
+ decode_logs(logs)
218
+ end
219
+
220
+ # Polls for new events in a background thread. Returns a BlockGiven::Watcher.
221
+ # watcher = usdc.watch_event(:Transfer, args: { to: me }) { |event| puts event.args }
222
+ # watcher.stop
223
+ #
224
+ # Resuming after a restart: pass from_block: (your persisted cursor + 1) and persist the
225
+ # `to` block handed to on_progress after each processed range. confirmations: keeps the
226
+ # watcher N blocks behind the head so reorged logs are never delivered.
227
+ def watch_event(name = nil, args: {}, from_block: nil, polling_interval: nil, max_block_range: nil,
228
+ confirmations: 0, on_progress: nil, id: nil, &block)
229
+ raise ::ArgumentError, "a block is required" unless block
230
+
231
+ event = name && interface.event(name)
232
+ topics = event&.encode_topics(args)
233
+ label = "#{event ? event.name : '*'}@#{self.class.name || 'Contract'}(#{address[0, 10]})"
234
+ client.watch_logs(address: address, topics: topics, from_block: from_block,
235
+ polling_interval: polling_interval, max_block_range: max_block_range,
236
+ confirmations: confirmations, on_progress: on_progress, id: id, name: label) do |logs|
237
+ decode_logs(logs).each { |event| block.call(event) }
238
+ end
239
+ end
240
+ alias watch_events watch_event
241
+
242
+ # Decodes raw logs with this contract's ABI. Unknown topics are skipped.
243
+ def decode_logs(logs)
244
+ logs.filter_map do |log|
245
+ log = Normalizer.normalize(log) unless log.is_a?(Hash) && log.key?(:topics)
246
+ topic = Array(log[:topics]).first
247
+ event = topic && interface.event_by_topic(topic)
248
+ event&.decode(log)
249
+ end
250
+ end
251
+
252
+ # Events emitted by this contract in a receipt.
253
+ def events_from(receipt)
254
+ logs = receipt.respond_to?(:logs) ? receipt.logs : Array(receipt[:logs])
255
+ decode_logs(logs.select { |l| Utils.same_address?(l[:address], address) })
256
+ end
257
+
258
+ def explorer_url = chain.explorer_address_url(address)
259
+ def ==(other) = other.class == self.class && other.address == address
260
+ def inspect = "#<#{self.class.name} #{address}#{" wallet=#{wallet.address}" if wallet}>"
261
+
262
+ private
263
+
264
+ def resolve(name, args, kwargs)
265
+ name.is_a?(Abi::Function) ? name : interface.function(name, args: args, kwargs: kwargs)
266
+ end
267
+
268
+ def tx_options(tx)
269
+ raise InvalidArgumentError, "tx: must be a Hash" unless tx.is_a?(Hash)
270
+
271
+ options = tx.transform_keys(&:to_sym)
272
+ unknown = options.keys - TX_OPTIONS
273
+ unless unknown.empty?
274
+ raise InvalidArgumentError,
275
+ "unknown tx option(s): #{unknown.join(', ')} (allowed: #{TX_OPTIONS.join(', ')})"
276
+ end
277
+
278
+ options
279
+ end
280
+
281
+ def with_decoded_errors
282
+ yield
283
+ rescue ContractRevertError => e
284
+ raise e.decode_with(interface)
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # Base class for every error raised by BlockGiven.
5
+ class Error < StandardError; end
6
+
7
+ class ConfigurationError < Error; end
8
+ class InvalidArgumentError < Error; end
9
+ class InvalidAddressError < InvalidArgumentError; end
10
+ class WalletRequiredError < Error; end
11
+ class TimeoutError < Error; end
12
+
13
+ class AbiError < Error; end
14
+ class FunctionNotFoundError < AbiError; end
15
+ class EventNotFoundError < AbiError; end
16
+ class AmbiguousFunctionError < AbiError; end
17
+
18
+ # Transport-level failure (non-2xx HTTP status, connection refused, ...).
19
+ class HttpError < Error
20
+ attr_reader :status, :body
21
+
22
+ def initialize(message, status: nil, body: nil)
23
+ super(message)
24
+ @status = status
25
+ @body = body
26
+ end
27
+ end
28
+
29
+ # JSON-RPC error object returned by the node.
30
+ class RpcError < Error
31
+ attr_reader :code, :data, :rpc_method
32
+
33
+ def initialize(message, code: nil, data: nil, rpc_method: nil)
34
+ super(message)
35
+ @code = code
36
+ @data = data
37
+ @rpc_method = rpc_method
38
+ end
39
+
40
+ # Builds the most specific error for a JSON-RPC error payload. Reverts carry
41
+ # ABI-encoded data that we surface as a ContractRevertError.
42
+ def self.from_payload(error, rpc_method: nil)
43
+ error = { "message" => error.to_s } unless error.is_a?(Hash)
44
+ code = error["code"]
45
+ message = error["message"].to_s
46
+ data = error["data"]
47
+ revert_data = extract_revert_data(data)
48
+
49
+ if revert_data || message.match?(/revert/i)
50
+ ContractRevertError.new(message, code: code, data: data, rpc_method: rpc_method, revert_data: revert_data)
51
+ else
52
+ new(message, code: code, data: data, rpc_method: rpc_method)
53
+ end
54
+ end
55
+
56
+ # Providers disagree on where the revert bytes live: Alchemy/geth put them in
57
+ # `data`, Hardhat/Anvil nest them under `data.data`.
58
+ def self.extract_revert_data(data)
59
+ case data
60
+ when String then data.start_with?("0x") ? data : nil
61
+ when Hash then extract_revert_data(data["data"] || data[:data])
62
+ end
63
+ end
64
+ end
65
+
66
+ # eth_call / eth_estimateGas / eth_sendRawTransaction rejected by the EVM.
67
+ class ContractRevertError < RpcError
68
+ ERROR_STRING_SELECTOR = "0x08c379a0"
69
+ PANIC_SELECTOR = "0x4e487b71"
70
+ PANIC_REASONS = {
71
+ 0x00 => "generic compiler inserted panic",
72
+ 0x01 => "assertion failed",
73
+ 0x11 => "arithmetic overflow or underflow",
74
+ 0x12 => "division or modulo by zero",
75
+ 0x21 => "invalid enum conversion",
76
+ 0x22 => "incorrectly encoded storage byte array",
77
+ 0x31 => "pop() on an empty array",
78
+ 0x32 => "array index out of bounds",
79
+ 0x41 => "too much memory allocated or array too large",
80
+ 0x51 => "call to a zero-initialized variable of internal function type"
81
+ }.freeze
82
+
83
+ attr_reader :revert_data, :error_name, :args
84
+
85
+ def initialize(message, code: nil, data: nil, rpc_method: nil, revert_data: nil, error_name: nil, args: nil)
86
+ @revert_data = revert_data
87
+ @error_name = error_name
88
+ @args = args
89
+ super(build_message(message), code: code, data: data, rpc_method: rpc_method)
90
+ end
91
+
92
+ # Human readable revert reason when it can be decoded (Error(string) / Panic).
93
+ def reason
94
+ return @reason if defined?(@reason)
95
+
96
+ @reason = decode_builtin_reason
97
+ end
98
+
99
+ def selector
100
+ revert_data && revert_data.length >= 10 ? revert_data[0, 10] : nil
101
+ end
102
+
103
+ def custom_error?
104
+ !selector.nil? && ![ERROR_STRING_SELECTOR, PANIC_SELECTOR].include?(selector)
105
+ end
106
+
107
+ # Returns a copy of this error enriched with a custom error decoded from the
108
+ # given ABI interface, or self when the interface does not know the selector.
109
+ def decode_with(interface)
110
+ return self unless custom_error? && interface
111
+
112
+ custom = interface.error_by_selector(selector)
113
+ return self unless custom
114
+
115
+ decoded = custom.decode(revert_data)
116
+ self.class.new(
117
+ "#{custom.name}(#{decoded.values.map(&:inspect).join(', ')})",
118
+ code: code, data: data, rpc_method: rpc_method, revert_data: revert_data,
119
+ error_name: custom.name, args: decoded
120
+ )
121
+ end
122
+
123
+ private
124
+
125
+ def build_message(original)
126
+ reason = decode_builtin_reason
127
+ return "execution reverted: #{reason}" if reason && !original.include?(reason)
128
+
129
+ original.empty? ? "execution reverted" : original
130
+ end
131
+
132
+ def decode_builtin_reason
133
+ return nil unless revert_data && revert_data.length > 10
134
+
135
+ payload = "0x#{revert_data[10..]}"
136
+ case selector
137
+ when ERROR_STRING_SELECTOR
138
+ Eth::Abi.decode(["string"], payload).first
139
+ when PANIC_SELECTOR
140
+ panic_code = Eth::Abi.decode(["uint256"], payload).first
141
+ "Panic(0x#{panic_code.to_s(16).rjust(2, '0')}): #{PANIC_REASONS.fetch(panic_code, 'unknown panic')}"
142
+ end
143
+ rescue StandardError
144
+ nil
145
+ end
146
+ end
147
+
148
+ # Raised by Transaction#wait! when the mined receipt has a failed status.
149
+ class TransactionRevertedError < Error
150
+ attr_reader :receipt
151
+
152
+ def initialize(receipt)
153
+ @receipt = receipt
154
+ super("transaction #{receipt.transaction_hash} reverted in block #{receipt.block_number}")
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # A decoded event log.
5
+ #
6
+ # event.name # => "Transfer"
7
+ # event.args # => { from: "0x...", to: "0x...", value: 1000000 }
8
+ # event[:value] # => 1000000
9
+ # event.block_number # => 12345
10
+ class Event
11
+ attr_reader :name, :signature, :args, :log
12
+
13
+ def initialize(name:, signature:, args:, log:)
14
+ @name = name
15
+ @signature = signature
16
+ @args = args
17
+ @log = log
18
+ end
19
+
20
+ def [](key) = args[Utils.snake_case(key).to_sym]
21
+ def address = log[:address] && Utils.checksum_address(log[:address])
22
+ def block_number = log[:block_number]
23
+ def block_hash = log[:block_hash]
24
+ def transaction_hash = log[:transaction_hash]
25
+ def transaction_index = log[:transaction_index]
26
+ def log_index = log[:log_index]
27
+ def removed? = !!log[:removed]
28
+
29
+ def to_h
30
+ { name: name, args: args, address: address, block_number: block_number,
31
+ transaction_hash: transaction_hash, log_index: log_index }
32
+ end
33
+
34
+ def inspect = "#<BlockGiven::Event #{name} #{args.inspect} block=#{block_number}>"
35
+ end
36
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # Converts raw JSON-RPC objects (blocks, transactions, receipts, logs) into
5
+ # Ruby-friendly hashes: snake_case symbol keys, QUANTITY fields as Integers.
6
+ module Normalizer
7
+ module_function
8
+
9
+ QUANTITY_KEYS = %i[
10
+ number timestamp gas_used gas_limit base_fee_per_gas block_number transaction_index log_index
11
+ cumulative_gas_used effective_gas_price status value gas gas_price max_fee_per_gas
12
+ max_priority_fee_per_gas chain_id type difficulty total_difficulty size nonce blob_gas_used
13
+ blob_gas_price excess_blob_gas max_fee_per_blob_gas l1_fee l1_gas_used l1_gas_price l1_fee_scalar
14
+ deposit_nonce deposit_receipt_version
15
+ ].freeze
16
+
17
+ def normalize(value)
18
+ case value
19
+ when Hash then value.each_with_object({}) do |(k, v), h|
20
+ h[key = Utils.snake_case(k).to_sym] = normalize_field(key, v)
21
+ end
22
+ when Array then value.map { |v| normalize(v) }
23
+ else value
24
+ end
25
+ end
26
+
27
+ def normalize_field(key, value)
28
+ if QUANTITY_KEYS.include?(key) && Utils.hex?(value)
29
+ value == "0x" ? nil : Utils.hex_to_int(value)
30
+ elsif key == :topics && value.is_a?(Array)
31
+ value
32
+ else
33
+ normalize(value)
34
+ end
35
+ end
36
+ end
37
+ end