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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/README.md +92 -55
- data/lib/block_given/abi/codec/decoder.rb +111 -0
- data/lib/block_given/abi/codec.rb +170 -0
- data/lib/block_given/abi/coder.rb +122 -11
- data/lib/block_given/abi/custom_error.rb +33 -1
- data/lib/block_given/abi/event.rb +85 -3
- data/lib/block_given/abi/function.rb +94 -2
- data/lib/block_given/abi/interface.rb +99 -4
- data/lib/block_given/abi/parameter.rb +60 -3
- data/lib/block_given/abi/standards/erc1155.rb +41 -0
- data/lib/block_given/abi/standards/erc20.rb +33 -0
- data/lib/block_given/abi/standards/erc4626.rb +41 -0
- data/lib/block_given/abi/standards/erc721.rb +47 -0
- data/lib/block_given/abi/standards.rb +124 -0
- data/lib/block_given/abi/type.rb +138 -0
- data/lib/block_given/chain.rb +114 -2
- data/lib/block_given/client.rb +322 -20
- data/lib/block_given/configuration.rb +85 -3
- data/lib/block_given/connectors/alchemy.rb +38 -4
- data/lib/block_given/connectors/base.rb +32 -5
- data/lib/block_given/connectors/http.rb +91 -5
- data/lib/block_given/connectors/stub.rb +70 -4
- data/lib/block_given/contract.rb +422 -26
- data/lib/block_given/crypto/keccak.rb +152 -0
- data/lib/block_given/crypto/secp256k1.rb +168 -0
- data/lib/block_given/crypto.rb +22 -0
- data/lib/block_given/eip712.rb +199 -0
- data/lib/block_given/errors.rb +134 -14
- data/lib/block_given/event.rb +51 -1
- data/lib/block_given/normalizer.rb +27 -2
- data/lib/block_given/poller.rb +177 -15
- data/lib/block_given/receipt.rb +63 -3
- data/lib/block_given/rlp.rb +146 -0
- data/lib/block_given/signed_transaction.rb +148 -47
- data/lib/block_given/transaction.rb +103 -12
- data/lib/block_given/transaction_envelope/fields.rb +104 -0
- data/lib/block_given/transaction_envelope.rb +183 -0
- data/lib/block_given/utils.rb +143 -9
- data/lib/block_given/version.rb +2 -1
- data/lib/block_given/wallet.rb +216 -35
- data/lib/block_given.rb +67 -4
- metadata +19 -23
|
@@ -1,63 +1,91 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module BlockGiven
|
|
4
|
-
# A signed transaction that has not been broadcast yet.
|
|
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)`.
|
|
4
|
+
# A signed transaction that has not been broadcast yet.
|
|
8
5
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
6
|
+
# The transaction hash is the keccak-256 of the signed RLP bytes, so {#hash} and {#nonce} are known before
|
|
7
|
+
# any network call: persist them (or {#raw} / {#to_h}), then {#broadcast}. Whatever happens to the RPC call,
|
|
8
|
+
# the transaction can be found again with `client.transaction(hash)` or {#transaction}.
|
|
9
|
+
#
|
|
10
|
+
# Lifecycle:
|
|
11
|
+
#
|
|
12
|
+
# 1. {Wallet#signed_transaction} (or `Contract#prepare_write`) signs and returns an instance.
|
|
13
|
+
# 2. {#broadcast} sends the bytes with `eth_sendRawTransaction` and returns a {Transaction} carrying the local
|
|
14
|
+
# hash (a warning is logged if the node answers with a different hash).
|
|
15
|
+
# 3. If the transaction is stuck in the mempool, {#replacement} re-signs the same payload with the same nonce
|
|
16
|
+
# and higher fees; only one of the two can ever be mined.
|
|
17
|
+
# 4. After a restart, {.from_raw} rebuilds an instance from the persisted bytes so steps 2 and 3 still work.
|
|
12
18
|
#
|
|
13
|
-
#
|
|
19
|
+
# `hash` is the transaction hash (a String, like {Transaction#hash}), not Ruby's Object#hash: instances are
|
|
20
|
+
# not usable as Hash keys or Set members; compare them with {#==}.
|
|
14
21
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
22
|
+
# @example Sign, persist, broadcast, then replace when stuck
|
|
23
|
+
# signed = registry.prepare_write(:record, movement_id, tx: { nonce: call.nonce })
|
|
24
|
+
# call.update!(tx_hash: signed.hash, nonce: signed.nonce, raw_tx: signed.raw, status: :submitted)
|
|
25
|
+
# tx = signed.broadcast # => BlockGiven::Transaction (same hash)
|
|
26
|
+
#
|
|
27
|
+
# signed.replacement.broadcast if tx.pending? # same nonce, fees bumped by 12.5%
|
|
17
28
|
class SignedTransaction
|
|
18
|
-
# Nodes reject a same-nonce replacement whose fees are not at least 10% higher.
|
|
29
|
+
# Nodes reject a same-nonce replacement whose fees are not at least 10% higher than the original's.
|
|
19
30
|
MIN_REPLACEMENT_MULTIPLIER = 1.1
|
|
31
|
+
# Fee multiplier used by {#replacement} when none is given (12.5% bump, geth's default replacement rule).
|
|
20
32
|
DEFAULT_REPLACEMENT_MULTIPLIER = 1.125
|
|
21
33
|
|
|
22
|
-
# Rebuilds a
|
|
23
|
-
#
|
|
34
|
+
# Rebuilds a signed transaction from persisted raw bytes, typically after a process restart, so it can be
|
|
35
|
+
# broadcast again ({#broadcast}) or replaced ({#replacement}).
|
|
36
|
+
#
|
|
37
|
+
# Both EIP-1559 (type 2) and legacy (type 0) transactions are supported and the recovered sender must be
|
|
38
|
+
# `wallet`'s address, since only that wallet can sign a replacement. A non-empty access list comes back as
|
|
39
|
+
# `[{ address:, storage_keys: }]`.
|
|
40
|
+
#
|
|
41
|
+
# @param raw [String] the signed transaction bytes as hex (with or without `0x`), as returned by {#raw}
|
|
42
|
+
# @param wallet [Wallet] the wallet that signed the bytes; used by {#replacement} to re-sign
|
|
43
|
+
# @param interface [Abi::Interface, nil] ABI used to decode custom errors when the node rejects the
|
|
44
|
+
# broadcast with revert data (typically `contract.interface`)
|
|
45
|
+
# @return [SignedTransaction] the rebuilt transaction; its {#params} mirror {Wallet#prepare_transaction}
|
|
46
|
+
# @raise [BlockGiven::InvalidArgumentError] when the bytes cannot be decoded, or when they were signed by
|
|
47
|
+
# another address than `wallet.address`
|
|
48
|
+
# @example
|
|
49
|
+
# signed = BlockGiven::SignedTransaction.from_raw(payout.raw_tx, wallet: wallet, interface: usdc.interface)
|
|
50
|
+
# signed.hash == payout.tx_hash # => true
|
|
51
|
+
# signed.transaction.status # => :pending
|
|
24
52
|
def self.from_raw(raw, wallet:, interface: nil)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
rescue
|
|
53
|
+
params = begin
|
|
54
|
+
TransactionEnvelope.decode(raw)
|
|
55
|
+
rescue InvalidArgumentError => e
|
|
28
56
|
raise InvalidArgumentError, "cannot decode signed transaction: #{e.message}"
|
|
29
57
|
end
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
raise InvalidArgumentError, "signed transaction was sent by #{sender}, not by wallet #{wallet.address}"
|
|
58
|
+
unless Utils.same_address?(params[:from], wallet.address)
|
|
59
|
+
raise InvalidArgumentError, "signed transaction was sent by #{params[:from]}, not by wallet #{wallet.address}"
|
|
33
60
|
end
|
|
34
61
|
|
|
35
|
-
new(raw: raw, params:
|
|
62
|
+
new(raw: raw, params: params, wallet: wallet, interface: interface)
|
|
36
63
|
end
|
|
37
64
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
params.merge(gas_price: decoded.gas_price, access_list: nil)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
private_class_method :params_from
|
|
56
|
-
|
|
65
|
+
# @!attribute [r] raw
|
|
66
|
+
# @return [String] the RLP-encoded signed transaction as `0x`-prefixed hex, as sent to
|
|
67
|
+
# `eth_sendRawTransaction`
|
|
68
|
+
# @!attribute [r] hash
|
|
69
|
+
# @return [String] the transaction hash (`0x` hex, keccak-256 of {#raw}), identical to the hash the node
|
|
70
|
+
# will report once broadcast. A String, so this is not Ruby's Object#hash
|
|
71
|
+
# @!attribute [r] params
|
|
72
|
+
# @return [Hash{Symbol => Object}] the frozen resolved parameters the bytes were signed from, in the shape
|
|
73
|
+
# returned by {Wallet#prepare_transaction} (`:from`, `:to`, `:value`, `:data`, `:chain_id`, `:nonce`,
|
|
74
|
+
# `:gas`, `:access_list`, plus `:gas_price` or the two EIP-1559 fee fields)
|
|
75
|
+
# @!attribute [r] wallet
|
|
76
|
+
# @return [Wallet] the wallet that signed the bytes and that {#replacement} re-signs with
|
|
77
|
+
# @!attribute [r] interface
|
|
78
|
+
# @return [Abi::Interface, nil] ABI used to name custom errors on revert, or nil
|
|
57
79
|
attr_reader :raw, :hash, :params, :wallet, :interface
|
|
58
80
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
81
|
+
# Wraps already signed bytes. Applications normally get instances from {Wallet#signed_transaction},
|
|
82
|
+
# `Contract#prepare_write` or {.from_raw} rather than calling this directly.
|
|
83
|
+
#
|
|
84
|
+
# @param raw [String] the signed transaction bytes as hex (with or without `0x`)
|
|
85
|
+
# @param params [Hash{Symbol => Object}] the resolved parameters the bytes were signed from (frozen here)
|
|
86
|
+
# @param wallet [Wallet] the signing wallet
|
|
87
|
+
# @param interface [Abi::Interface, nil] ABI used to name custom errors when the node rejects the
|
|
88
|
+
# broadcast with revert data (set by `Contract#prepare_write`)
|
|
61
89
|
def initialize(raw:, params:, wallet:, interface: nil)
|
|
62
90
|
@raw = Utils.prefix_hex(raw)
|
|
63
91
|
@params = params.freeze
|
|
@@ -66,23 +94,64 @@ module BlockGiven
|
|
|
66
94
|
@hash = Utils.keccak256(@raw)
|
|
67
95
|
end
|
|
68
96
|
|
|
97
|
+
# Returns a copy of this signed transaction that decodes custom errors with the given ABI.
|
|
98
|
+
#
|
|
99
|
+
# @param interface [Abi::Interface, nil] the ABI to use for revert decoding
|
|
100
|
+
# @return [SignedTransaction] a new instance with the same bytes, params and wallet
|
|
69
101
|
def with_interface(interface) = self.class.new(raw: raw, params: params, wallet: wallet, interface: interface)
|
|
70
102
|
|
|
103
|
+
# The client used to broadcast and to look the transaction up.
|
|
104
|
+
#
|
|
105
|
+
# @return [Client] `wallet.client`
|
|
71
106
|
def client = wallet.client
|
|
72
107
|
|
|
108
|
+
# @return [String] the checksummed sender address (`params[:from]`)
|
|
73
109
|
def from = params[:from]
|
|
110
|
+
|
|
111
|
+
# @return [String, nil] the checksummed recipient address, or nil for a contract creation
|
|
74
112
|
def to = params[:to]
|
|
113
|
+
|
|
114
|
+
# @return [Integer] the amount of wei transferred
|
|
75
115
|
def value = params[:value]
|
|
116
|
+
|
|
117
|
+
# @return [String] the calldata as `0x` hex, or `""` when there is none
|
|
76
118
|
def data = params[:data]
|
|
119
|
+
|
|
120
|
+
# @return [Integer] the nonce the bytes were signed with; a {#replacement} reuses it
|
|
77
121
|
def nonce = params[:nonce]
|
|
122
|
+
|
|
123
|
+
# @return [Integer] the gas limit
|
|
78
124
|
def gas = params[:gas]
|
|
125
|
+
|
|
126
|
+
# @return [Integer] the chain id the transaction is valid on (EIP-155)
|
|
79
127
|
def chain_id = params[:chain_id]
|
|
128
|
+
|
|
129
|
+
# @return [Integer, nil] the EIP-1559 max fee per gas in wei, or nil for a legacy transaction
|
|
80
130
|
def max_fee_per_gas = params[:max_fee_per_gas]
|
|
131
|
+
|
|
132
|
+
# @return [Integer, nil] the EIP-1559 max priority fee per gas in wei, or nil for a legacy transaction
|
|
81
133
|
def max_priority_fee_per_gas = params[:max_priority_fee_per_gas]
|
|
134
|
+
|
|
135
|
+
# @return [Integer, nil] the gas price in wei for a legacy (type 0) transaction, or nil for EIP-1559
|
|
82
136
|
def gas_price = params[:gas_price]
|
|
137
|
+
|
|
138
|
+
# @return [Boolean] true when this is a legacy (type 0) transaction, i.e. {#gas_price} is set
|
|
83
139
|
def legacy? = !gas_price.nil?
|
|
84
140
|
|
|
85
|
-
#
|
|
141
|
+
# Broadcasts the signed bytes with `eth_sendRawTransaction`.
|
|
142
|
+
#
|
|
143
|
+
# The returned {Transaction} carries the locally computed {#hash}, not the value answered by the node; if
|
|
144
|
+
# the node reports a different hash a warning is logged through `client.logger`, but the local hash is
|
|
145
|
+
# still the one to track. Revert errors raised by the node are enriched with {#interface} when present.
|
|
146
|
+
#
|
|
147
|
+
# @return [Transaction] a handle on the broadcast transaction, identified by {#hash}
|
|
148
|
+
# @raise [BlockGiven::ContractRevertError] when the node rejects the transaction with revert data (decoded
|
|
149
|
+
# to a named custom error when {#interface} knows its selector)
|
|
150
|
+
# @raise [BlockGiven::RpcError] for any other node-side rejection (nonce too low, underpriced, ...)
|
|
151
|
+
# @example
|
|
152
|
+
# tx = signed.broadcast
|
|
153
|
+
# tx.hash == signed.hash # => true
|
|
154
|
+
# tx.wait!
|
|
86
155
|
def broadcast
|
|
87
156
|
sent = with_decoded_errors { client.send_raw_transaction(raw) }
|
|
88
157
|
unless sent.hash.to_s.casecmp?(hash)
|
|
@@ -94,13 +163,32 @@ module BlockGiven
|
|
|
94
163
|
end
|
|
95
164
|
alias submit broadcast
|
|
96
165
|
|
|
97
|
-
# The Transaction handle for
|
|
166
|
+
# The {Transaction} handle for {#hash}, without broadcasting anything (status, receipt, confirmations...).
|
|
167
|
+
#
|
|
168
|
+
# @return [Transaction] the same handle `client.transaction(hash)` returns
|
|
98
169
|
def transaction = client.transaction(hash)
|
|
99
170
|
|
|
100
|
-
# Re-signs the same payload with the same nonce and higher fees, to replace a
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
171
|
+
# Re-signs the same payload with the same nonce and higher fees, to replace a transaction stuck in the
|
|
172
|
+
# mempool.
|
|
173
|
+
#
|
|
174
|
+
# Each fee becomes the maximum of the current fee multiplied by `fee_multiplier` (rounded up) and a fresh
|
|
175
|
+
# estimate from the node ({Client#estimate_fees_per_gas} for EIP-1559, {Client#gas_price} for legacy), so
|
|
176
|
+
# the replacement both satisfies the node's bump rule and catches up with the market. For EIP-1559 the
|
|
177
|
+
# max fee is also kept at or above the priority fee. Everything else (`to`, `value`, `data`, `gas`,
|
|
178
|
+
# `nonce`, `chain_id`, `access_list`, {#interface}) is reused. Both transactions share a nonce: only one
|
|
179
|
+
# can be mined, the other is rejected by the node.
|
|
180
|
+
#
|
|
181
|
+
# @param fee_multiplier [Numeric] multiplier applied to the current fees; must be at least
|
|
182
|
+
# {MIN_REPLACEMENT_MULTIPLIER} (1.1), defaults to {DEFAULT_REPLACEMENT_MULTIPLIER} (1.125)
|
|
183
|
+
# @return [SignedTransaction] a new signed transaction with the same nonce and higher fees, not broadcast
|
|
184
|
+
# @raise [BlockGiven::InvalidArgumentError] when `fee_multiplier` is below 1.1, or when the instance has no
|
|
185
|
+
# fee parameters (neither `gas_price` nor both EIP-1559 fields; rebuild it with {.from_raw})
|
|
186
|
+
# @raise [BlockGiven::ContractRevertError] when the fee estimation itself is rejected by the node with
|
|
187
|
+
# revert data
|
|
188
|
+
# @example
|
|
189
|
+
# faster = signed.replacement(fee_multiplier: 1.5)
|
|
190
|
+
# faster.nonce == signed.nonce # => true
|
|
191
|
+
# faster.broadcast
|
|
104
192
|
def replacement(fee_multiplier: DEFAULT_REPLACEMENT_MULTIPLIER)
|
|
105
193
|
if fee_multiplier < MIN_REPLACEMENT_MULTIPLIER
|
|
106
194
|
raise InvalidArgumentError, "fee_multiplier must be >= #{MIN_REPLACEMENT_MULTIPLIER} (got #{fee_multiplier})"
|
|
@@ -113,10 +201,23 @@ module BlockGiven
|
|
|
113
201
|
).with_interface(interface)
|
|
114
202
|
end
|
|
115
203
|
|
|
204
|
+
# Everything needed to persist and later rebuild the transaction.
|
|
205
|
+
#
|
|
206
|
+
# @return [Hash{Symbol => Object}] {#params} merged with `:hash` and `:raw`
|
|
116
207
|
def to_h = params.merge(hash: hash, raw: raw)
|
|
117
208
|
|
|
209
|
+
# @return [String] the transaction {#hash}
|
|
118
210
|
def to_s = hash
|
|
211
|
+
|
|
212
|
+
# Two signed transactions are equal when their signed bytes are identical.
|
|
213
|
+
#
|
|
214
|
+
# @param other [Object] the object to compare with
|
|
215
|
+
# @return [Boolean] true when `other` is a {SignedTransaction} with the same {#raw} bytes
|
|
119
216
|
def ==(other) = other.is_a?(SignedTransaction) && other.raw == raw
|
|
217
|
+
|
|
218
|
+
# Debug representation with the hash, nonce and recipient; never includes the raw bytes.
|
|
219
|
+
#
|
|
220
|
+
# @return [String] e.g. `#<BlockGiven::SignedTransaction 0x... nonce=12 to=0x...>`
|
|
120
221
|
def inspect = "#<BlockGiven::SignedTransaction #{hash} nonce=#{nonce} to=#{to}>"
|
|
121
222
|
|
|
122
223
|
private
|
|
@@ -1,67 +1,142 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module BlockGiven
|
|
4
|
-
# A broadcast transaction identified by its hash. Wraps receipt polling.
|
|
4
|
+
# A broadcast transaction identified by its hash. Wraps receipt lookup, status classification and polling.
|
|
5
5
|
#
|
|
6
|
+
# Instances are cheap handles: nothing is fetched until a method asks the node. The receipt is memoized
|
|
7
|
+
# once found (see {#receipt} and {#reload}).
|
|
8
|
+
#
|
|
9
|
+
# @example Wait for a contract write
|
|
6
10
|
# tx = usdc.transfer(to: "0x...", amount: 1e6)
|
|
7
11
|
# tx.hash # => "0x..."
|
|
8
12
|
# receipt = tx.wait # polls until mined (see BlockGiven.config.timeout / polling_interval)
|
|
9
13
|
# tx.wait! # same, but raises BlockGiven::TransactionRevertedError on failure
|
|
14
|
+
#
|
|
15
|
+
# @example Classify a persisted transaction in one round trip
|
|
16
|
+
# tx = client.transaction(payout.tx_hash)
|
|
17
|
+
# case tx.status
|
|
18
|
+
# when :success then payout.confirmed! if tx.confirmed?(5)
|
|
19
|
+
# when :reverted then payout.failed!
|
|
20
|
+
# when :pending then signed.replacement.broadcast if payout.submitted_at < 5.minutes.ago
|
|
21
|
+
# when :unknown then signed.broadcast # never seen or dropped by the node: resend the same bytes
|
|
22
|
+
# end
|
|
10
23
|
class Transaction
|
|
24
|
+
# @!attribute [r] hash
|
|
25
|
+
# @return [String] the transaction hash as `0x` hex. A String, so this is not Ruby's Object#hash and
|
|
26
|
+
# instances are not usable as Hash keys
|
|
27
|
+
# @!attribute [r] client
|
|
28
|
+
# @return [Client] the client used to query the node
|
|
11
29
|
attr_reader :hash, :client
|
|
12
30
|
|
|
31
|
+
# Builds a handle on a transaction hash. Applications usually get instances from {Client#transaction},
|
|
32
|
+
# {SignedTransaction#broadcast} or contract writes rather than calling this directly.
|
|
33
|
+
#
|
|
34
|
+
# @param hash [String] the transaction hash as `0x` hex
|
|
35
|
+
# @param client [Client] the client used to query the node
|
|
13
36
|
def initialize(hash, client:)
|
|
14
37
|
@hash = hash
|
|
15
38
|
@client = client
|
|
16
39
|
@receipt = nil
|
|
17
40
|
end
|
|
18
41
|
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
42
|
+
# The receipt, without blocking: nil while the transaction is not mined.
|
|
43
|
+
#
|
|
44
|
+
# Cached once found: call {#reload} (or build a fresh handle with {Client#transaction}) to query the node
|
|
45
|
+
# again, e.g. on every tick of a long-lived worker.
|
|
46
|
+
#
|
|
47
|
+
# @return [Receipt, nil] the receipt once the transaction is mined, nil otherwise
|
|
22
48
|
def receipt
|
|
23
49
|
@receipt ||= client.get_transaction_receipt(hash)
|
|
24
50
|
end
|
|
25
51
|
|
|
52
|
+
# Forgets the memoized receipt so the next call to {#receipt} (or any method built on it) asks the node.
|
|
53
|
+
#
|
|
54
|
+
# @return [self]
|
|
26
55
|
def reload
|
|
27
56
|
@receipt = nil
|
|
28
57
|
self
|
|
29
58
|
end
|
|
30
59
|
|
|
60
|
+
# @return [Boolean] true when a receipt exists, i.e. the transaction is included in a block
|
|
31
61
|
def mined? = !receipt.nil?
|
|
32
62
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
63
|
+
# Classifies the transaction in one RPC round trip (two while it is not mined).
|
|
64
|
+
#
|
|
65
|
+
# - `:success` / `:reverted` when mined, from {Receipt#status}
|
|
66
|
+
# - `:pending` when not mined but `eth_getTransactionByHash` knows it (waiting in the mempool)
|
|
67
|
+
# - `:unknown` when the node has never seen it or dropped it; it is then safe to re-broadcast the same
|
|
68
|
+
# signed bytes or a {SignedTransaction#replacement}
|
|
69
|
+
#
|
|
70
|
+
# @return [Symbol] one of `:success`, `:reverted`, `:pending`, `:unknown`
|
|
71
|
+
# @example
|
|
72
|
+
# case tx.status
|
|
73
|
+
# when :success then record.confirmed!
|
|
74
|
+
# when :reverted then record.failed!
|
|
75
|
+
# when :pending then wait_a_bit
|
|
76
|
+
# when :unknown then signed.broadcast
|
|
77
|
+
# end
|
|
38
78
|
def status
|
|
39
79
|
return receipt.status if mined?
|
|
40
80
|
|
|
41
81
|
details ? :pending : :unknown
|
|
42
82
|
end
|
|
43
83
|
|
|
84
|
+
# @return [Boolean] true when mined with a successful receipt status
|
|
44
85
|
def success? = mined? && receipt.success?
|
|
86
|
+
|
|
87
|
+
# @return [Boolean] true when mined with a failed receipt status
|
|
45
88
|
def reverted? = mined? && receipt.reverted?
|
|
89
|
+
|
|
90
|
+
# @return [Boolean] true when {#status} is `:pending` (known by the node, not mined)
|
|
46
91
|
def pending? = status == :pending
|
|
92
|
+
|
|
93
|
+
# @return [Boolean] true when {#status} is `:unknown` (never seen or dropped by the node)
|
|
47
94
|
def unknown? = status == :unknown
|
|
48
95
|
|
|
49
|
-
#
|
|
96
|
+
# Number of blocks since inclusion: `head - block_number + 1`.
|
|
97
|
+
#
|
|
98
|
+
# 1 when mined in the latest block, 0 while not mined. Never negative, even if the node answers with a
|
|
99
|
+
# head behind the receipt's block (e.g. a lagging load-balanced provider).
|
|
100
|
+
#
|
|
101
|
+
# @return [Integer] the confirmation count
|
|
50
102
|
def confirmations
|
|
51
103
|
return 0 unless mined?
|
|
52
104
|
|
|
53
105
|
[client.block_number - receipt.block_number + 1, 0].max
|
|
54
106
|
end
|
|
55
107
|
|
|
56
|
-
# Non-blocking counterpart of wait(confirmations:)
|
|
108
|
+
# Non-blocking counterpart of `wait(confirmations:)`.
|
|
109
|
+
#
|
|
110
|
+
# @param count [Integer, nil] required confirmations; defaults to `BlockGiven.config.confirmations`
|
|
111
|
+
# @return [Boolean] true when {#confirmations} is at least `count`
|
|
57
112
|
def confirmed?(count = nil) = confirmations >= (count || BlockGiven.config.confirmations)
|
|
58
113
|
|
|
114
|
+
# Blocks until the transaction is mined and confirmed, then memoizes and returns the receipt.
|
|
115
|
+
#
|
|
116
|
+
# Polls `eth_getTransactionReceipt` through {Client#wait_for_transaction_receipt}. The receipt is returned
|
|
117
|
+
# whatever its status; use {#wait!} to raise on revert.
|
|
118
|
+
#
|
|
119
|
+
# @param confirmations [Integer, nil] blocks to wait for after inclusion; defaults to
|
|
120
|
+
# `BlockGiven.config.confirmations`
|
|
121
|
+
# @param timeout [Numeric, nil] seconds before giving up; defaults to the client's timeout
|
|
122
|
+
# (`BlockGiven.config.timeout`)
|
|
123
|
+
# @param polling_interval [Numeric, nil] seconds between two polls; defaults to the client's polling interval
|
|
124
|
+
# (`BlockGiven.config.polling_interval`)
|
|
125
|
+
# @return [Receipt] the mined receipt
|
|
126
|
+
# @raise [BlockGiven::TimeoutError] when the receipt is not available (or not confirmed) within `timeout`
|
|
59
127
|
def wait(confirmations: nil, timeout: nil, polling_interval: nil)
|
|
60
128
|
@receipt = client.wait_for_transaction_receipt(
|
|
61
129
|
hash, confirmations: confirmations, timeout: timeout, polling_interval: polling_interval
|
|
62
130
|
)
|
|
63
131
|
end
|
|
64
132
|
|
|
133
|
+
# Same as {#wait} but raises when the mined transaction reverted.
|
|
134
|
+
#
|
|
135
|
+
# @param options [Hash] forwarded to {#wait} (`confirmations:`, `timeout:`, `polling_interval:`)
|
|
136
|
+
# @return [Receipt] the mined receipt, guaranteed successful
|
|
137
|
+
# @raise [BlockGiven::TransactionRevertedError] when the receipt status is `:reverted`; the error exposes the
|
|
138
|
+
# receipt
|
|
139
|
+
# @raise [BlockGiven::TimeoutError] when the receipt is not available within the timeout
|
|
65
140
|
def wait!(**options)
|
|
66
141
|
receipt = wait(**options)
|
|
67
142
|
raise TransactionRevertedError, receipt if receipt.reverted?
|
|
@@ -69,13 +144,29 @@ module BlockGiven
|
|
|
69
144
|
receipt
|
|
70
145
|
end
|
|
71
146
|
|
|
72
|
-
#
|
|
147
|
+
# The transaction object as the node knows it (`eth_getTransactionByHash`), never memoized.
|
|
148
|
+
#
|
|
149
|
+
# @return [Hash{Symbol => Object}, nil] the normalized transaction (snake_case symbol keys, Integer
|
|
150
|
+
# quantities), or nil while the node does not know the hash
|
|
73
151
|
def details = client.get_transaction(hash)
|
|
74
152
|
|
|
153
|
+
# Link to the transaction on the chain's block explorer.
|
|
154
|
+
#
|
|
155
|
+
# @return [String, nil] the URL, or nil when the client's chain has no explorer configured
|
|
75
156
|
def explorer_url = client.chain&.explorer_tx_url(hash)
|
|
76
157
|
|
|
158
|
+
# @return [String] the transaction {#hash}
|
|
77
159
|
def to_s = hash
|
|
160
|
+
|
|
161
|
+
# Two handles are equal when they point at the same hash, whatever their client.
|
|
162
|
+
#
|
|
163
|
+
# @param other [Object] the object to compare with
|
|
164
|
+
# @return [Boolean] true when `other` is a {Transaction} with the same {#hash}
|
|
78
165
|
def ==(other) = other.is_a?(Transaction) && other.hash == hash
|
|
166
|
+
|
|
167
|
+
# Debug representation; includes block and status only when a receipt is already memoized (no RPC call).
|
|
168
|
+
#
|
|
169
|
+
# @return [String] e.g. `#<BlockGiven::Transaction 0x... mined block=123 status=success>`
|
|
79
170
|
def inspect = "#<BlockGiven::Transaction #{hash}#{mined_flag}>"
|
|
80
171
|
|
|
81
172
|
private
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlockGiven
|
|
4
|
+
module TransactionEnvelope
|
|
5
|
+
# Field-level rules shared by signing and decoding: access list conversion, sanity checks and the intrinsic
|
|
6
|
+
# gas a transaction must cover.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module Fields
|
|
10
|
+
# Base cost of any transaction.
|
|
11
|
+
BASE_GAS = 21_000
|
|
12
|
+
# Extra cost of a contract creation.
|
|
13
|
+
CREATE_GAS = 32_000
|
|
14
|
+
# Calldata cost of a zero byte.
|
|
15
|
+
ZERO_BYTE_GAS = 4
|
|
16
|
+
# Calldata cost of a non-zero byte.
|
|
17
|
+
NON_ZERO_BYTE_GAS = 16
|
|
18
|
+
# Cost of each 32-byte word of init code (EIP-3860).
|
|
19
|
+
INITCODE_WORD_GAS = 2
|
|
20
|
+
# Cost of each address of an access list (EIP-2930).
|
|
21
|
+
ACCESS_LIST_ADDRESS_GAS = 2_400
|
|
22
|
+
# Cost of each storage key of an access list (EIP-2930).
|
|
23
|
+
ACCESS_LIST_KEY_GAS = 1_900
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Converts an access list into its RLP form.
|
|
28
|
+
#
|
|
29
|
+
# @param list [Array<Hash, Array>, nil] in one of the forms {TransactionEnvelope} accepts
|
|
30
|
+
# @return [Array<Array>] `[[address_bytes, [key_bytes...]]...]`
|
|
31
|
+
# @raise [BlockGiven::InvalidArgumentError] for an entry that is neither a Hash nor a pair
|
|
32
|
+
def rlp_access_list(list)
|
|
33
|
+
Array(list).map do |entry|
|
|
34
|
+
address, keys =
|
|
35
|
+
case entry
|
|
36
|
+
when Hash
|
|
37
|
+
entry = entry.transform_keys { |k| Utils.snake_case(k) }
|
|
38
|
+
[entry["address"], entry["storage_keys"]]
|
|
39
|
+
when Array then entry
|
|
40
|
+
else raise InvalidArgumentError, "invalid access list entry #{entry.inspect}"
|
|
41
|
+
end
|
|
42
|
+
[Utils.hex_to_bin(address.to_s), Array(keys).map { |key| Utils.hex_to_bin(Utils.pad_hex(key)) }]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Converts a decoded RLP access list into Ruby values.
|
|
47
|
+
#
|
|
48
|
+
# @param list [Array] decoded RLP items
|
|
49
|
+
# @return [Array<Hash{Symbol => Object}>] `[{ address:, storage_keys: }]`
|
|
50
|
+
# @raise [BlockGiven::InvalidArgumentError] when the structure is not an access list
|
|
51
|
+
def parse_access_list(list)
|
|
52
|
+
raise InvalidArgumentError, "invalid access list" unless list.is_a?(Array)
|
|
53
|
+
|
|
54
|
+
list.map do |entry|
|
|
55
|
+
valid = entry.is_a?(Array) && entry.size == 2 && entry[0].is_a?(String) && entry[1].is_a?(Array)
|
|
56
|
+
raise InvalidArgumentError, "invalid access list" unless valid
|
|
57
|
+
|
|
58
|
+
{ address: Utils.checksum_address(Utils.bin_to_hex(entry[0])),
|
|
59
|
+
storage_keys: entry[1].map { |key| Utils.bin_to_hex(key) } }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Rejects parameters no node would accept.
|
|
64
|
+
#
|
|
65
|
+
# @param params [Hash{Symbol => Object}]
|
|
66
|
+
# @return [void]
|
|
67
|
+
# @raise [BlockGiven::InvalidArgumentError] for a negative or missing field, a chain id below 1 or a gas
|
|
68
|
+
# limit under the intrinsic gas
|
|
69
|
+
def validate!(params)
|
|
70
|
+
fee_fields = params[:gas_price] ? %i[gas_price] : %i[max_fee_per_gas max_priority_fee_per_gas]
|
|
71
|
+
(%i[nonce gas value] + fee_fields).each do |field|
|
|
72
|
+
value = params[field]
|
|
73
|
+
next if value.is_a?(Integer) && !value.negative?
|
|
74
|
+
|
|
75
|
+
raise InvalidArgumentError, "invalid #{field}: #{value.inspect}"
|
|
76
|
+
end
|
|
77
|
+
chain_id = params[:chain_id]
|
|
78
|
+
unless chain_id.is_a?(Integer) && chain_id.positive?
|
|
79
|
+
raise InvalidArgumentError, "invalid chain_id: #{chain_id.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
minimum = intrinsic_gas(params)
|
|
83
|
+
return if params[:gas] >= minimum
|
|
84
|
+
|
|
85
|
+
raise InvalidArgumentError,
|
|
86
|
+
"gas limit #{params[:gas]} is below the intrinsic gas of the transaction (#{minimum})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Minimum gas a transaction pays before executing anything.
|
|
90
|
+
#
|
|
91
|
+
# @param params [Hash{Symbol => Object}]
|
|
92
|
+
# @return [Integer]
|
|
93
|
+
def intrinsic_gas(params)
|
|
94
|
+
data = Utils.hex_to_bin(params[:data].to_s)
|
|
95
|
+
zeros = data.count("\x00")
|
|
96
|
+
gas = BASE_GAS + (zeros * ZERO_BYTE_GAS) + ((data.bytesize - zeros) * NON_ZERO_BYTE_GAS)
|
|
97
|
+
gas += CREATE_GAS + (INITCODE_WORD_GAS * ((data.bytesize + 31) / 32)) if params[:to].nil?
|
|
98
|
+
gas + rlp_access_list(params[:access_list]).sum do |entry|
|
|
99
|
+
ACCESS_LIST_ADDRESS_GAS + (ACCESS_LIST_KEY_GAS * entry[1].size)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|