erc20 0.2.9 → 0.4.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.
data/lib/erc20/wallet.rb CHANGED
@@ -11,6 +11,7 @@ require 'json'
11
11
  require 'jsonrpc/client'
12
12
  require 'loog'
13
13
  require 'uri'
14
+ require_relative 'checks'
14
15
  require_relative 'erc20'
15
16
 
16
17
  # A wallet with ERC20 tokens on Ethereum.
@@ -58,10 +59,11 @@ require_relative 'erc20'
58
59
  # Copyright:: Copyright (c) 2025 Yegor Bugayenko
59
60
  # License:: MIT
60
61
  class ERC20::Wallet
61
- # Address of USDT contract.
62
+ include ERC20::Checks
63
+
62
64
  USDT = '0xdac17f958d2ee523a2206206994597c13d831ec7'
65
+ TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
63
66
 
64
- # These properties are read-only:
65
67
  attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path
66
68
 
67
69
  # Constructor.
@@ -73,36 +75,50 @@ class ERC20::Wallet
73
75
  # @param [String] ws_path The path in the connection URL, for Websockets
74
76
  # @param [Boolean] ssl Should we use SSL (for https and wss)
75
77
  # @param [String] proxy The URL of the proxy to use
78
+ # @param [Integer] attempts How many times to try every HTTP RPC endpoint before giving up
79
+ # @param [Array<String>] fallbacks Alternative HTTP RPC endpoint URLs to try when the primary one fails
76
80
  # @param [Object] log The destination for logs
77
- def initialize(contract: USDT, chain: 1, log: $stdout,
78
- host: nil, port: 443, http_path: '/', ws_path: '/',
79
- ssl: true, proxy: nil)
80
- raise 'Contract can\'t be nil' unless contract
81
- raise 'Contract must be a String' unless contract.is_a?(String)
82
- raise 'Invalid format of the contract' unless /^0x[0-9a-fA-F]{40}$/.match?(contract)
81
+ def initialize(
82
+ contract: USDT, chain: 1, log: $stdout,
83
+ host: nil, port: 443, http_path: '/', ws_path: '/',
84
+ ssl: true, proxy: nil, attempts: 1, fallbacks: []
85
+ )
86
+ raise(ArgumentError, 'Contract can\'t be nil') unless contract
87
+ raise(ArgumentError, 'Contract must be a String') unless contract.is_a?(String)
88
+ raise(ArgumentError, 'Invalid format of the contract') unless /^0x[0-9a-fA-F]{40}$/.match?(contract)
83
89
  @contract = contract
84
- raise 'Host can\'t be nil' unless host
85
- raise 'Host must be a String' unless host.is_a?(String)
90
+ raise(ArgumentError, 'Host can\'t be nil') unless host
91
+ raise(ArgumentError, 'Host must be a String') unless host.is_a?(String)
86
92
  @host = host
87
- raise 'Port can\'t be nil' unless port
88
- raise 'Port must be an Integer' unless port.is_a?(Integer)
89
- raise 'Port must be a positive Integer' unless port.positive?
93
+ raise(ArgumentError, 'Port can\'t be nil') unless port
94
+ raise(ArgumentError, 'Port must be an Integer') unless port.is_a?(Integer)
95
+ raise(ArgumentError, 'Port must be a positive Integer') unless port.positive?
90
96
  @port = port
91
- raise 'Ssl can\'t be nil' if ssl.nil?
97
+ raise(ArgumentError, 'Ssl can\'t be nil') if ssl.nil?
92
98
  @ssl = ssl
93
- raise 'Http_path can\'t be nil' unless http_path
94
- raise 'Http_path must be a String' unless http_path.is_a?(String)
99
+ raise(ArgumentError, 'Http_path can\'t be nil') unless http_path
100
+ raise(ArgumentError, 'Http_path must be a String') unless http_path.is_a?(String)
95
101
  @http_path = http_path
96
- raise 'Ws_path can\'t be nil' unless ws_path
97
- raise 'Ws_path must be a String' unless ws_path.is_a?(String)
102
+ raise(ArgumentError, 'Ws_path can\'t be nil') unless ws_path
103
+ raise(ArgumentError, 'Ws_path must be a String') unless ws_path.is_a?(String)
98
104
  @ws_path = ws_path
99
- raise 'Log can\'t be nil' unless log
105
+ raise(ArgumentError, 'Log can\'t be nil') unless log
100
106
  @log = log
101
- raise 'Chain can\'t be nil' unless chain
102
- raise 'Chain must be an Integer' unless chain.is_a?(Integer)
103
- raise 'Chain must be a positive Integer' unless chain.positive?
107
+ raise(ArgumentError, 'Chain can\'t be nil') unless chain
108
+ raise(ArgumentError, 'Chain must be an Integer') unless chain.is_a?(Integer)
109
+ raise(ArgumentError, 'Chain must be a positive Integer') unless chain.positive?
104
110
  @chain = chain
