erc20 0.3.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9719ca99febb05bab13326b5a1de126b73aa5d923ad7f091ef3c4493a6d4030f
4
- data.tar.gz: b75de1bc31c3e1e1ac2b3167d92ac9d924841048035e190ec961dfebde81d9c5
3
+ metadata.gz: aae427d3d4d3c935aa7ea204031b9bdd85c67ce26fd182cb3a3d4ac8c2ed3cd4
4
+ data.tar.gz: 54fce4882a54f7de725bcc40ca234538900dfa67bcae210b23544ac0806d31d9
5
5
  SHA512:
6
- metadata.gz: 6c306141b126499fe21d589809a596567439b7ec70ada449a0b7b4feee656fddb08d94f21943b10c6ea8c73526327a337cc056adc861a720fdee0bdce5e3a2f4
7
- data.tar.gz: adc65e660ef7394c02f0d37979a7568c91b032a07620d25a40d3874e866468b3531e617e353d8bcd48b9e453d10891aa4977f67e21cbd41912123ce470ea3d79
6
+ metadata.gz: 8d49f09b1f3210391496b670aa467455584ba1cce21a19b2247bd6874438291803aef1e367e78c8027e2b6e1eb174ff7950eb002ae46ef272dcc59381948c7c0
7
+ data.tar.gz: ea308190d652a2f7747e24f93910e8d4a2c593acbc01edd53d60594e56f96652591a49281d6f784873f46c2474e99385292554eea66eb3cb6a856efd24c6796e
data/Gemfile CHANGED
@@ -8,8 +8,9 @@ gemspec
8
8
 
9
9
  gem 'backtrace', '~>0.4', require: false
10
10
  gem 'concurrent-ruby', '~>1.2', require: false
11
- gem 'cucumber', '~>10.0', require: false
11
+ gem 'cucumber', '~>11.0', require: false
12
12
  gem 'donce', '~>0.2', require: false
13
+ gem 'em-websocket', '~>0.5', require: false
13
14
  gem 'faraday', '~>2.14', require: false
14
15
  gem 'loog', '~>0.6', require: false
15
16
  gem 'minitest', '~>6.0', require: false
@@ -18,14 +19,14 @@ gem 'minitest-retry', '~>0.2', require: false
18
19
  gem 'qbash', '~>0.4', require: false
19
20
  gem 'rake', '~>13.2', require: false
20
21
  gem 'random-port', '~>0.7', require: false
21
- gem 'rdoc', '~>7.0', require: false
22
+ gem 'rdoc', '~>8.0', require: false
22
23
  gem 'rubocop', '~>1.75', require: false
23
24
  gem 'rubocop-elegant', '~>0.5', require: false
24
25
  gem 'rubocop-minitest', '~>0.38', require: false
25
26
  gem 'rubocop-performance', '~>1.25', require: false
26
27
  gem 'rubocop-rake', '~>0.7', require: false
27
- gem 'simplecov', '~>0.22', require: false
28
- gem 'simplecov-cobertura', '~>3.1', require: false
28
+ gem 'simplecov', '~>1.0', require: false
29
+ gem 'simplecov-cobertura', '~>4.0', require: false
29
30
  gem 'threads', '~>0.4', require: false
30
31
  gem 'typhoeus', '~>1.5', require: false
31
32
  gem 'webmock', '~>3.23', require: false
data/Gemfile.lock CHANGED
@@ -21,22 +21,22 @@ GEM
21
21
  base64 (0.3.0)
22
22
  bigdecimal (4.1.2)
23
23
  builder (3.3.0)
24
- concurrent-ruby (1.3.6)
24
+ concurrent-ruby (1.3.8)
25
25
  crack (1.0.1)
26
26
  bigdecimal
27
27
  rexml
28
- cucumber (10.2.0)
28
+ cucumber (11.1.1)
29
29
  base64 (~> 0.2)
30
30
  builder (~> 3.2)
31
31
  cucumber-ci-environment (> 9, < 12)
32
- cucumber-core (> 15, < 17)
32
+ cucumber-core (>= 16.2.0, < 17)
33
33
  cucumber-cucumber-expressions (> 17, < 20)
34
- cucumber-html-formatter (> 21, < 23)
34
+ cucumber-html-formatter (> 21, < 24)
35
35
  diff-lcs (~> 1.5)
36
36
  logger (~> 1.6)
37
37
  mini_mime (~> 1.1)
38
38
  multi_test (~> 1.1)
39
- sys-uname (~> 1.3)
39
+ sys-uname (~> 1.5)
40
40
  cucumber-ci-environment (11.0.0)
41
41
  cucumber-core (16.2.0)
42
42
  cucumber-gherkin (> 36, < 40)
@@ -46,13 +46,11 @@ GEM
46
46
  bigdecimal
47
47
  cucumber-gherkin (39.1.0)
48
48
  cucumber-messages (>= 31, < 33)
49
- cucumber-html-formatter (22.3.0)
49
+ cucumber-html-formatter (23.1.0)
50
50
  cucumber-messages (> 23, < 33)
51
51
  cucumber-messages (32.3.1)
52
52
  cucumber-tag-expressions (8.1.0)
53
- date (3.5.1)
54
53
  diff-lcs (1.6.2)
55
- docile (1.4.1)
56
54
  donce (0.7.0)
57
55
  backtrace (~> 0.3)
58
56
  os (~> 1.1)
@@ -62,7 +60,10 @@ GEM
62
60
  loog (~> 0.6)
63
61
  tago (~> 0.1)
64
62
  ellipsized (0.3.0)
65
- erb (6.0.4)
63
+ em-websocket (0.5.3)
64
+ eventmachine (>= 0.12.9)
65
+ http_parser.rb (~> 0)
66
+ erb (6.0.6)
66
67
  eth (0.5.13)
67
68
  forwardable (~> 1.3)
68
69
  keccak (~> 1.3)
@@ -74,11 +75,11 @@ GEM
74
75
  ffi (>= 1.15.0)
75
76
  logger
76
77
  eventmachine (1.2.7)
77
- faraday (2.14.2)
78
+ faraday (2.14.3)
78
79
  faraday-net_http (>= 2.0, < 3.5)
79
80
  json
80
81
  logger
81
- faraday-net_http (3.4.3)
82
+ faraday-net_http (3.4.4)
82
83
  net-http (~> 0.5)
83
84
  faye-websocket (0.12.0)
84
85
  eventmachine (>= 0.12.0)
@@ -92,13 +93,14 @@ GEM
92
93
  rake
93
94
  forwardable (1.4.0)
94
95
  hashdiff (1.2.1)
