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,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module BlockGiven
6
+ # Blocking polling helper.
7
+ #
8
+ # receipt = BlockGiven::Poller.poll(interval: 2, timeout: 120) { client.get_transaction_receipt(hash) }
9
+ #
10
+ # The block is called until it returns a non-nil / non-false value, which is
11
+ # returned. Raises BlockGiven::TimeoutError when the timeout elapses.
12
+ module Poller
13
+ module_function
14
+
15
+ def poll(interval:, timeout: nil, description: "condition")
16
+ started = monotonic_now
17
+ attempt = 0
18
+ loop do
19
+ result = yield(attempt)
20
+ return result if result
21
+
22
+ attempt += 1
23
+ elapsed = monotonic_now - started
24
+ raise TimeoutError, "timed out after #{timeout}s waiting for #{description}" if timeout && elapsed >= timeout
25
+
26
+ remaining = timeout ? timeout - elapsed : interval
27
+ sleep([interval, remaining].min)
28
+ end
29
+ end
30
+
31
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ end
33
+
34
+ # Background polling loop running in its own thread. Returned by the
35
+ # `watch_*` helpers; call #stop (alias #unwatch) to end it.
36
+ #
37
+ # Every running watcher is registered under a unique id, so they can be listed
38
+ # and stopped even when the object reference was lost:
39
+ #
40
+ # watcher = client.watch_block_number(id: "blocks") { |n| puts n }
41
+ # BlockGiven.watchers # => [#<BlockGiven::Watcher blocks ...>]
42
+ # BlockGiven::Watcher.find("blocks").stop
43
+ # BlockGiven::Watcher.stop_all
44
+ class Watcher
45
+ @registry = {}
46
+ @registry_mutex = Mutex.new
47
+
48
+ class << self
49
+ # Running (or stopping) watchers, oldest first.
50
+ def all
51
+ @registry_mutex.synchronize { @registry.values.dup }
52
+ end
53
+
54
+ def find(id)
55
+ @registry_mutex.synchronize { @registry[id.to_s] }
56
+ end
57
+
58
+ def find!(id)
59
+ find(id) || raise(InvalidArgumentError, "no running watcher with id #{id.inspect} (running: #{ids.join(', ')})")
60
+ end
61
+
62
+ def ids = all.map(&:id)
63
+
64
+ # Graceful stop by id. Returns the watcher, or nil if unknown.
65
+ def stop(id, join: nil)
66
+ watcher = find(id)
67
+ watcher&.stop&.join(join)
68
+ end
69
+
70
+ def kill(id) = find(id)&.kill
71
+
72
+ def stop_all(join: nil)
73
+ watchers = all
74
+ watchers.each(&:stop)
75
+ watchers.each { |w| w.join(join) }
76
+ watchers
77
+ end
78
+
79
+ def kill_all = all.each(&:kill)
80
+
81
+ # @api private
82
+ def register(watcher)
83
+ @registry_mutex.synchronize do
84
+ if (existing = @registry[watcher.id]) && !existing.equal?(watcher)
85
+ raise InvalidArgumentError, "a watcher with id #{watcher.id.inspect} is already running"
86
+ end
87
+
88
+ @registry[watcher.id] = watcher
89
+ end
90
+ end
91
+
92
+ # @api private
93
+ def unregister(watcher)
94
+ @registry_mutex.synchronize { @registry.delete(watcher.id) if @registry[watcher.id].equal?(watcher) }
95
+ end
96
+
97
+ def generate_id(name) = "#{name.to_s.gsub(/[^a-zA-Z0-9_.:@-]/, '_')}-#{SecureRandom.hex(3)}"
98
+ end
99
+
100
+ attr_reader :id, :interval, :name, :started_at, :ticks, :last_error, :last_error_at, :last_tick_at
101
+ # Last fully processed position (block number for log watchers), set by the tick.
102
+ attr_accessor :cursor
103
+
104
+ def initialize(interval:, name: "watcher", id: nil, logger: nil, on_error: nil, &tick)
105
+ raise ::ArgumentError, "a block is required" unless tick
106
+
107
+ @interval = interval
108
+ @name = name.to_s
109
+ @id = (id || self.class.generate_id(@name)).to_s
110
+ @logger = logger
111
+ @on_error = on_error
112
+ @tick = tick
113
+ @mutex = Mutex.new
114
+ @cond = ConditionVariable.new
115
+ @stopped = false
116
+ @thread = nil
117
+ @ticks = 0
118
+ @started_at = nil
119
+ @last_tick_at = nil
120
+ @last_error = nil
121
+ @last_error_at = nil
122
+ end
123
+
124
+ def start
125
+ return self if running?
126
+
127
+ self.class.register(self)
128
+ @stopped = false
129
+ @started_at = Time.now
130
+ @thread = Thread.new { run }
131
+ @thread.name = "block_given:#{id}"
132
+ @thread.report_on_exception = false
133
+ self
134
+ end
135
+
136
+ # Graceful: the current tick finishes, then the thread exits.
137
+ def stop
138
+ @mutex.synchronize do
139
+ @stopped = true
140
+ @cond.broadcast
141
+ end
142
+ self
143
+ end
144
+ alias unwatch stop
145
+
146
+ # Forceful: kills the thread even in the middle of a tick (use when stop does not return).
147
+ def kill
148
+ stop
149
+ @thread&.kill
150
+ self.class.unregister(self)
151
+ self
152
+ end
153
+
154
+ def running? = !!@thread&.alive?
155
+ def stopped? = @stopped
156
+
157
+ def status
158
+ return :idle if @thread.nil?
159
+ return :running if running? && !stopped?
160
+ return :stopping if running?
161
+
162
+ :stopped
163
+ end
164
+
165
+ def join(timeout = nil)
166
+ @thread&.join(timeout)
167
+ self
168
+ end
169
+
170
+ # Replace the error handler. Without a handler errors are logged and polling continues.
171
+ def on_error(&handler)
172
+ @on_error = handler
173
+ self
174
+ end
175
+
176
+ def uptime = started_at ? Time.now - started_at : 0
177
+
178
+ def to_h
179
+ {
180
+ id: id, name: name, status: status, interval: interval, cursor: cursor, ticks: ticks,
181
+ started_at: started_at, last_tick_at: last_tick_at,
182
+ last_error: last_error && "#{last_error.class}: #{last_error.message}", last_error_at: last_error_at,
183
+ thread: @thread&.name
184
+ }
185
+ end
186
+
187
+ def inspect
188
+ error = last_error ? " last_error=#{last_error.class}" : ""
189
+ "#<BlockGiven::Watcher #{id} #{name} #{status} cursor=#{cursor.inspect} ticks=#{ticks}#{error}>"
190
+ end
191
+
192
+ private
193
+
194
+ def run
195
+ until stopped?
196
+ begin
197
+ @tick.call(self)
198
+ rescue StandardError => e
199
+ handle_error(e)
200
+ ensure
201
+ @ticks += 1
202
+ @last_tick_at = Time.now
203
+ end
204
+ wait_interval
205
+ end
206
+ ensure
207
+ self.class.unregister(self)
208
+ end
209
+
210
+ def wait_interval
211
+ @mutex.synchronize { @cond.wait(@mutex, interval) unless @stopped }
212
+ end
213
+
214
+ def handle_error(error)
215
+ @last_error = error
216
+ @last_error_at = Time.now
217
+ if @on_error
218
+ @on_error.call(error, self)
219
+ else
220
+ logger.warn { "[block_given] watcher #{id}: #{error.class}: #{error.message}" }
221
+ end
222
+ end
223
+
224
+ def logger = @logger || BlockGiven.config.logger
225
+ end
226
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # Loaded automatically inside a Rails app (Rails 7.0+). Once the app has booted,
5
+ # BlockGiven logs through Rails.logger unless an initializer configured another logger.
6
+ #
7
+ # # config/initializers/block_given.rb
8
+ # BlockGiven.configure do |c|
9
+ # c.connector = BlockGiven::Connectors::Alchemy.new(api_key: Rails.application.credentials.alchemy_api_key)
10
+ # c.chain = Rails.env.production? ? :base : :base_sepolia
11
+ # end
12
+ class Railtie < Rails::Railtie
13
+ config.after_initialize do
14
+ BlockGiven.config.logger = Rails.logger if Rails.logger && !BlockGiven.config.logger_configured?
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # Transaction receipt with symbolized, integer-decoded fields.
5
+ class Receipt
6
+ attr_reader :to_h
7
+
8
+ def initialize(raw)
9
+ @to_h = raw.is_a?(Receipt) ? raw.to_h : Normalizer.normalize(raw)
10
+ end
11
+
12
+ def transaction_hash = to_h[:transaction_hash]
13
+ def block_number = to_h[:block_number]
14
+ def block_hash = to_h[:block_hash]
15
+ def from = to_h[:from]
16
+ def to = to_h[:to]
17
+ def contract_address = to_h[:contract_address]
18
+ def gas_used = to_h[:gas_used]
19
+ def effective_gas_price = to_h[:effective_gas_price]
20
+ def cumulative_gas_used = to_h[:cumulative_gas_used]
21
+ def transaction_index = to_h[:transaction_index]
22
+ def logs = to_h[:logs] || []
23
+
24
+ # :success / :reverted (pre-Byzantium receipts have no status: treated as success).
25
+ def status
26
+ return :success if to_h[:status].nil?
27
+
28
+ to_h[:status] == 1 ? :success : :reverted
29
+ end
30
+
31
+ def success? = status == :success
32
+ def reverted? = status == :reverted
33
+
34
+ # Total fee paid in wei.
35
+ def fee = gas_used && effective_gas_price ? gas_used * effective_gas_price : nil
36
+
37
+ def [](key) = to_h[key.to_sym]
38
+
39
+ def inspect
40
+ "#<BlockGiven::Receipt #{transaction_hash} status=#{status} block=#{block_number} " \
41
+ "gas_used=#{gas_used} logs=#{logs.size}>"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # A signed transaction that has not been broadcast yet. Its hash is derived
5
+ # from the signed bytes, so it is known before any network call: persist
6
+ # `hash` and `nonce`, then `broadcast`. Whatever happens to the RPC call, the
7
+ # transaction can be found again with `client.transaction(hash)`.
8
+ #
9
+ # signed = registry.prepare_write(:record, movement_id, tx: { nonce: call.nonce })
10
+ # call.update!(tx_hash: signed.hash, nonce: signed.nonce, status: :submitted)
11
+ # signed.broadcast # => BlockGiven::Transaction
12
+ #
13
+ # signed.replacement.broadcast # same nonce, fees bumped by 12.5%
14
+ #
15
+ # `hash` is the transaction hash (a String, like Transaction#hash), so instances
16
+ # are not usable as Hash keys; compare them with ==.
17
+ class SignedTransaction
18
+ # Nodes reject a same-nonce replacement whose fees are not at least 10% higher.
19
+ MIN_REPLACEMENT_MULTIPLIER = 1.1
20
+ DEFAULT_REPLACEMENT_MULTIPLIER = 1.125
21
+
22
+ # Rebuilds a SignedTransaction from persisted raw bytes (after a restart, typically), so it
23
+ # can be broadcast again or replaced. The bytes must have been signed by `wallet`.
24
+ def self.from_raw(raw, wallet:, interface: nil)
25
+ decoded = begin
26
+ Eth::Tx.decode(Utils.prefix_hex(raw))
27
+ rescue StandardError => e
28
+ raise InvalidArgumentError, "cannot decode signed transaction: #{e.message}"
29
+ end
30
+ sender = Utils.checksum_address(Utils.prefix_hex(decoded.sender)) # eth returns unprefixed hex
31
+ unless Utils.same_address?(sender, wallet.address)
32
+ raise InvalidArgumentError, "signed transaction was sent by #{sender}, not by wallet #{wallet.address}"
33
+ end
34
+
35
+ new(raw: raw, params: params_from(decoded, sender), wallet: wallet, interface: interface)
36
+ end
37
+
38
+ def self.params_from(decoded, sender)
39
+ destination = decoded.destination.to_s
40
+ params = {
41
+ from: sender, to: destination.empty? ? nil : Utils.checksum_address(Utils.prefix_hex(destination)),
42
+ value: decoded.amount, data: decoded.payload.to_s.empty? ? "" : Utils.bin_to_hex(decoded.payload),
43
+ chain_id: decoded.chain_id, nonce: decoded.signer_nonce, gas: decoded.gas_limit
44
+ }
45
+ # Same shape as Wallet#prepare_transaction (access_list nil when empty).
46
+ if decoded.respond_to?(:max_fee_per_gas)
47
+ access_list = decoded.access_list
48
+ params.merge(max_fee_per_gas: decoded.max_fee_per_gas,
49
+ max_priority_fee_per_gas: decoded.max_priority_fee_per_gas,
50
+ access_list: access_list.nil? || access_list.empty? ? nil : access_list)
51
+ else
52
+ params.merge(gas_price: decoded.gas_price, access_list: nil)
53
+ end
54
+ end
55
+ private_class_method :params_from
56
+
57
+ attr_reader :raw, :hash, :params, :wallet, :interface
58
+
59
+ # interface: an Abi::Interface used to name custom errors when the node rejects the
60
+ # broadcast with revert data (set by Contract#prepare_write).
61
+ def initialize(raw:, params:, wallet:, interface: nil)
62
+ @raw = Utils.prefix_hex(raw)
63
+ @params = params.freeze
64
+ @wallet = wallet
65
+ @interface = interface
66
+ @hash = Utils.keccak256(@raw)
67
+ end
68
+
69
+ def with_interface(interface) = self.class.new(raw: raw, params: params, wallet: wallet, interface: interface)
70
+
71
+ def client = wallet.client
72
+
73
+ def from = params[:from]
74
+ def to = params[:to]
75
+ def value = params[:value]
76
+ def data = params[:data]
77
+ def nonce = params[:nonce]
78
+ def gas = params[:gas]
79
+ def chain_id = params[:chain_id]
80
+ def max_fee_per_gas = params[:max_fee_per_gas]
81
+ def max_priority_fee_per_gas = params[:max_priority_fee_per_gas]
82
+ def gas_price = params[:gas_price]
83
+ def legacy? = !gas_price.nil?
84
+
85
+ # eth_sendRawTransaction. Returns a Transaction carrying the locally computed hash.
86
+ def broadcast
87
+ sent = with_decoded_errors { client.send_raw_transaction(raw) }
88
+ unless sent.hash.to_s.casecmp?(hash)
89
+ client.logger.warn do
90
+ "[block_given] node returned #{sent.hash} for signed transaction #{hash}"
91
+ end
92
+ end
93
+ transaction
94
+ end
95
+ alias submit broadcast
96
+
97
+ # The Transaction handle for this hash, without broadcasting (status, receipt...).
98
+ def transaction = client.transaction(hash)
99
+
100
+ # Re-signs the same payload with the same nonce and higher fees, to replace a
101
+ # transaction stuck in the mempool. Fees are the max of (current fees x
102
+ # fee_multiplier) and a fresh estimate from the node, so the replacement also
103
+ # catches up with the market. Both transactions share a nonce: only one can be mined.
104
+ def replacement(fee_multiplier: DEFAULT_REPLACEMENT_MULTIPLIER)
105
+ if fee_multiplier < MIN_REPLACEMENT_MULTIPLIER
106
+ raise InvalidArgumentError, "fee_multiplier must be >= #{MIN_REPLACEMENT_MULTIPLIER} (got #{fee_multiplier})"
107
+ end
108
+ raise InvalidArgumentError, "cannot replace a transaction without fee params (use .from_raw)" unless fees?
109
+
110
+ wallet.signed_transaction(
111
+ to: to, value: value, data: data, gas: gas, nonce: nonce, chain_id: chain_id,
112
+ access_list: params[:access_list], **with_decoded_errors { bumped_fees(fee_multiplier) }
113
+ ).with_interface(interface)
114
+ end
115
+
116
+ def to_h = params.merge(hash: hash, raw: raw)
117
+
118
+ def to_s = hash
119
+ def ==(other) = other.is_a?(SignedTransaction) && other.raw == raw
120
+ def inspect = "#<BlockGiven::SignedTransaction #{hash} nonce=#{nonce} to=#{to}>"
121
+
122
+ private
123
+
124
+ def fees? = legacy? || (max_fee_per_gas && max_priority_fee_per_gas)
125
+
126
+ def bumped_fees(multiplier)
127
+ if legacy?
128
+ { gas_price: [bump(gas_price, multiplier), client.gas_price].max }
129
+ else
130
+ estimated = client.estimate_fees_per_gas
131
+ priority = [bump(max_priority_fee_per_gas, multiplier), estimated[:max_priority_fee_per_gas]].max
132
+ max_fee = [bump(max_fee_per_gas, multiplier), estimated[:max_fee_per_gas], priority].max
133
+ { max_fee_per_gas: max_fee, max_priority_fee_per_gas: priority }
134
+ end
135
+ end
136
+
137
+ def bump(fee, multiplier) = (fee * multiplier).ceil
138
+
139
+ def with_decoded_errors
140
+ yield
141
+ rescue ContractRevertError => e
142
+ raise interface ? e.decode_with(interface) : e
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ # A broadcast transaction identified by its hash. Wraps receipt polling.
5
+ #
6
+ # tx = usdc.transfer(to: "0x...", amount: 1e6)
7
+ # tx.hash # => "0x..."
8
+ # receipt = tx.wait # polls until mined (see BlockGiven.config.timeout / polling_interval)
9
+ # tx.wait! # same, but raises BlockGiven::TransactionRevertedError on failure
10
+ class Transaction
11
+ attr_reader :hash, :client
12
+
13
+ def initialize(hash, client:)
14
+ @hash = hash
15
+ @client = client
16
+ @receipt = nil
17
+ end
18
+
19
+ # Non-blocking: the receipt if the transaction is mined, nil otherwise. Cached once
20
+ # found: call #reload (or build a fresh handle with client.transaction) to re-query the
21
+ # node, e.g. on every tick of a long-lived worker.
22
+ def receipt
23
+ @receipt ||= client.get_transaction_receipt(hash)
24
+ end
25
+
26
+ def reload
27
+ @receipt = nil
28
+ self
29
+ end
30
+
31
+ def mined? = !receipt.nil?
32
+
33
+ # One RPC round trip to classify the transaction (two while it is not mined):
34
+ # :success / :reverted mined, from the receipt status
35
+ # :pending known by the node, waiting in the mempool
36
+ # :unknown the node has never seen it, or dropped it: safe to re-broadcast
37
+ # the same signed bytes (see SignedTransaction#replacement)
38
+ def status
39
+ return receipt.status if mined?
40
+
41
+ details ? :pending : :unknown
42
+ end
43
+
44
+ def success? = mined? && receipt.success?
45
+ def reverted? = mined? && receipt.reverted?
46
+ def pending? = status == :pending
47
+ def unknown? = status == :unknown
48
+
49
+ # Blocks since inclusion, 1 when mined in the latest block, 0 while not mined.
50
+ def confirmations
51
+ return 0 unless mined?
52
+
53
+ [client.block_number - receipt.block_number + 1, 0].max
54
+ end
55
+
56
+ # Non-blocking counterpart of wait(confirmations:). Defaults to BlockGiven.config.confirmations.
57
+ def confirmed?(count = nil) = confirmations >= (count || BlockGiven.config.confirmations)
58
+
59
+ def wait(confirmations: nil, timeout: nil, polling_interval: nil)
60
+ @receipt = client.wait_for_transaction_receipt(
61
+ hash, confirmations: confirmations, timeout: timeout, polling_interval: polling_interval
62
+ )
63
+ end
64
+
65
+ def wait!(**options)
66
+ receipt = wait(**options)
67
+ raise TransactionRevertedError, receipt if receipt.reverted?
68
+
69
+ receipt
70
+ end
71
+
72
+ # Raw transaction object from the node (nil while it is not yet known).
73
+ def details = client.get_transaction(hash)
74
+
75
+ def explorer_url = client.chain&.explorer_tx_url(hash)
76
+
77
+ def to_s = hash
78
+ def ==(other) = other.is_a?(Transaction) && other.hash == hash
79
+ def inspect = "#<BlockGiven::Transaction #{hash}#{mined_flag}>"
80
+
81
+ private
82
+
83
+ def mined_flag = @receipt ? " mined block=#{@receipt.block_number} status=#{@receipt.status}" : ""
84
+ end
85
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
6
+ module BlockGiven
7
+ # Stateless helpers, mirroring viem's `utils` (parseUnits, formatUnits, keccak256, ...).
8
+ module Utils
9
+ module_function
10
+
11
+ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
12
+ BLOCK_TAGS = %w[latest earliest pending safe finalized].freeze
13
+
14
+ def hex?(value)
15
+ value.is_a?(String) && value.match?(/\A0x[0-9a-fA-F]*\z/)
16
+ end
17
+
18
+ def prefix_hex(hex)
19
+ hex.start_with?("0x") ? hex : "0x#{hex}"
20
+ end
21
+
22
+ def strip_hex(hex)
23
+ hex.start_with?("0x") ? hex[2..] : hex
24
+ end
25
+
26
+ # Integer -> "0x1a" (no leading zeros, as JSON-RPC QUANTITY expects).
27
+ def to_hex(value)
28
+ case value
29
+ when Integer then "0x#{value.to_s(16)}"
30
+ when String then prefix_hex(value)
31
+ else raise InvalidArgumentError, "cannot convert #{value.inspect} to hex"
32
+ end
33
+ end
34
+
35
+ def hex_to_int(hex)
36
+ return nil if hex.nil?
37
+ return hex if hex.is_a?(Integer)
38
+
39
+ Integer(strip_hex(hex), 16)
40
+ end
41
+
42
+ def hex_to_bin(hex)
43
+ [strip_hex(hex)].pack("H*")
44
+ end
45
+
46
+ def bin_to_hex(bin)
47
+ "0x#{bin.unpack1('H*')}"
48
+ end
49
+
50
+ # Left-pads a hex value to 32 bytes (used for event topics).
51
+ def pad_hex(hex, bytes: 32)
52
+ "0x#{strip_hex(hex).rjust(bytes * 2, '0')}"
53
+ end
54
+
55
+ # keccak256 of raw bytes (or of the bytes represented by a 0x hex string).
56
+ def keccak256(data)
57
+ bytes = hex?(data) ? hex_to_bin(data) : data.to_s
58
+ bin_to_hex(Eth::Util.keccak256(bytes))
59
+ end
60
+
61
+ def address?(value)
62
+ value.is_a?(String) && value.match?(/\A0x[0-9a-fA-F]{40}\z/)
63
+ end
64
+
65
+ def checksum_address(value)
66
+ value = value.address if value.respond_to?(:address) && !value.is_a?(String)
67
+ raise InvalidAddressError, "invalid address: #{value.inspect}" unless address?(value)
68
+
69
+ Eth::Address.new(value).checksummed
70
+ end
71
+
72
+ def same_address?(a, b)
73
+ a.to_s.downcase == b.to_s.downcase
74
+ end
75
+
76
+ # "1.5", 6 -> 1_500_000. Accepts String, Integer, Float, Rational, BigDecimal.
77
+ def parse_units(value, decimals)
78
+ decimal = to_decimal(value)
79
+ scaled = decimal * (BigDecimal(10)**decimals)
80
+ raise InvalidArgumentError, "#{value} has more than #{decimals} decimals" unless scaled.frac.zero?
81
+
82
+ scaled.to_i
83
+ end
84
+
85
+ # 1_500_000, 6 -> "1.5"
86
+ def format_units(value, decimals)
87
+ decimal = BigDecimal(value.to_i) / (BigDecimal(10)**decimals)
88
+ str = decimal.to_s("F")
89
+ str = str.sub(/\.?0+\z/, "") if str.include?(".")
90
+ str
91
+ end
92
+
93
+ def parse_ether(value) = parse_units(value, 18)
94
+ def format_ether(value) = format_units(value, 18)
95
+ def parse_gwei(value) = parse_units(value, 9)
96
+ def format_gwei(value) = format_units(value, 9)
97
+
98
+ def to_decimal(value)
99
+ case value
100
+ when BigDecimal then value
101
+ when Integer then BigDecimal(value)
102
+ when Float then BigDecimal(value.to_s)
103
+ when Rational then BigDecimal(value, 40)
104
+ when String then BigDecimal(value.strip)
105
+ else raise InvalidArgumentError, "cannot convert #{value.inspect} to a decimal"
106
+ end
107
+ rescue ::ArgumentError
108
+ raise InvalidArgumentError, "cannot convert #{value.inspect} to a decimal"
109
+ end
110
+
111
+ # Accepts an Integer, a hex QUANTITY or a block tag (:latest, "pending", ...).
112
+ def block_tag(value)
113
+ case value
114
+ when nil then "latest"
115
+ when Integer then to_hex(value)
116
+ when Symbol then block_tag(value.to_s)
117
+ when String
118
+ return value if hex?(value) || BLOCK_TAGS.include?(value)
119
+
120
+ raise InvalidArgumentError, "invalid block tag: #{value.inspect}"
121
+ else raise InvalidArgumentError, "invalid block: #{value.inspect}"
122
+ end
123
+ end
124
+
125
+ def snake_case(name)
126
+ name.to_s
127
+ .sub(/\A_+/, "")
128
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
129
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
130
+ .tr("-", "_")
131
+ .downcase
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ VERSION = "0.1.0"
5
+ end