105
111
  @proxy = proxy
112
+ raise(ArgumentError, 'Attempts can\'t be nil') unless attempts
113
+ raise(ArgumentError, 'Attempts must be an Integer') unless attempts.is_a?(Integer)
114
+ raise(ArgumentError, 'Attempts must be a positive Integer') unless attempts.positive?
115
+ @attempts = attempts
116
+ raise(ArgumentError, 'Fallbacks can\'t be nil') if fallbacks.nil?
117
+ raise(ArgumentError, 'Fallbacks must be an Array') unless fallbacks.is_a?(Array)
118
+ fallbacks.each do |f|
119
+ raise(ArgumentError, 'Each fallback must be a String') unless f.is_a?(String)
120
+ end
121
+ @fallbacks = fallbacks
106
122
  @mutex = Mutex.new
107
123
  end
108
124
 
@@ -112,19 +128,26 @@ class ERC20::Wallet
112
128
  # balance in ETH crypto. Another balance is the one kept by the ERC20 contract
113
129
  # in its own ledger in root storage. This balance is checked by this method.
114
130
  #
131
+ # An address that has no tokens has a balance of zero, but so does an address
132
+ # asked at the wrong contract or in the wrong chain: there, +eth_call+ succeeds
133
+ # with empty return data. A +balanceOf+ always answers with a single 32-byte
134
+ # word, thus anything shorter is a misconfiguration and an error is raised,
135
+ # instead of a zero that nobody may tell from a genuinely empty balance.
136
+ #
115
137
  # @param [String] address Public key, in hex, starting from '0x'
116
138
  # @return [Integer] Balance, in tokens
117
139
  def balance(address)
118
- raise 'Address can\'t be nil' unless address
119
- raise 'Address must be a String' unless address.is_a?(String)
120
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(address)
121
- func = '70a08231' # balanceOf
122
- data = "0x#{func}000000000000000000000000#{address[2..].downcase}"
123
- r =
124
- with_jsonrpc do |jr|
125
- jr.eth_call({ to: @contract, data: data }, 'latest')
126
- end
127
- b = r[2..].to_i(16)
140
+ to_address(address)
141
+ data = "0x70a08231000000000000000000000000#{address[2..].downcase}"
142
+ hex = with_jsonrpc { |jr| jr.eth_call({ to: @contract, data: data }, 'latest') }
143
+ unless /^0x[0-9a-fA-F]{64}$/.match?(hex)
144
+ raise(
145
+ StandardError,
146
+ "The #{@contract} contract in chain #{@chain} answered #{hex.inspect} instead of " \
147
+ 'a 32-byte word, it may not be an ERC20 contract at all'
148
+ )
149
+ end
150
+ b = hex[2..].to_i(16)
128
151
  log_it(:debug, "The balance of #{address} is #{b} ERC20 tokens")
129
152
  b
130
153
  end
@@ -137,41 +160,54 @@ class ERC20::Wallet
137
160
  # @param [String] address Public key, in hex, starting from '0x'
138
161
  # @return [Integer] Balance, in ETH
139
162
  def eth_balance(address)
140
- raise 'Address can\'t be nil' unless address
141
- raise 'Address must be a String' unless address.is_a?(String)
142
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(address)
143
- r =
144
- with_jsonrpc do |jr|
145
- jr.eth_getBalance(address, 'latest')
146
- end
147
- b = r[2..].to_i(16)
163
+ to_address(address)
164
+ hex = with_jsonrpc { |jr| jr.eth_getBalance(address, 'latest') }
165
+ unless /^0x[0-9a-fA-F]+$/.match?(hex)
166
+ raise(StandardError, "The node answered #{hex.inspect} instead of a hex quantity, for the balance of #{address}")
167
+ end
168
+ b = hex[2..].to_i(16)
148
169
  log_it(:debug, "The balance of #{address} is #{b} ETHs")
149
170
  b
150
171
  end
151
172
 
152
173
  # Get ERC20 amount (in tokens) that was sent in the given transaction.
153
174
  #
175
+ # One transaction may carry many transfers of the same token: batch payouts,
176
+ # multisend contracts, swaps, and fee splits all do that. When the +to+ is
177
+ # given, only the transfers to that address are counted and their sum is
178
+ # returned. When it is not given and the transaction carries more than one
179
+ # transfer, the amount is ambiguous and an error is raised.
180
+ #
154
181
  # @param [String] txn Hex of transaction