95
- json (2.19.5)
96
+ http_parser.rb (0.8.1)
97
+ json (2.21.1)
96
98
  jsonrpc-client (0.1.4)
97
99
  faraday
98
100
  multi_json (>= 1.1.0)
99
101
  keccak (1.3.3)
100
102
  konstructor (1.0.2)
101
- language_server-protocol (3.17.0.5)
103
+ language_server-protocol (3.17.0.6)
102
104
  lint_roller (1.1.0)
103
105
  logger (1.7.0)
104
106
  loog (0.8.0)
@@ -124,14 +126,11 @@ GEM
124
126
  openssl (3.3.3)
125
127
  os (1.1.4)
126
128
  parallel (2.1.0)
127
- parser (3.3.11.1)
129
+ parser (3.3.12.0)
128
130
  ast (~> 2.4.1)
129
131
  racc
130
132
  pkg-config (1.6.5)
131
133
  prism (1.9.0)
132
- psych (5.3.1)
133
- date
134
- stringio
135
134
  public_suffix (7.0.5)
136
135
  qbash (0.8.4)
137
136
  backtrace (> 0)
@@ -143,17 +142,22 @@ GEM
143
142
  rake (13.4.2)
144
143
  random-port (0.8.2)
145
144
  tago (~> 0.0)
145
+ rbs (4.1.0)
146
+ logger
147
+ prism (>= 1.6.0)
148
+ tsort
146
149
  rbsecp256k1 (6.0.0)
147
150
  mini_portile2 (~> 2.8)
148
151
  pkg-config (~> 1.5)
149
152
  rubyzip (~> 2.3)
150
- rdoc (7.2.0)
153
+ rdoc (8.0.0)
151
154
  erb
152
- psych (>= 4.0.0)
155
+ prism (>= 1.6.0)
156
+ rbs (>= 4.0.0)
153
157
  tsort
154
158
  regexp_parser (2.12.0)
155
159
  rexml (3.4.4)
156
- rubocop (1.86.2)
160
+ rubocop (1.88.2)
157
161
  json (~> 2.3)
158
162
  language_server-protocol (~> 3.17.0.2)
159
163
  lint_roller (~> 1.1.0)
@@ -164,13 +168,13 @@ GEM
164
168
  rubocop-ast (>= 1.49.0, < 2.0)
165
169
  ruby-progressbar (~> 1.7)
166
170
  unicode-display_width (>= 2.4.0, < 4.0)
167
- rubocop-ast (1.49.1)
171
+ rubocop-ast (1.50.0)
168
172
  parser (>= 3.3.7.2)
169
173
  prism (~> 1.7)
170
- rubocop-elegant (0.5.1)
174
+ rubocop-elegant (0.7.1)
171
175
  lint_roller (~> 1.1)
172
176
  rubocop (~> 1.75)
173
- rubocop-minitest (0.39.1)
177
+ rubocop-minitest (0.40.0)
174
178
  lint_roller (~> 1.1)
175
179
  rubocop (>= 1.75.0, < 2.0)
176
180
  rubocop-ast (>= 1.38.0, < 2.0)
@@ -186,16 +190,10 @@ GEM
186
190
  scrypt (3.1.0)
187
191
  ffi-compiler (>= 1.0, < 2.0)
188
192
  rake (~> 13)
189
- simplecov (0.22.0)
190
- docile (~> 1.1)
191
- simplecov-html (~> 0.11)
192
- simplecov_json_formatter (~> 0.1)
193
- simplecov-cobertura (3.1.0)
193
+ simplecov (1.0.3)
194
+ simplecov-cobertura (4.0.0)
194
195
  rexml
195
- simplecov (~> 0.19)
196
- simplecov-html (0.13.2)
197
- simplecov_json_formatter (0.1.4)
198
- stringio (3.2.0)
196
+ simplecov (~> 1.0)
199
197
  sys-uname (1.5.1)
200
198
  ffi (~> 1.1)
201
199
  memoist3 (~> 1.0.0)
@@ -219,12 +217,12 @@ GEM
219
217
  addressable (>= 2.8.0)
220
218
  crack (>= 0.3.2)
221
219
  hashdiff (>= 0.4.0, < 2.0.0)
222
- websocket-driver (0.8.0)
220
+ websocket-driver (0.8.2)
223
221
  base64
224
222
  websocket-extensions (>= 0.1.0)
225
223
  websocket-extensions (0.1.5)
226
224
  win32ole (1.9.3)
227
- yard (0.9.44)
225
+ yard (0.9.45)
228
226
 
229
227
  PLATFORMS
230
228
  arm64-darwin-22
@@ -238,8 +236,9 @@ PLATFORMS
238
236
  DEPENDENCIES
239
237
  backtrace (~> 0.4)
240
238
  concurrent-ruby (~> 1.2)
241
- cucumber (~> 10.0)
239
+ cucumber (~> 11.0)
242
240
  donce (~> 0.2)
241
+ em-websocket (~> 0.5)
243
242
  erc20!
244
243
  faraday (~> 2.14)
245
244
  loog (~> 0.6)
@@ -249,14 +248,14 @@ DEPENDENCIES
249
248
  qbash (~> 0.4)
250
249
  rake (~> 13.2)
251
250
  random-port (~> 0.7)
252
- rdoc (~> 7.0)
251
+ rdoc (~> 8.0)
253
252
  rubocop (~> 1.75)
254
253
  rubocop-elegant (~> 0.5)
255
254
  rubocop-minitest (~> 0.38)
256
255
  rubocop-performance (~> 1.25)
257
256
  rubocop-rake (~> 0.7)
258
- simplecov (~> 0.22)
259
- simplecov-cobertura (~> 3.1)
257
+ simplecov (~> 1.0)
258
+ simplecov-cobertura (~> 4.0)
260
259
  threads (~> 0.4)
261
260
  typhoeus (~> 1.5)
262
261
  webmock (~> 3.23)
data/README.md CHANGED
@@ -40,7 +40,7 @@ w = ERC20::Wallet.new(
40
40
  host: 'mainnet.infura.io',
41
41
  http_path: '/v3/<your-infura-key>',
42
42
  ws_path: '/ws/v3/<your-infura-key>',
43
- attempts: 3, # retry failed HTTP RPC calls up to 3 times, with backoff
43
+ attempts: 3, # try every endpoint up to 3 times, with backoff
44
44
  fallbacks: ['https://eth.drpc.org'], # alternative RPC endpoints to try
45
45
  log: $stdout
46
46
  )
@@ -58,9 +58,16 @@ w.accept(addresses) do |event|
58
58
  puts event[:amount] # how much, in tokens (1000000 = $1 USDT)
59
59
  puts event[:from] # who sent the payment
60
60
  puts event[:to] # who was the receiver
