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 +4 -4
- data/Gemfile +5 -4
- data/Gemfile.lock +37 -38
- data/README.md +10 -2
- data/bin/erc20 +16 -8
- data/features/dry.feature +9 -1
- data/lib/erc20/checks.rb +102 -0
- data/lib/erc20/erc20.rb +1 -1
- data/lib/erc20/fake_wallet.rb +55 -16
- data/lib/erc20/wallet.rb +267 -164
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aae427d3d4d3c935aa7ea204031b9bdd85c67ce26fd182cb3a3d4ac8c2ed3cd4
|
|
4
|
+
data.tar.gz: 54fce4882a54f7de725bcc40ca234538900dfa67bcae210b23544ac0806d31d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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', '~>
|
|
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', '~>
|
|
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
|
|
28
|
-
gem 'simplecov-cobertura', '~>
|
|
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.
|
|
24
|
+
concurrent-ruby (1.3.8)
|
|
25
25
|
crack (1.0.1)
|
|
26
26
|
bigdecimal
|
|
27
27
|
rexml
|
|
28
|
-
cucumber (
|
|
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 (
|
|
32
|
+
cucumber-core (>= 16.2.0, < 17)
|
|
33
33
|
cucumber-cucumber-expressions (> 17, < 20)
|
|
34
|
-
cucumber-html-formatter (> 21, <
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
153
|
+
rdoc (8.0.0)
|
|
151
154
|
erb
|
|
152
|
-
|
|
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.
|
|
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.
|
|
171
|
+
rubocop-ast (1.50.0)
|
|
168
172
|
parser (>= 3.3.7.2)
|
|
169
173
|
prism (~> 1.7)
|
|
170
|
-
rubocop-elegant (0.
|
|
174
|
+
rubocop-elegant (0.7.1)
|
|
171
175
|
lint_roller (~> 1.1)
|
|
172
176
|
rubocop (~> 1.75)
|
|
173
|
-
rubocop-minitest (0.
|
|
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.
|
|
190
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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 (~>
|
|
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 (~>
|
|
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
|
|
259
|
-
simplecov-cobertura (~>
|
|
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, #
|
|
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
|
|
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$/, '')
|
|
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(/^\$/, '')
|
|
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
|
|
168
|
+
elsif /^[0-9]+wei$/.match?(amount)
|
|
169
169
|
amount.gsub(/wei$/, '').to_i
|
|
170
|
-
elsif
|
|
171
|
-
(amount.gsub(/gwei$/, '')
|
|
172
|
-
elsif
|
|
173
|
-
(amount.gsub(/eth$/, '')
|
|
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
|
|
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
|
data/lib/erc20/checks.rb
ADDED
|
@@ -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
data/lib/erc20/fake_wallet.rb
CHANGED
|
@@ -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]
|
|
70
|
-
# @
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
88
|
-
#
|
|
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
|
-
|
|
91
|
-
@history << { method: :gas_price, result:
|
|
92
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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 [
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
# @
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
{
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
#
|
|
358
|
-
#
|
|
359
|
-
#
|
|
360
|
-
#
|
|
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 [
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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 [
|
|
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 #{
|
|
428
|
-
"#{
|
|
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['
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
625
|
+
budget = @attempts * endpoints.size
|
|
626
|
+
tried = 0
|
|
526
627
|
begin
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
534
|
-
pause = 2**(
|
|
535
|
-
log_it(:debug, "Attempt #{
|
|
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
|
-
|
|
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.
|
|
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
|