155
- # @return [Integer] Balance, in ERC20 tokens
156
- def sum_of(txn)
157
- raise 'Transaction hash can\'t be nil' unless txn
158
- raise 'Transaction hash must be a String' unless txn.is_a?(String)
159
- raise 'Invalid format of the transaction hash' unless /^0x[0-9a-fA-F]{64}$/.match?(txn)
182
+ # @param [String] to Public key of the receiver, in hex, starting from '0x'
183
+ # @return [Integer] Amount, in ERC20 tokens
184
+ def sum_of(txn, to: nil)
185
+ to_txn(txn)
186
+ to_address(to) unless to.nil?
160
187
  receipt =
161
188
  with_jsonrpc do |jr|
162
189
  jr.eth_getTransactionReceipt(txn)
163
190
  end
164
- raise "Transaction not found: #{txn}" if receipt.nil?
165
- logs = receipt['logs'] || []
166
- transfer_event = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
167
- logs.each do |log|
168
- next unless log['topics'] && log['topics'][0] == transfer_event
169
- next unless log['address'].downcase == @contract.downcase
170
- amount = log['data'].to_i(16)
171
- log_it(:debug, "Found transfer of #{amount} tokens in transaction #{txn}")
172
- return amount
191
+ raise(StandardError, "Transaction not found: #{txn}") if receipt.nil?
192
+ raise(StandardError, "Transaction #{txn} is reverted, its status is #{receipt['status']}") \
193
+ unless receipt['status'] == '0x1'
194
+ amounts =
195
+ (receipt['logs'] || []).filter_map do |log|
196
+ next unless log['topics'] && log['topics'][0] == TRANSFER
197
+ next unless log['address'].downcase == @contract.downcase
198
+ next unless to.nil? || log['topics'][2].to_s.downcase == "0x000000000000000000000000#{to[2..].downcase}"
199
+ log['data'].to_i(16)
200
+ end
201
+ raise(StandardError, "No transfer event found in transaction #{txn}") if amounts.empty?
202
+ if to.nil? && amounts.size > 1
203
+ raise(
204
+ StandardError,
205
+ "Transaction #{txn} carries #{amounts.size} transfers, tell me the receiving address to pick the right ones"
206
+ )
173
207
  end
174
- raise "No transfer event found in transaction #{txn}"
208
+ sum = amounts.sum
209
+ log_it(:debug, "Found transfer of #{sum} tokens in transaction #{txn}")
210
+ sum
175
211
  end
176
212
 
177
213
  # How many gas units are required to send an ERC20 transaction.
@@ -181,15 +217,9 @@ class ERC20::Wallet
181
217
  # @param [Integer] amount How many ERC20 tokens to send
182
218
  # @return [Integer] Number of gas units required
183
219
  def gas_estimate(from, to, amount)
184
- raise 'Address can\'t be nil' unless from
185
- raise 'Address must be a String' unless from.is_a?(String)
186
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(from)
187
- raise 'Address can\'t be nil' unless to
188
- raise 'Address must be a String' unless to.is_a?(String)
189
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(to)
190
- raise 'Amount can\'t be nil' unless amount
191
- raise "Amount (#{amount}) must be an Integer" unless amount.is_a?(Integer)
192
- raise "Amount (#{amount}) must be a positive Integer" unless amount.positive?
220
+ to_address(from)
221
+ to_address(to)
222
+ to_amount(amount)
193
223
  gas =
194
224
  with_jsonrpc do |jr|
195
225
  jr.eth_estimateGas({ from:, to: @contract, data: to_pay_data(to, amount) }, 'latest').to_i(16)
@@ -198,25 +228,41 @@ class ERC20::Wallet
198
228
  gas
199
229
  end
200
230
 
201
- # What is the price of gas unit in gwei?
231
+ GAS_PRICE_TIP = 1_000_000_000
232
+
233
+ # What is the price of gas unit in wei?
202
234
  #
203
235
  # In Ethereum, gas is a unit that measures the computational work required to
204
236
  # execute operations on the network. Every transaction and smart contract
205
237
  # interaction consumes gas. Gas price is the amount of ETH you're willing to pay
206
- # for each unit of gas, denominated in gwei (1 gwei = 0.000000001 ETH). Higher
238
+ # for each unit of gas, denominated in wei (1 gwei = 0.000000001 ETH). Higher
207
239
  # gas prices incentivize miners to include your transaction sooner, while lower
208
240
  # prices may result in longer confirmation times.
209
241
  #
210
- # @return [Integer] Price of gas unit, in gwei (0.000000001 ETH)
242
+ # The returned price is not the bare EIP-1559 base fee. The base fee alone
243
+ # leaves a zero miner tip (+tip = gasPrice - baseFee = 0+), so proposers have
244
+ # no incentive to include the transaction, and it becomes unmineable the
245
+ # moment the base fee rises (it may grow up to 12.5% per block). To make the
246
+ # price mineable, we double the base fee (a buffer that absorbs several blocks
247
+ # of base-fee growth) and add a priority tip (+GAS_PRICE_TIP+).
248
+ #
249
+ # The price is a ceiling, not a payment: it lands in the +maxFeePerGas+ of a
250
+ # type-2 transaction, where the network charges only +baseFee + tip+ per gas
251
+ # unit and refunds the rest of the buffer.
252
+ #
253
+ # @return [Integer] Price of gas unit, in wei (1 gwei = 0.000000001 ETH)
211
254
  def gas_price