61
+ puts event[:block] # number of the block it was mined in
62
+ puts event[:index] # position of the log in the block
61
63
  end
62
64
  ```
63
65
 
66
+ When the connection to the provider breaks, `accept` reconnects and fetches the
67
+ logs of the blocks that were mined while it was away. A payment may reach the
68
+ block twice because of that, so use `txn` and `index` together as the key of
69
+ the payment and credit it only once.
70
+
64
71
  You can also check ETH balance and send ETH transactions:
65
72
 
66
73
  ```ruby
@@ -77,7 +84,8 @@ To check the price of a gas unit and the expected cost of a payment:
77
84
  # How many gas units required to send this payment:
78
85
  units = w.gas_estimate(from, to, amount)
79
86
 
80
- # What is the price of a gas unit, in wei:
87
+ # What is the most a gas unit may cost, in wei (payments are type-2,
88
+ # thus the network charges only the base fee plus the tip):
81
89
  price = w.gas_price
82
90
  ```
83
91
 
data/bin/erc20 CHANGED
@@ -43,7 +43,7 @@ class Bin < Thor
43
43
  class_option :proxy, type: :string,
44
44
  desc: 'HTTP/S proxy for all requests, e.g. "localhost:3128"'
45
45
  class_option :attempts, type: :numeric, default: 1,
46
- desc: 'How many times should we try before failing'
46
+ desc: 'How many times should we try every endpoint before failing'
47
47
  class_option :fallbacks, type: :array,
48
48
  default: ['https://ethereum.publicnode.com', 'https://eth.drpc.org', 'https://rpc.ankr.com/eth'],
49
49
  desc: 'Fallback HTTP RPC endpoint URLs to try when the primary host fails'
@@ -151,10 +151,10 @@ class Bin < Thor
151
151
  amount = amount.to_i
152
152
  log.debug("The token amount equals to #{amount} ERC20 tokens (as provided)")
153
153
  elsif /^[0-9]+(\.[0-9]+)?usdt$/.match?(amount)
154
- amount = (amount.gsub(/usdt$/, '').to_f * 1_000_000).to_i
154
+ amount = units(amount.gsub(/usdt$/, ''), 1_000_000)
155
155
  log.debug("The USDT amount equals to #{amount} ERC20 tokens")
156
156
  elsif /^\$[0-9]+(\.[0-9]+)?$/.match?(amount)
157
- amount = (amount.gsub(/^\$/, '').to_f * 1_000_000).to_i
157
+ amount = units(amount.gsub(/^\$/, ''), 1_000_000)
158
158
  log.debug("The dollar amount equals to #{amount} ERC20 tokens")
159
159
  else
160
160
  raise "Can't understand amount: #{amount.inspect}"
@@ -165,16 +165,24 @@ class Bin < Thor
165
165
  def wei(amount)
166
166
  if /^[0-9]+$/.match?(amount)
167
167
  amount.to_i
168
- elsif /[0-9]wei$/.match?(amount)
168
+ elsif /^[0-9]+wei$/.match?(amount)
169
169
  amount.gsub(/wei$/, '').to_i
170
- elsif /[0-9]gwei$/.match?(amount)
171
- (amount.gsub(/gwei$/, '').to_f * 1_000_000_000).to_i
172
- elsif /[0-9]eth$/.match?(amount)
173
- (amount.gsub(/eth$/, '').to_f * 1_000_000_000_000_000_000).to_i
170
+ elsif /^[0-9]+(\.[0-9]+)?gwei$/.match?(amount)
171
+ units(amount.gsub(/gwei$/, ''), 1_000_000_000)
172
+ elsif /^[0-9]+(\.[0-9]+)?eth$/.match?(amount)
173
+ units(amount.gsub(/eth$/, ''), 1_000_000_000_000_000_000)
174
174
  else
175
175
  raise "Can't understand amount: #{amount.inspect}"
176
176
  end
177
177
  end
178
+
179
+ def units(amount, factor)
180
+ exact = Rational(amount) * factor
181
+ unless exact.denominator == 1
182
+ raise "Amount #{amount.inspect} is too precise, while the smallest unit is 1/#{factor}"
183
+ end
184
+ exact.numerator
185
+ end
178
186
  end
179
187
 
180
188
  begin
data/features/dry.feature CHANGED
@@ -21,13 +21,17 @@ Feature: Command Line Processing, in Dry Mode
21
21
  Then Exit code is zero
22
22
 
23
23
  Scenario: ERC20 payment can be sent in dollars
24
- When I run bin/erc20 with "pay --dry --verbose 46feba063e9b59a8ae0dba68abd39a3cb8f52089e776576d6eb1bb5bfec123d1 0x7232148927F8a580053792f44D4d59d40Fd00ABD $10"
24
+ When I run bin/erc20 with "pay --dry --verbose 46feba063e9b59a8ae0dba68abd39a3cb8f52089e776576d6eb1bb5bfec123d1 0x7232148927F8a580053792f44D4d59d40Fd00ABD \$10"
25
25
  Then Exit code is zero
26
26
 
27
27
  Scenario: ERC20 payment can be sent in USDT
28
28
  When I run bin/erc20 with "pay --dry --verbose 46feba063e9b59a8ae0dba68abd39a3cb8f52089e776576d6eb1bb5bfec123d1 0x7232148927F8a580053792f44D4d59d40Fd00ABD 10usdt"
29
29
  Then Exit code is zero
30
30
 
31
+ Scenario: ERC20 payment in USDT keeps every token unit
32
+ When I run bin/erc20 with "pay --dry --verbose 46feba063e9b59a8ae0dba68abd39a3cb8f52089e776576d6eb1bb5bfec123d1 0x7232148927F8a580053792f44D4d59d40Fd00ABD 8.2usdt"
33
+ Then Stdout contains "Sending 8200000 ERC20 tokens"
34
+
31
35
  Scenario: ETH payment can be sent in wei
32
36
  When I run bin/erc20 with "eth_pay --dry --verbose 46feba063e9b59a8ae0dba68abd39a3cb8f52089e776576d6eb1bb5bfec123d1 0x7232148927F8a580053792f44D4d59d40Fd00ABD 10000000"
33
37
  Then Exit code is zero
@@ -35,3 +39,7 @@ Feature: Command Line Processing, in Dry Mode
35
39
  Scenario: ETH payment can be sent in ETH
36
40
  When I run bin/erc20 with "eth_pay --dry --verbose 46feba063e9b59a8ae0dba68abd39a3cb8f52089e776576d6eb1bb5bfec123d1 0x7232148927F8a580053792f44D4d59d40Fd00ABD 1eth"
37
41
  Then Exit code is zero
42
+
43
+ Scenario: ETH payment smaller than one wei is rejected
44
+ When I run bin/erc20 with "eth_pay --dry 46feba063e9b59a8ae0dba68abd39a3cb8f52089e776576d6eb1bb5bfec123d1 0x7232148927F8a580053792f44D4d59d40Fd00ABD 0.0000000000000000001eth"
45
+ Then Exit code is not zero
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require 'eth'
7
+ require_relative 'erc20'
8
+
9
+ # The checks that every wallet makes before it touches the network.
10
+ #
11
+ # A wallet deals with private keys, public addresses, and amounts of money. A
12
+ # broken one of them either costs money or silently loses it, thus it is
13
+ # rejected the moment it arrives. Both +ERC20::Wallet+ and +ERC20::FakeWallet+
14
+ # make these very checks, so that a test that passes against the fake wallet
15
+ # doesn't fail against a real provider.
16
+ #
17
+ # Author:: Yegor Bugayenko (yegor256@gmail.com)
18
+ # Copyright:: Copyright (c) 2025 Yegor Bugayenko
19
+ # License:: MIT
20
+ module ERC20::Checks
21
+ HEX = /^0x[0-9a-fA-F]{40}$/
22
+ private_constant :HEX
23
+
24
+ MAX_AMOUNT = (2**256) - 1
25
+ private_constant :MAX_AMOUNT
26
+
27
+ private
28
+
29
+ def to_priv(priv)
30
+ raise(ArgumentError, 'Private key can\'t be nil') unless priv
31
+ raise(ArgumentError, 'Private key must be a String') unless priv.is_a?(String)
32
+ raise(ArgumentError, 'Invalid format of private key') unless /^[0-9a-fA-F]{64}$/.match?(priv)
33
+ priv
34
+ end
35
+
36
+ def to_address(address)
37
+ raise(ArgumentError, 'Address can\'t be nil') unless address
38
+ raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
39
+ raise(ArgumentError, 'Invalid format of the address') unless HEX.match?(address)
40
+ address
41
+ end
42
+
43
+ def to_addresses(addresses)
44
+ raise(ArgumentError, 'Addresses can\'t be nil') unless addresses
45
+ raise(ArgumentError, 'Addresses must respond to .to_a()') unless addresses.respond_to?(:to_a)
46
+ addresses.to_a.each do |a|
47
+ raise(ArgumentError, 'Each address must be a String') unless a.is_a?(String)
48
+ raise(ArgumentError, "Invalid format of the address (#{a})") unless HEX.match?(a)
49
+ end
50
+ end
51
+
52
+ def to_active(active)
53
+ raise(ArgumentError, 'Active can\'t be nil') unless active
54
+ raise(ArgumentError, 'Active must respond to .to_a()') unless active.respond_to?(:to_a)
55
+ raise(ArgumentError, 'Active must respond to .append()') unless active.respond_to?(:append)
56
+ raise(ArgumentError, 'Active must respond to .clear()') unless active.respond_to?(:clear)
57
+ active
58
+ end
59
+
60
+ def to_txn(txn)
61
+ raise(ArgumentError, 'Transaction hash can\'t be nil') unless txn
62
+ raise(ArgumentError, 'Transaction hash must be a String') unless txn.is_a?(String)
63
+ raise(ArgumentError, 'Invalid format of the transaction hash') unless /^0x[0-9a-fA-F]{64}$/.match?(txn)
64
+ txn
65
+ end
66
+
67
+ def to_amount(amount)
68
+ raise(ArgumentError, 'Amount can\'t be nil') unless amount
69
+ raise(ArgumentError, "Amount (#{amount}) must be an Integer") unless amount.is_a?(Integer)
70
+ raise(ArgumentError, "Amount (#{amount}) must be a positive Integer") unless amount.positive?
71
+ raise(ArgumentError, "Amount (#{amount}) must fit into uint256") if amount > MAX_AMOUNT
72
+ amount
73
+ end
74
+
75
+ def to_limit(limit)
76
+ raise(ArgumentError, 'Gas limit must be an Integer') unless limit.is_a?(Integer)
77
+ least = Eth::Tx::DEFAULT_GAS_LIMIT
78
+ raise(ArgumentError, "Gas limit #{limit} is below #{least}") if limit < least
79
+ most = Eth::Tx::BLOCK_GAS_LIMIT
80
+ raise(ArgumentError, "Gas limit #{limit} is above #{most}") if limit > most
81
+ limit
82
+ end
83
+
84
+ def to_price(price)
85
+ raise(ArgumentError, 'Gas price can\'t be nil') unless price
86
+ raise(ArgumentError, 'Gas price must be an Integer') unless price.is_a?(Integer)
87
+ raise(ArgumentError, 'Gas price must be a positive Integer') unless price.positive?
88
+ price
89
+ end
90
+
91
+ def to_delay(delay)
92
+ raise(ArgumentError, 'Delay must be a number') unless delay.is_a?(Numeric)
93
+ raise(ArgumentError, 'Delay must be a positive number') unless delay.positive?
94
+ delay
95
+ end
96
+
97
+ def to_subscription(id)
98
+ raise(ArgumentError, 'Subscription ID must be an Integer') unless id.is_a?(Integer)
99
+ raise(ArgumentError, 'Subscription ID must be a positive Integer') unless id.positive?
100
+ id
101
+ end
102
+ end
data/lib/erc20/erc20.rb CHANGED
@@ -24,5 +24,5 @@
24
24
  # Copyright:: Copyright (c) 2025 Yegor Bugayenko
25
25
  # License:: MIT
26
26
  module ERC20
27
- VERSION = '0.3.0' unless defined?(VERSION)
27
+ VERSION = '0.4.0' unless defined?(VERSION)
28
28
  end
@@ -3,15 +3,22 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
+ require_relative 'checks'
6
7
  require_relative 'erc20'
7
8
  require_relative 'wallet'
8
9
 
9
10
  # A fake wallet that behaves like a +ERC20::Wallet+.
10
11
  #
12
+ # It rejects exactly what the real wallet rejects, so that a broken private
13
+ # key, address, or amount fails in a test the same way it would fail with a
14
+ # real provider, where money is at stake.
15
+ #
11
16
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
12
17
  # Copyright:: Copyright (c) 2025 Yegor Bugayenko
13
18
  # License:: MIT
14
19
  class ERC20::FakeWallet
20
+ include ERC20::Checks
21
+
15
22
  TXN_HASH = '0x172de9cda30537eae68ab4a96163ebbb8f8a85293b8737dd2e5deb4714b14623'
16
23
 
17
24
  attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path, :history
@@ -49,6 +56,7 @@ class ERC20::FakeWallet
49
56
  # @param [String] address Public key, in hex, starting from '0x'
50
57
  # @return [Integer] Balance, in tokens
51
58
  def balance(address)
59
+ to_address(address)
52
60
  b = @balances[address] || 42_000_000
53
61
  @history << { method: :balance, address:, result: b }
54
62
  b
@@ -59,6 +67,7 @@ class ERC20::FakeWallet
59
67
  # @param [String] address Public key, in hex, starting from '0x'
60
68
  # @return [Integer] Balance, in tokens
61
69
  def eth_balance(address)
70
+ to_address(address)
62
71
  b = @eth_balances[address] || 77_000_000_000_000_000
63
72
  @history << { method: :eth_balance, address:, result: b }
64
73
  b
@@ -66,10 +75,15 @@ class ERC20::FakeWallet
66
75
 
67
76
  # Get ERC20 amount (in tokens) that was sent in the given transaction.
68
77
  #
69
- # @param [String] _txn Hex of transaction
70
- # @return [Integer] Balance, in ERC20 tokens
71
- def sum_of(_txn)
72
- 42_000_000
78
+ # @param [String] txn Hex of transaction
79
+ # @param [String] to Public key of the receiver, in hex, starting from '0x'
80
+ # @return [Integer] Amount, in ERC20 tokens
81
+ def sum_of(txn, to: nil)
82
+ to_txn(txn)
83
+ to_address(to) unless to.nil?
84
+ tokens = 42_000_000
85
+ @history << { method: :sum_of, txn:, to:, result: tokens }
86
+ tokens
73
87
  end
74
88
 
75
89
  # How many gas units are required to send an ERC20 transaction.
@@ -79,17 +93,25 @@ class ERC20::FakeWallet
79
93
  # @param [Integer] amount How many ERC20 tokens to send
80
94
  # @return [Integer] How many gas units required
81
95
  def gas_estimate(from, to, amount)
96
+ to_address(from)
97
+ to_address(to)
98
+ to_amount(amount)
82
99
  gas = 66_000
83
100
  @history << { method: :gas_estimate, from:, to:, amount:, result: gas }
84
101
  gas
85
102
  end
86
103
 
87
- # What is the price of gas unit in gwei?
88
- # @return [Integer] Price of gas unit, in gwei (0.000000001 ETH)
104
+ # What is the price of gas unit in wei?
105
+ #
106
+ # The unit is the same as in +ERC20::Wallet#gas_price+: a price in gwei
107
+ # would be a billion times smaller and would make every fee computed in a
108
+ # test look affordable, while in production it wouldn't be.
109
+ #
110
+ # @return [Integer] Price of gas unit, in wei (1 gwei = 0.000000001 ETH)
89
111
  def gas_price
90
- gwei = 55_555
91
- @history << { method: :gas_price, result: gwei }
92
- gwei
112
+ wei = 55_555_000_000
113
+ @history << { method: :gas_price, result: wei }
114
+ wei
93
115
  end
94
116
 
95
117
  # Send a single ERC20 payment from a private address to a public one.
@@ -98,9 +120,14 @@ class ERC20::FakeWallet
98
120
  # @param [String] address Public key, in hex
99
121
  # @param [Integer] amount The amount of ERC20 tokens to send
100
122
  # @param [Integer] limit Optional gas limit
101
- # @param [Integer] price Optional gas price in gwei
123
+ # @param [Integer] price The most you pay per computation unit, in wei
102
124
  # @return [String] Transaction hash
103
- def pay(priv, address, amount, limit: nil, price: nil)
125
+ def pay(priv, address, amount, limit: nil, price: gas_price)
126
+ to_priv(priv)
127
+ to_address(address)
128
+ to_amount(amount)
129
+ to_limit(limit) if limit
130
+ to_price(price)
104
131
  hex = TXN_HASH
105
132
  @history << { method: :pay, priv:, address:, amount:, limit:, price:, result: hex }
106
133
  hex
@@ -111,9 +138,13 @@ class ERC20::FakeWallet
111
138
  # @param [String] priv Private key, in hex
112
139
  # @param [String] address Public key, in hex
113
140
  # @param [Integer] amount The amount of ETHs to send
114
- # @param [Integer] price Optional gas price in gwei
141
+ # @param [Integer] price The most you pay per computation unit, in wei
115
142
  # @return [String] Transaction hash
116
- def eth_pay(priv, address, amount, price: nil)
143
+ def eth_pay(priv, address, amount, price: gas_price)
144
+ to_priv(priv)
145
+ to_address(address)
146
+ to_amount(amount)
147
+ to_price(price)
117
148
  hex = TXN_HASH
118
149
  @history << { method: :eth_pay, priv:, address:, amount:, price:, result: hex }
119
150
  hex
@@ -124,12 +155,18 @@ class ERC20::FakeWallet
124
155
  # @param [Array<String>] addresses Addresses to monitor
125
156
  # @param [Array] active List of addresses that we are actually listening to
126
157
  # @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
127
- # @param [Integer] delay How many seconds to wait between +eth_subscribe+ calls
128
- def accept(addresses, active = [], raw: false, delay: 1)
129
- @history << { method: :accept, addresses:, active:, raw:, delay: }
158
+ # @param [Numeric] delay How many seconds to wait between +eth_subscribe+ calls
159
+ # @param [Integer] subscription_id Unique ID of the subscription
160
+ def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(1..99_999))
161
+ to_addresses(addresses)
162
+ to_active(active)
163
+ to_delay(delay)
164
+ to_subscription(subscription_id)
165
+ @history << { method: :accept, addresses:, active:, raw:, delay:, subscription_id: }
130
166
  addresses.to_a.each { |a| active.append(a) }
131
167
  loop do
132
168
  sleep(delay)
169
+ to_addresses(addresses)
133
170
  a = addresses.to_a.sample
134
171
  next if a.nil?
135
172
  yield(
@@ -138,7 +175,9 @@ class ERC20::FakeWallet
138
175
  else
139
176
  {
140
177
  amount: 424_242,
178
+ block: 42,
141
179
  from: '0xd5ff1bfcde7a03da61ad229d962c74f1ea2f16a5',
180
+ index: 0,
142
181
  to: a,
143
182
  txn: TXN_HASH
144
183
  }
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,6 +59,8 @@ require_relative 'erc20'
58
59
  # Copyright:: Copyright (c) 2025 Yegor Bugayenko
59
60
  # License:: MIT
60
61
  class ERC20::Wallet
62
+ include ERC20::Checks
63
+
61
64
  USDT = '0xdac17f958d2ee523a2206206994597c13d831ec7'
62
65
  TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
63
66
 
@@ -72,7 +75,7 @@ class ERC20::Wallet
72
75
  # @param [String] ws_path The path in the connection URL, for Websockets
73
76
  # @param [Boolean] ssl Should we use SSL (for https and wss)
74
77
  # @param [String] proxy The URL of the proxy to use
75
- # @param [Integer] attempts How many times to retry a failed HTTP RPC call before giving up
78
+ # @param [Integer] attempts How many times to try every HTTP RPC endpoint before giving up
76
79
  # @param [Array<String>] fallbacks Alternative HTTP RPC endpoint URLs to try when the primary one fails
77
80
  # @param [Object] log The destination for logs
78
81
  def initialize(
@@ -125,14 +128,26 @@ class ERC20::Wallet
125
128
  # balance in ETH crypto. Another balance is the one kept by the ERC20 contract
126
129
  # in its own ledger in root storage. This balance is checked by this method.
127
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
+ #
128
137
  # @param [String] address Public key, in hex, starting from '0x'
129
138
  # @return [Integer] Balance, in tokens
130
139
  def balance(address)
131
- raise(ArgumentError, 'Address can\'t be nil') unless address
132
- raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
133
- raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(address)
140
+ to_address(address)
134
141
  data = "0x70a08231000000000000000000000000#{address[2..].downcase}"
135
- b = with_jsonrpc { |jr| jr.eth_call({ to: @contract, data: data }, 'latest') }[2..].to_i(16)
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)
136
151
  log_it(:debug, "The balance of #{address} is #{b} ERC20 tokens")
137
152
  b
138
153
  end
@@ -145,36 +160,54 @@ class ERC20::Wallet
145
160
  # @param [String] address Public key, in hex, starting from '0x'
146
161
  # @return [Integer] Balance, in ETH
147
162
  def eth_balance(address)
148
- raise(ArgumentError, 'Address can\'t be nil') unless address
149
- raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
150
- raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(address)
151
- b = with_jsonrpc { |jr| jr.eth_getBalance(address, 'latest') }[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)
152
169
  log_it(:debug, "The balance of #{address} is #{b} ETHs")
153
170
  b
154
171
  end
155
172
 
156
173
  # Get ERC20 amount (in tokens) that was sent in the given transaction.
157
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
+ #
158
181
  # @param [String] txn Hex of transaction
159
- # @return [Integer] Balance, in ERC20 tokens
160
- def sum_of(txn)
161
- raise(ArgumentError, 'Transaction hash can\'t be nil') unless txn
162
- raise(ArgumentError, 'Transaction hash must be a String') unless txn.is_a?(String)
163
- raise(ArgumentError, '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?
164
187
  receipt =
165
188
  with_jsonrpc do |jr|
166
189
  jr.eth_getTransactionReceipt(txn)
167
190
  end
168
191
  raise(StandardError, "Transaction not found: #{txn}") if receipt.nil?
169
- logs = receipt['logs'] || []
170
- logs.each do |log|
171
- next unless log['topics'] && log['topics'][0] == TRANSFER
172
- next unless log['address'].downcase == @contract.downcase
173
- amount = log['data'].to_i(16)
174
- log_it(:debug, "Found transfer of #{amount} tokens in transaction #{txn}")
175
- return amount
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
+ )
176
207
  end
177
- raise(StandardError, "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
178
211
  end
179
212
 
180
213
  # How many gas units are required to send an ERC20 transaction.
@@ -184,15 +217,9 @@ class ERC20::Wallet
184
217
  # @param [Integer] amount How many ERC20 tokens to send
185
218
  # @return [Integer] Number of gas units required
186
219
  def gas_estimate(from, to, amount)
187
- raise(ArgumentError, 'Address can\'t be nil') unless from
188
- raise(ArgumentError, 'Address must be a String') unless from.is_a?(String)
189
- raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(from)
190
- raise(ArgumentError, 'Address can\'t be nil') unless to
191
- raise(ArgumentError, 'Address must be a String') unless to.is_a?(String)
192
- raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(to)
193
- raise(ArgumentError, 'Amount can\'t be nil') unless amount
194
- raise(ArgumentError, "Amount (#{amount}) must be an Integer") unless amount.is_a?(Integer)
195
- raise(ArgumentError, "Amount (#{amount}) must be a positive Integer") unless amount.positive?
220
+ to_address(from)
221
+ to_address(to)
222
+ to_amount(amount)
196
223
  gas =
197
224
  with_jsonrpc do |jr|
198
225
  jr.eth_estimateGas({ from:, to: @contract, data: to_pay_data(to, amount) }, 'latest').to_i(16)
@@ -219,6 +246,10 @@ class ERC20::Wallet
219
246
  # price mineable, we double the base fee (a buffer that absorbs several blocks
220
247
  # of base-fee growth) and add a priority tip (+GAS_PRICE_TIP+).
221
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
+ #
222
253
  # @return [Integer] Price of gas unit, in wei (1 gwei = 0.000000001 ETH)
223
254
  def gas_price
224
255
  block =
@@ -226,7 +257,9 @@ class ERC20::Wallet
226
257
  jr.eth_getBlockByNumber('latest', false)
227
258
  end
228
259
  raise(StandardError, "Can't get gas price, try again later") if block.nil?
229
- base = block['baseFeePerGas'].to_i(16)
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)
230
263
  price = (base * 2) + GAS_PRICE_TIP
231
264
  log_it(:debug, "The base fee is #{base} wei, the cost of one gas unit is #{price} wei")
232
265
  price
@@ -242,52 +275,47 @@ class ERC20::Wallet
242
275
  # decrease your balance and increase the recipient's balance. This requires more
243
276
  # gas than ETH transfers since it involves executing contract code.
244
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
+ #
245
287
  # @param [String] priv Private key, in hex
246
288
  # @param [String] address Public key, in hex
247
289
  # @param [Integer] amount The amount of ERC20 tokens to send
248
290
  # @param [Integer] limit How much gas you're ready to spend
249
- # @param [Integer] price How much gas you pay per computation unit
291
+ # @param [Integer] price The most you pay per computation unit
250
292
  # @return [String] Transaction hash
251
293
  def pay(priv, address, amount, limit: nil, price: gas_price)
252
- raise(ArgumentError, 'Private key can\'t be nil') unless priv
253
- raise(ArgumentError, 'Private key must be a String') unless priv.is_a?(String)
254
- raise(ArgumentError, 'Invalid format of private key') unless /^[0-9a-fA-F]{64}$/.match?(priv)
255
- raise(ArgumentError, 'Address can\'t be nil') unless address
256
- raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
257
- raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(address)
258
- raise(ArgumentError, 'Amount can\'t be nil') unless amount
259
- raise(ArgumentError, "Amount (#{amount}) must be an Integer") unless amount.is_a?(Integer)
260
- raise(ArgumentError, "Amount (#{amount}) must be a positive Integer") unless amount.positive?
261
- if limit
262
- raise(ArgumentError, 'Gas limit must be an Integer') unless limit.is_a?(Integer)
263
- raise(ArgumentError, "Gas limit #{limit} is below #{Eth::Tx::DEFAULT_GAS_LIMIT}") if limit < Eth::Tx::DEFAULT_GAS_LIMIT
264
- raise(ArgumentError, "Gas limit #{limit} is above #{Eth::Tx::BLOCK_GAS_LIMIT}") if limit > Eth::Tx::BLOCK_GAS_LIMIT
265
- end
266
- if price
267
- raise(ArgumentError, 'Gas price must be an Integer') unless price.is_a?(Integer)
268
- raise(ArgumentError, 'Gas price must be a positive Integer') unless price.positive?
269
- end
294
+ to_priv(priv)
295
+ to_address(address)
296
+ to_amount(amount)
297
+ to_limit(limit) if limit
298
+ to_price(price)
270
299
  key = Eth::Key.new(priv: priv)
271
300
  from = key.address.to_s
272
301
  tnx =
273
302
  @mutex.synchronize do
274
- with_jsonrpc do |jr|
275
- tx = Eth::Tx.new(
276
- {
277
- nonce: jr.eth_getTransactionCount(from, 'pending').to_i(16),
278
- gas_price: price,
279
- gas_limit: limit || gas_estimate(from, address, amount),
280
- to: @contract,
281
- value: 0,
282
- data: to_pay_data(address, amount),
283
- chain_id: @chain
284
- }
285
- )
286
- tx.sign(key)
287
- hex = "0x#{tx.hex}"
288
- log_it(:debug, "Sending ERC20 transaction #{hex}")
289
- jr.eth_sendRawTransaction(hex)
290
- end
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),
308
+ gas_limit: limit || gas_estimate(from, address, amount),
309
+ to: @contract,
310
+ value: 0,
311
+ data: to_pay_data(address, amount),
312
+ chain_id: @chain
313
+ }
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) }
291
319
  end
292
320
  log_it(:debug, "Sent #{amount} ERC20 tokens from #{from} to #{address}: #{tnx}")
293
321
  tnx.downcase
@@ -298,42 +326,32 @@ class ERC20::Wallet
298
326
  # @param [String] priv Private key, in hex
299
327
  # @param [String] address Public key, in hex
300
328
  # @param [Integer] amount The amount of ETH to send
301
- # @param [Integer] price How much gas you pay per computation unit
329
+ # @param [Integer] price The most you pay per computation unit
302
330
  # @return [String] Transaction hash
303
331
  def eth_pay(priv, address, amount, price: gas_price)
304
- raise(ArgumentError, 'Private key can\'t be nil') unless priv
305
- raise(ArgumentError, 'Private key must be a String') unless priv.is_a?(String)
306
- raise(ArgumentError, 'Invalid format of private key') unless /^[0-9a-fA-F]{64}$/.match?(priv)
307
- raise(ArgumentError, 'Address can\'t be nil') unless address
308
- raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
309
- raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(address)
310
- raise(ArgumentError, 'Amount can\'t be nil') unless amount
311
- raise(ArgumentError, "Amount (#{amount}) must be an Integer") unless amount.is_a?(Integer)
312
- raise(ArgumentError, "Amount (#{amount}) must be a positive Integer") unless amount.positive?
313
- if price
314
- raise(ArgumentError, 'Gas price must be an Integer') unless price.is_a?(Integer)
315
- raise(ArgumentError, 'Gas price must be a positive Integer') unless price.positive?
316
- end
332
+ to_priv(priv)
333
+ to_address(address)
334
+ to_amount(amount)
335
+ to_price(price)
317
336
  key = Eth::Key.new(priv: priv)
318
337
  from = key.address.to_s
319
338
  tnx =
320
339
  @mutex.synchronize do
321
- with_jsonrpc do |jr|
322
- tx = Eth::Tx.new(
323
- {
324
- chain_id: @chain,
325
- nonce: jr.eth_getTransactionCount(from, 'pending').to_i(16),
326
- gas_price: price,
327
- gas_limit: 22_000,
328
- to: address,
329
- value: amount
330
- }
331
- )
332
- tx.sign(key)
333
- hex = "0x#{tx.hex}"
334
- log_it(:debug, "Sending ETH transaction #{hex}")
335
- jr.eth_sendRawTransaction(hex)
336
- end
340
+ tx = Eth::Tx.new(
341
+ {
342
+ chain_id: @chain,
343
+ nonce: with_jsonrpc { |jr| jr.eth_getTransactionCount(from, 'pending').to_i(16) },
344
+ max_gas_fee: price,
345
+ priority_fee: tip(price),
346
+ gas_limit: 22_000,
347
+ to: address,
348
+ value: amount
349
+ }
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) }
337
355
  end
338
356
  log_it(:debug, "Sent #{amount} ETHs from #{from} to #{address}: #{tnx}")
339
357
  tnx.downcase
@@ -352,29 +370,51 @@ class ERC20::Wallet
352
370
  #
353
371
  # The +addresses+ must have +to_a()+ implemented. This method will be
354
372
  # called every +delay+ seconds. It is expected that it returns the list
355
- # 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.
377
+ #
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.
356
397
  #
357
- # The +active+ must have +append()+ and +to_a()+ implemented. This array
358
- # maintains the list of addresses that were mentioned in incoming transactions.
359
- # This array is used mostly for testing. It is suggested to always provide
360
- # an empty array.
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.
361
407
  #
362
408
  # @param [Array<String>] addresses Addresses to monitor
363
409
  # @param [Array] active List of addresses that we are actually listening to
364
410
  # @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
365
- # @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
366
412
  # @param [Integer] subscription_id Unique ID of the subscription
367
- def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(99_999), &)
368
- raise(ArgumentError, 'Addresses can\'t be nil') unless addresses
369
- raise(ArgumentError, 'Addresses must respond to .to_a()') unless addresses.respond_to?(:to_a)
370
- raise(ArgumentError, 'Active can\'t be nil') unless active
371
- raise(ArgumentError, 'Active must respond to .to_a()') unless active.respond_to?(:to_a)
372
- raise(ArgumentError, 'Active must respond to .append()') unless active.respond_to?(:append)
373
- raise(ArgumentError, 'Active must respond to .clear()') unless active.respond_to?(:clear)
374
- raise(ArgumentError, 'Delay must be an Integer') unless delay.is_a?(Integer)
375
- raise(ArgumentError, 'Delay must be a positive Integer or positive Float') unless delay.positive?
376
- raise(ArgumentError, 'Subscription ID must be an Integer') unless subscription_id.is_a?(Integer)
377
- raise(ArgumentError, '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)
378
418
  EventMachine.run do
379
419
  reaccept(addresses, active, raw:, delay:, subscription_id:, &)
380
420
  end
@@ -385,16 +425,19 @@ class ERC20::Wallet
385
425
  # @param [Array<String>] addresses Addresses to monitor
386
426
  # @param [Array] active List of addresses that we are actually listening to
387
427
  # @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
388
- # @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
389
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
390
431
  # @return [Websocket]
391
- def reaccept(addresses, active, raw:, delay:, subscription_id:, &)
432
+ def reaccept(addresses, active, raw:, delay:, subscription_id:, since: nil, &)
392
433
  u = url(http: false)
393
434
  log_it(:debug, "Connecting ##{subscription_id} to #{u.hostname}:#{u.port}...")
394
- contract = @contract
395
435
  log_url = "ws#{'s' if @ssl}://#{u.hostname}:#{u.port}"
396
436
  ws = Faye::WebSocket::Client.new(u.to_s, [], proxy: @proxy ? { origin: @proxy } : {}, ping: 60)
397
437
  timer = nil
438
+ subscription = nil
439
+ wanted = nil
440
+ height = since
398
441
  ws.on(:open) do
399
442
  safe do
400
443
  verbose do
@@ -402,30 +445,33 @@ class ERC20::Wallet
402
445
  timer =
403
446
  EventMachine.add_periodic_timer(delay) do
404
447
  next if active.to_a.sort == addresses.to_a.sort
448
+ wanted = to_addresses(addresses).dup
405
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
406
462
  ws.send(
407
463
  {
408
464
  jsonrpc: '2.0',
409
465
  id: subscription_id,
410
466
  method: 'eth_subscribe',
411
- params: [
412
- 'logs',
413
- {
414
- address: contract,
415
- topics: [
416
- '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
417
- nil,
418
- addresses.to_a.map { |a| "0x000000000000000000000000#{a[2..]}" }
419
- ]
420
- }
421
- ]
467
+ params: ['logs', to_filter(wanted)]
422
468
  }.to_json
423
469
  )
424
470
  # rubocop:enable Style/Send
425
471
  log_it(
426
472
  :debug,
427
- "Requested to subscribe ##{subscription_id} to #{addresses.to_a.size} addresses: " \
428
- "#{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(', ')}"
429
475
  )
430
476
  end
431
477
  end
@@ -435,35 +481,41 @@ class ERC20::Wallet
435
481
  safe do
436
482
  verbose do
437
483
  data = to_json(msg)
438
- if data['id']
439
- before = active.to_a.uniq
440
- addresses.to_a.each do |a|
441
- next if before.include?(a)
442
- active.append(a)
443
- 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) }
444
491
  log_it(
445
492
  :debug,
446
493
  "Subscribed ##{subscription_id} to #{active.to_a.size} addresses at #{log_url}: " \
447
494
  "#{active.to_a.map { |a| a[0..6] }.join(', ')}"
448
495
  )
449
- elsif data['method'] == 'eth_subscription' && data.dig('params', 'result')
450
- event = data['params']['result']
451
- if raw
452
- log_it(:debug, "New event arrived from #{event['address']}")
453
- else
454
- event = {
455
- amount: event['data'].to_i(16),
456
- from: "0x#{event['topics'][1][26..].downcase}",
457
- to: "0x#{event['topics'][2][26..].downcase}",
458
- txn: event['transactionHash'].downcase
459
- }
460
- log_it(
461
- :debug,
462
- "Payment of #{event[:amount]} tokens arrived at ##{subscription_id} " \
463
- "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
464
505
  )
506
+ # rubocop:enable Style/Send
507
+ log_it(:debug, "Requested ##{subscription_id} the logs of the blocks after ##{since}")
508
+ since = nil
465
509
  end
466
- 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, &)
467
519
  end
468
520
  end
469
521
  end
@@ -474,7 +526,9 @@ class ERC20::Wallet
474
526
  log_it(:debug, "Disconnected ##{subscription_id} from #{log_url}")
475
527
  active.clear
476
528
  timer&.cancel
477
- 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
478
532
  end
479
533
  end
480
534
  end
@@ -487,9 +541,55 @@ class ERC20::Wallet
487
541
  end
488
542
  end
489
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
+
490
589
  def to_json(msg)
491
590
  JSON.parse(msg.data)
492
- 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}")
493
593
  {}
494
594
  end
495
595
 
@@ -522,26 +622,29 @@ class ERC20::Wallet
522
622
  end
523
623
  end
524
624
  endpoints = [url.to_s] + @fallbacks
525
- attempt = 0
625
+ budget = @attempts * endpoints.size
626
+ tried = 0
526
627
  begin
527
- attempt += 1
528
- u = URI.parse(endpoints[(attempt - 1) % endpoints.size])
628
+ u = URI.parse(endpoints[tried % endpoints.size])
629
+ tried += 1
529
630
  elapsed(@log, good: "Talked to #{u.host}:#{u.port}") do
530
631
  yield(JSONRPC::Client.new(u.to_s, opts))
531
632
  end
532
633
  rescue StandardError => e
533
- raise if attempt >= @attempts
534
- pause = 2**(attempt - 1)
535
- log_it(:debug, "Attempt #{attempt}/#{@attempts} to #{u.host} failed (#{e.class}), retrying in #{pause}s")
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")
536
637
  sleep(pause)
537
638
  retry
538
639
  end
539
640
  end
540
641
 
642
+ def tip(price)
643
+ [GAS_PRICE_TIP, price].min
644
+ end
645
+
541
646
  def to_pay_data(address, amount)
542
- to_clean = address.downcase.sub(/^0x/, '')
543
- amt_hex = amount.to_s(16)
544
- "0xa9059cbb#{('0' * (64 - to_clean.size)) + to_clean}#{('0' * (64 - amt_hex.size)) + amt_hex}"
647
+ "0xa9059cbb#{format('%064x', address.to_i(16))}#{format('%064x', amount)}"
545
648
  end
546
649
 
547
650
  def log_it(method, msg)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: erc20
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
@@ -141,6 +141,7 @@ files:
141
141
  - hardhat/ignition/modules/Foo.ts
142
142
  - hardhat/package.json
143
143
  - lib/erc20.rb
144
+ - lib/erc20/checks.rb
144
145
  - lib/erc20/erc20.rb
145
146
  - lib/erc20/fake_wallet.rb
146
147
  - lib/erc20/wallet.rb