block_given 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/README.md +92 -55
  4. data/lib/block_given/abi/codec/decoder.rb +111 -0
  5. data/lib/block_given/abi/codec.rb +170 -0
  6. data/lib/block_given/abi/coder.rb +122 -11
  7. data/lib/block_given/abi/custom_error.rb +33 -1
  8. data/lib/block_given/abi/event.rb +85 -3
  9. data/lib/block_given/abi/function.rb +94 -2
  10. data/lib/block_given/abi/interface.rb +99 -4
  11. data/lib/block_given/abi/parameter.rb +60 -3
  12. data/lib/block_given/abi/standards/erc1155.rb +41 -0
  13. data/lib/block_given/abi/standards/erc20.rb +33 -0
  14. data/lib/block_given/abi/standards/erc4626.rb +41 -0
  15. data/lib/block_given/abi/standards/erc721.rb +47 -0
  16. data/lib/block_given/abi/standards.rb +124 -0
  17. data/lib/block_given/abi/type.rb +138 -0
  18. data/lib/block_given/chain.rb +114 -2
  19. data/lib/block_given/client.rb +322 -20
  20. data/lib/block_given/configuration.rb +85 -3
  21. data/lib/block_given/connectors/alchemy.rb +38 -4
  22. data/lib/block_given/connectors/base.rb +32 -5
  23. data/lib/block_given/connectors/http.rb +91 -5
  24. data/lib/block_given/connectors/stub.rb +70 -4
  25. data/lib/block_given/contract.rb +422 -26
  26. data/lib/block_given/crypto/keccak.rb +152 -0
  27. data/lib/block_given/crypto/secp256k1.rb +168 -0
  28. data/lib/block_given/crypto.rb +22 -0
  29. data/lib/block_given/eip712.rb +199 -0
  30. data/lib/block_given/errors.rb +134 -14
  31. data/lib/block_given/event.rb +51 -1
  32. data/lib/block_given/normalizer.rb +27 -2
  33. data/lib/block_given/poller.rb +177 -15
  34. data/lib/block_given/receipt.rb +63 -3
  35. data/lib/block_given/rlp.rb +146 -0
  36. data/lib/block_given/signed_transaction.rb +148 -47
  37. data/lib/block_given/transaction.rb +103 -12
  38. data/lib/block_given/transaction_envelope/fields.rb +104 -0
  39. data/lib/block_given/transaction_envelope.rb +183 -0
  40. data/lib/block_given/utils.rb +143 -9
  41. data/lib/block_given/version.rb +2 -1
  42. data/lib/block_given/wallet.rb +216 -35
  43. data/lib/block_given.rb +67 -4
  44. metadata +19 -23
@@ -1,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. 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)`.
4
+ # A signed transaction that has not been broadcast yet.
8
5
  #
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
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
- # signed.replacement.broadcast # same nonce, fees bumped by 12.5%
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
- # `hash` is the transaction hash (a String, like Transaction#hash), so instances
16
- # are not usable as Hash keys; compare them with ==.
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 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`.
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
- decoded = begin
26
- Eth::Tx.decode(Utils.prefix_hex(raw))
27
- rescue StandardError => e
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
- 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}"
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: params_from(decoded, sender), wallet: wallet, interface: interface)
62
+ new(raw: raw, params: params, wallet: wallet, interface: interface)
36
63
  end
37
64
 
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
-
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
- # interface: an Abi::Interface used to name custom errors when the node rejects the
60
- # broadcast with revert data (set by Contract#prepare_write).
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
- # eth_sendRawTransaction. Returns a Transaction carrying the locally computed hash.
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 this hash, without broadcasting (status, receipt...).
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
- # 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.
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
- # 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.
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
- # 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)
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
- # Blocks since inclusion, 1 when mined in the latest block, 0 while not mined.
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:). Defaults to BlockGiven.config.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
- # Raw transaction object from the node (nil while it is not yet known).
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