212
255
  block =
213
256
  with_jsonrpc do |jr|
214
257
  jr.eth_getBlockByNumber('latest', false)
215
258
  end
216
- raise "Can't get gas price, try again later" if block.nil?
217
- gwei = block['baseFeePerGas'].to_i(16)
218
- log_it(:debug, "The cost of one gas unit is #{gwei} gwei")
219
- gwei
259
+ raise(StandardError, "Can't get gas price, try again later") if block.nil?
260
+ fee = block['baseFeePerGas']
261
+ raise(StandardError, 'The latest block has no baseFeePerGas, the chain is not EIP-1559 capable') if fee.nil?
262
+ base = fee.to_i(16)
263
+ price = (base * 2) + GAS_PRICE_TIP
264
+ log_it(:debug, "The base fee is #{base} wei, the cost of one gas unit is #{price} wei")
265
+ price
220
266
  end
221
267
 
222
268
  # Send a single ERC20 payment from a private address to a public one.
@@ -229,52 +275,47 @@ class ERC20::Wallet
229
275
  # decrease your balance and increase the recipient's balance. This requires more
230
276
  # gas than ETH transfers since it involves executing contract code.
231
277
  #
278
+ # The nonce is fetched and the transaction is signed before the broadcast,
279
+ # outside of the retry loop. A broadcast that fails is repeated with the very
280
+ # same signed transaction, which the network either mines once or rejects as
281
+ # already known. Thus, no number of +attempts+ may pay twice.
282
+ #
283
+ # The transaction is a type-2 one (EIP-1559): the +price+ is the most the
284
+ # sender agrees to pay per gas unit, while the actual payment is only
285
+ # +baseFee + GAS_PRICE_TIP+.
286
+ #
232
287
  # @param [String] priv Private key, in hex
233
288
  # @param [String] address Public key, in hex
234
289
  # @param [Integer] amount The amount of ERC20 tokens to send
235
290
  # @param [Integer] limit How much gas you're ready to spend
236
- # @param [Integer] price How much gas you pay per computation unit
291
+ # @param [Integer] price The most you pay per computation unit
237
292
  # @return [String] Transaction hash
238
293
  def pay(priv, address, amount, limit: nil, price: gas_price)
239
- raise 'Private key can\'t be nil' unless priv
240
- raise 'Private key must be a String' unless priv.is_a?(String)
241
- raise 'Invalid format of private key' unless /^[0-9a-fA-F]{64}$/.match?(priv)
242
- raise 'Address can\'t be nil' unless address
243
- raise 'Address must be a String' unless address.is_a?(String)
244
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(address)
245
- raise 'Amount can\'t be nil' unless amount
246
- raise "Amount (#{amount}) must be an Integer" unless amount.is_a?(Integer)
247
- raise "Amount (#{amount}) must be a positive Integer" unless amount.positive?
248
- if limit
249
- raise 'Gas limit must be an Integer' unless limit.is_a?(Integer)
250
- raise "Gas limit #{limit} is below #{Eth::Tx::DEFAULT_GAS_LIMIT}" if limit < Eth::Tx::DEFAULT_GAS_LIMIT
251
- raise "Gas limit #{limit} is above #{Eth::Tx::BLOCK_GAS_LIMIT}" if limit > Eth::Tx::BLOCK_GAS_LIMIT
252
- end
253
- if price
254
- raise 'Gas price must be an Integer' unless price.is_a?(Integer)
255
- raise 'Gas price must be a positive Integer' unless price.positive?
256
- end
294
+ to_priv(priv)
295
+ to_address(address)
296
+ to_amount(amount)
297
+ to_limit(limit) if limit
298
+ to_price(price)
257
299
  key = Eth::Key.new(priv: priv)
258
300
  from = key.address.to_s
259
301
  tnx =
260
302
  @mutex.synchronize do
