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,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
|