261
- with_jsonrpc do |jr|
262
- nonce = jr.eth_getTransactionCount(from, 'pending').to_i(16)
263
- h = {
264
- nonce:,
265
- gas_price: price,
303
+ tx = Eth::Tx.new(
304
+ {
305
+ nonce: with_jsonrpc { |jr| jr.eth_getTransactionCount(from, 'pending').to_i(16) },
306
+ max_gas_fee: price,
307
+ priority_fee: tip(price),
266
308
  gas_limit: limit || gas_estimate(from, address, amount),
267
309
  to: @contract,
268
310
  value: 0,
269
311
  data: to_pay_data(address, amount),
270
312
  chain_id: @chain
271
313
  }
272
- tx = Eth::Tx.new(h)
273
- tx.sign(key)
274
- hex = "0x#{tx.hex}"
275
- log_it(:debug, "Sending ERC20 transaction #{hex}")
276
- jr.eth_sendRawTransaction(hex)
277
- end
314
+ )
315
+ tx.sign(key)
316
+ hex = "0x#{tx.hex}"
317
+ log_it(:debug, "Sending ERC20 transaction #{hex}")
318
+ with_jsonrpc { |jr| jr.eth_sendRawTransaction(hex) }
278
319
  end
279
320
  log_it(:debug, "Sent #{amount} ERC20 tokens from #{from} to #{address}: #{tnx}")
280
321
  tnx.downcase
@@ -285,42 +326,32 @@ class ERC20::Wallet
285
326
  # @param [String] priv Private key, in hex
286
327
  # @param [String] address Public key, in hex
287
328
  # @param [Integer] amount The amount of ETH to send
288
- # @param [Integer] price How much gas you pay per computation unit
329
+ # @param [Integer] price The most you pay per computation unit
289
330
  # @return [String] Transaction hash
290
331
  def eth_pay(priv, address, amount, price: gas_price)
291
- raise 'Private key can\'t be nil' unless priv
292
- raise 'Private key must be a String' unless priv.is_a?(String)
293
- raise 'Invalid format of private key' unless /^[0-9a-fA-F]{64}$/.match?(priv)
294
- raise 'Address can\'t be nil' unless address
295
- raise 'Address must be a String' unless address.is_a?(String)
296
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(address)
297
- raise 'Amount can\'t be nil' unless amount
298
- raise "Amount (#{amount}) must be an Integer" unless amount.is_a?(Integer)
299
- raise "Amount (#{amount}) must be a positive Integer" unless amount.positive?
300
- if price
301
- raise 'Gas price must be an Integer' unless price.is_a?(Integer)
302
- raise 'Gas price must be a positive Integer' unless price.positive?
303
- end
332
+ to_priv(priv)
333
+ to_address(address)
334
+ to_amount(amount)
335
+ to_price(price)
304
336
  key = Eth::Key.new(priv: priv)
305
337
  from = key.address.to_s
306
338
  tnx =
307
339
  @mutex.synchronize do
308
- with_jsonrpc do |jr|
309
- nonce = jr.eth_getTransactionCount(from, 'pending').to_i(16)
310
- h = {
340
+ tx = Eth::Tx.new(
341
+ {
311
342
  chain_id: @chain,
312
- nonce:,
313
- gas_price: price,
343
+ nonce: with_jsonrpc { |jr| jr.eth_getTransactionCount(from, 'pending').to_i(16) },
344
+ max_gas_fee: price,
345
+ priority_fee: tip(price),
314
346
  gas_limit: 22_000,
315
347
  to: address,
316
348
  value: amount
317
349
  }
318
- tx = Eth::Tx.new(h)
319
- tx.sign(key)
320
- hex = "0x#{tx.hex}"
321
- log_it(:debug, "Sending ETH transaction #{hex}")
322
- jr.eth_sendRawTransaction(hex)
323
- end
350
+ )
351
+ tx.sign(key)
352
+ hex = "0x#{tx.hex}"
353
+ log_it(:debug, "Sending ETH transaction #{hex}")
354
+ with_jsonrpc { |jr| jr.eth_sendRawTransaction(hex) }
324
355
  end
325
356
  log_it(:debug, "Sent #{amount} ETHs from #{from} to #{address}: #{tnx}")
326
357
  tnx.downcase
@@ -339,29 +370,51 @@ class ERC20::Wallet
339
370
  #
340
371
  # The +addresses+ must have +to_a()+ implemented. This method will be
341
372
  # called every +delay+ seconds. It is expected that it returns the list
342
- # of Ethereum public addresses that must be monitored.
373
+ # of Ethereum public addresses that must be monitored. Every address must
374
+ # be a hex with the +0x+ prefix, both at the start and later, when the list
375
+ # changes: a malformed address makes the filter of the subscription target
376
+ # a different address, thus payments are never seen.
343
377
  #
344
- # The +active+ must have +append()+ and +to_a()+ implemented. This array
345
- # maintains the list of addresses that were mentioned in incoming transactions.
346
- # This array is used mostly for testing. It is suggested to always provide
347
- # an empty array.
378
+ # The +active+ must have +append()+, +clear()+ and +to_a()+ implemented. This
379
+ # array holds the addresses that the node has confirmed a subscription for,
380
+ # and it is rebuilt on every confirmation. This array is used mostly for
381
+ # testing. It is suggested to always provide an empty array.
382
+ #
383
+ # When the node answers a subscribe request with an error, the addresses stay
384
+ # out of +active+, the error goes to the log, and the next subscribe attempt
385
+ # happens +delay+ seconds later.
386
+ #
387
+ # A dropped connection leaves a gap: the blocks mined between the disconnect
388
+ # and the confirmation of the new subscription are not streamed by the node.
389
+ # After a reconnect, the logs of that gap are fetched with +eth_getLogs+ and
390
+ # yielded before the live ones. A payment may arrive twice this way, because
391
+ # the gap starts at the block of the last payment seen, and the block must be
392
+ # ready for it: use +txn+ and +index+ of the event as the key of the payment.
393
+ #
394
+ # An exception from the block does not stop the stream: it goes to the log
395
+ # and the event is gone, since the node has no way of sending it again. The
396
+ # block must handle its own errors, if the payment must not be lost.
397
+ #
398
+ # A reorganization of the chain may revert a payment that was already mined.
399
+ # The node then re-sends its log with the +removed+ flag set. Such an event
400
+ # is not yielded, since the payment never happened. In +raw+ mode the event
401
+ # is yielded as it arrives and the +removed+ flag must be checked by the
402
+ # consumer.
403
+ #
404
+ # Events are yielded with zero confirmations, the moment they arrive from the
405
+ # node. A payment must not be treated as settled until enough blocks are
406
+ # mined on top of it.
348
407
  #
349
408
  # @param [Array<String>] addresses Addresses to monitor
350
409
  # @param [Array] active List of addresses that we are actually listening to
351
410
  # @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
352
- # @param [Integer] delay How many seconds to wait between +eth_subscribe+ calls
411
+ # @param [Numeric] delay How many seconds to wait between +eth_subscribe+ calls
353
412
  # @param [Integer] subscription_id Unique ID of the subscription
354
- def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(99_999), &)
355
- raise 'Addresses can\'t be nil' unless addresses
356
- raise 'Addresses must respond to .to_a()' unless addresses.respond_to?(:to_a)
357
- raise 'Active can\'t be nil' unless active
358
- raise 'Active must respond to .to_a()' unless active.respond_to?(:to_a)
359
- raise 'Active must respond to .append()' unless active.respond_to?(:append)
360
- raise 'Active must respond to .clear()' unless active.respond_to?(:clear)
361
- raise 'Delay must be an Integer' unless delay.is_a?(Integer)
362
- raise 'Delay must be a positive Integer or positive Float' unless delay.positive?
363
- raise 'Subscription ID must be an Integer' unless subscription_id.is_a?(Integer)
364
- raise 'Subscription ID must be a positive Integer' unless subscription_id.positive?
413
+ def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(1..99_999), &)
414
+ to_addresses(addresses)
415
+ to_active(active)
416
+ to_delay(delay)
417
+ to_subscription(subscription_id)
365
418
  EventMachine.run do
366
419
  reaccept(addresses, active, raw:, delay:, subscription_id:, &)
367
420
  end
@@ -372,16 +425,19 @@ class ERC20::Wallet
372
425
  # @param [Array<String>] addresses Addresses to monitor
373
426
  # @param [Array] active List of addresses that we are actually listening to
374
427
  # @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
375
- # @param [Integer] delay How many seconds to wait between +eth_subscribe+ calls
428
+ # @param [Numeric] delay How many seconds to wait between +eth_subscribe+ calls
376
429
  # @param [Integer] subscription_id Unique ID of the subscription
430
+ # @param [Integer] since The number of the last block we have seen a payment in
377
431
  # @return [Websocket]
378
- def reaccept(addresses, active, raw:, delay:, subscription_id:, &)
432
+ def reaccept(addresses, active, raw:, delay:, subscription_id:, since: nil, &)
379
433
  u = url(http: false)
380
434
  log_it(:debug, "Connecting ##{subscription_id} to #{u.hostname}:#{u.port}...")
381
- contract = @contract
382
435
  log_url = "ws#{'s' if @ssl}://#{u.hostname}:#{u.port}"
383
436
  ws = Faye::WebSocket::Client.new(u.to_s, [], proxy: @proxy ? { origin: @proxy } : {}, ping: 60)
384
437
  timer = nil
438
+ subscription = nil
439
+ wanted = nil
440
+ height = since
385
441
  ws.on(:open) do
386
442
  safe do
387
443
  verbose do
@@ -389,28 +445,33 @@ class ERC20::Wallet
389
445
  timer =
390
446
  EventMachine.add_periodic_timer(delay) do
391
447
  next if active.to_a.sort == addresses.to_a.sort
448
+ wanted = to_addresses(addresses).dup
449
+ # rubocop:disable Style/Send
450
+ if subscription
451
+ ws.send(
452
+ {
453
+ jsonrpc: '2.0',
454
+ id: subscription_id + 1,
455
+ method: 'eth_unsubscribe',
456
+ params: [subscription]
457
+ }.to_json
458
+ )
459
+ log_it(:debug, "Requested to unsubscribe ##{subscription_id} from #{subscription}")
460
+ subscription = nil
461
+ end
392
462
  ws.send(
393
463
  {
394
464
  jsonrpc: '2.0',
395
465
  id: subscription_id,
396
466
  method: 'eth_subscribe',
397
- params: [
398
- 'logs',
399
- {
400
- address: contract,
401
- topics: [
402
- '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
403
- nil,
404
- addresses.to_a.map { |a| "0x000000000000000000000000#{a[2..]}" }
405
- ]
406
- }
407
- ]
467
+ params: ['logs', to_filter(wanted)]
408
468
  }.to_json
409
469
  )
470
+ # rubocop:enable Style/Send
410
471
  log_it(
411
472
  :debug,
412
- "Requested to subscribe ##{subscription_id} to #{addresses.to_a.size} addresses: " \
413
- "#{addresses.to_a.map { |a| a[0..6] }.join(', ')}"
473
+ "Requested to subscribe ##{subscription_id} to #{wanted.size} addresses: " \
474
+ "#{wanted.map { |a| a[0..6] }.join(', ')}"
414
475
  )
415
476
  end
416
477
  end
@@ -420,35 +481,41 @@ class ERC20::Wallet
420
481
  safe do
421
482
  verbose do
422
483
  data = to_json(msg)
423
- if data['id']
424
- before = active.to_a.uniq
425
- addresses.to_a.each do |a|
426
- next if before.include?(a)
427
- active.append(a)
428
- end
484
+ if data['error']
485
+ active.clear if data['id'] == subscription_id
486
+ log_it(:error, "Request ##{data['id']} was rejected by #{log_url}: #{data['error']}")
487
+ elsif data['id'] == subscription_id
488
+ subscription = data['result']
489
+ active.clear
490
+ wanted&.each { |a| active.append(a) }
429
491
  log_it(
430
492
  :debug,
431
493
  "Subscribed ##{subscription_id} to #{active.to_a.size} addresses at #{log_url}: " \
432
494
  "#{active.to_a.map { |a| a[0..6] }.join(', ')}"
433
495
  )
434
- elsif data['method'] == 'eth_subscription' && data.dig('params', 'result')
435
- event = data['params']['result']
436
- if raw
437
- log_it(:debug, "New event arrived from #{event['address']}")
438
- else
439
- event = {
440
- amount: event['data'].to_i(16),
441
- from: "0x#{event['topics'][1][26..].downcase}",
442
- to: "0x#{event['topics'][2][26..].downcase}",
443
- txn: event['transactionHash'].downcase
444
- }
445
- log_it(
446
- :debug,
447
- "Payment of #{event[:amount]} tokens arrived at ##{subscription_id} " \
448
- "from #{event[:from]} to #{event[:to]} in #{event[:txn]}"
496
+ if since
497
+ # rubocop:disable Style/Send
498
+ ws.send(
499
+ {
500
+ jsonrpc: '2.0',
501
+ id: subscription_id + 2,
502
+ method: 'eth_getLogs',
503
+ params: [to_filter(wanted).merge(fromBlock: format('0x%x', since + 1), toBlock: 'latest')]
504
+ }.to_json
449
505
  )
506
+ # rubocop:enable Style/Send
507
+ log_it(:debug, "Requested ##{subscription_id} the logs of the blocks after ##{since}")
508
+ since = nil
450
509
  end
451
- yield event
510
+ elsif data['id'] == subscription_id + 2
511
+ log_it(:debug, "Received #{data['result'].size} logs mined while ##{subscription_id} was offline")
512
+ data['result'].each do |log|
513
+ height = [height, log['blockNumber'].to_s.to_i(16)].compact.max
514
+ deliver(log, raw:, id: subscription_id, &)
515
+ end
516
+ elsif data['method'] == 'eth_subscription' && data.dig('params', 'result')
517
+ height = [height, data['params']['result']['blockNumber'].to_s.to_i(16)].compact.max
518
+ deliver(data['params']['result'], raw:, id: subscription_id, &)
452
519
  end
453
520
  end
454
521
  end
@@ -459,7 +526,9 @@ class ERC20::Wallet
459
526
  log_it(:debug, "Disconnected ##{subscription_id} from #{log_url}")
460
527
  active.clear
461
528
  timer&.cancel
462
- reaccept(addresses, active, raw:, delay:, subscription_id: subscription_id + 1, &)
529
+ EventMachine.add_timer(delay) do
530
+ reaccept(addresses, active, raw:, delay:, subscription_id: subscription_id + 1, since: height, &)
531
+ end
463
532
  end
464
533
  end
465
534
  end
@@ -472,9 +541,55 @@ class ERC20::Wallet
472
541
  end
473
542
  end
474
543
 
544
+ def to_filter(addresses)
545
+ {
546
+ address: @contract,
547
+ topics: [
548
+ TRANSFER,
549
+ nil,
550
+ addresses.to_a.map { |a| "0x000000000000000000000000#{a[2..]}" }
551
+ ]
552
+ }
553
+ end
554
+
555
+ def to_event(log)
556
+ {
557
+ amount: log['data'].to_i(16),
558
+ block: log['blockNumber'].to_s.to_i(16),
559
+ from: "0x#{log['topics'][1][26..].downcase}",
560
+ index: log['logIndex'].to_s.to_i(16),
561
+ to: "0x#{log['topics'][2][26..].downcase}",
562
+ txn: log['transactionHash'].downcase
563
+ }
564
+ end
565
+
566
+ def deliver(log, raw:, id:, &)
567
+ if raw
568
+ log_it(:debug, "New event arrived from #{log['address']}")
569
+ digest(log, log['transactionHash'], &)
570
+ elsif log['removed']
571
+ log_it(:debug, "Payment in #{log['transactionHash']} is reverted by a reorganization of the chain, ignoring it")
572
+ else
573
+ event = to_event(log)
574
+ log_it(
575
+ :debug,
576
+ "Payment of #{event[:amount]} tokens arrived at ##{id} " \
577
+ "from #{event[:from]} to #{event[:to]} in #{event[:txn]}"
578
+ )
579
+ digest(event, event[:txn], &)
580
+ end
581
+ end
582
+
583
+ def digest(event, txn)
584
+ yield(event)
585
+ rescue StandardError => e
586
+ log_it(:error, "The block failed to process the payment in #{txn}, the event is lost (#{e.class}): #{e.message}")
587
+ end
588
+
475
589
  def to_json(msg)
476
590
  JSON.parse(msg.data)
477
- rescue StandardError
591
+ rescue StandardError => e
592
+ log_it(:error, "Failed to parse a frame of #{msg.data.to_s.length} bytes (#{e.message}): #{msg.data}")
478
593
  {}
479
594
  end
480
595
 
@@ -482,13 +597,13 @@ class ERC20::Wallet
482
597
  yield
483
598
  rescue StandardError => e
484
599
  log_it(:error, Backtrace.new(e).to_s)
485
- raise e
600
+ raise(e)
486
601
  end
487
602
 
488
603
  def safe
489
604
  yield
490
605
  rescue StandardError
491
- # ignore it
606
+ nil
492
607
  end
493
608
 
494
609
  def url(http: true)
@@ -503,25 +618,33 @@ class ERC20::Wallet
503
618
  opts[:connection] =
504
619
  Faraday.new do |f|
505
620
  f.adapter(Faraday.default_adapter)
506
- f.proxy = {
507
- uri: "#{uri.scheme}://#{uri.hostname}:#{uri.port}",
508
- user: uri.user,
509
- password: uri.password
510
- }
621
+ f.proxy = { uri: "#{uri.scheme}://#{uri.hostname}:#{uri.port}", user: uri.user, password: uri.password }
511
622
  end
512
623
  end
513
- elapsed(@log, good: "Talked to #{url.host}:#{url.port}") do
514
- yield JSONRPC::Client.new(url.to_s, opts)
624
+ endpoints = [url.to_s] + @fallbacks
625
+ budget = @attempts * endpoints.size
626
+ tried = 0
627
+ begin
628
+ u = URI.parse(endpoints[tried % endpoints.size])
629
+ tried += 1
630
+ elapsed(@log, good: "Talked to #{u.host}:#{u.port}") do
631
+ yield(JSONRPC::Client.new(u.to_s, opts))
632
+ end
633
+ rescue StandardError => e
634
+ raise if tried >= budget
635
+ pause = (tried % endpoints.size).zero? ? 2**((tried / endpoints.size) - 1) : 0
636
+ log_it(:debug, "Attempt #{tried}/#{budget} to #{u.host} failed (#{e.class}), retrying in #{pause}s")
637
+ sleep(pause)
638
+ retry
515
639
  end
516
640
  end
517
641
 
642
+ def tip(price)
643
+ [GAS_PRICE_TIP, price].min
644
+ end
645
+
518
646
  def to_pay_data(address, amount)
519
- func = 'a9059cbb' # transfer(address,uint256)
520
- to_clean = address.downcase.sub(/^0x/, '')
521
- to_padded = ('0' * (64 - to_clean.size)) + to_clean
522
- amt_hex = amount.to_s(16)
523
- amt_padded = ('0' * (64 - amt_hex.size)) + amt_hex
524
- "0x#{func}#{to_padded}#{amt_padded}"
647
+ "0xa9059cbb#{format('%064x', address.to_i(16))}#{format('%064x', amount)}"
525
648
  end
526
649
 
527
650
  def log_it(method, msg)