erc20 0.0.4 → 0.0.6
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 +1 -0
- data/Gemfile.lock +13 -12
- data/README.md +29 -6
- data/Rakefile +2 -0
- data/erc20.gemspec +8 -6
- data/lib/erc20/wallet.rb +152 -84
- data/lib/erc20.rb +1 -1
- data/test/erc20/test_wallet.rb +217 -27
- data/test/test__helper.rb +4 -2
- metadata +27 -25
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ddbc3966b9eec2d455e33dde71df0427a7a99e4d59f0df69191d8f617434c8bf
|
4
|
+
data.tar.gz: 0d577a397eee74c5dae388ca620f8a681c4bec2af8d9b6cf5514ca32cbebc992
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1f87ab9c39b5476b9b90bd44020f21970edc8830826129b0a2c6386d562887627e346c55a1e8bb1fcf3e9983d1977e6e0851b7e2fdc162217898951988b2173f
|
7
|
+
data.tar.gz: 02d6b7e7058dd6c6f3a74b81ece853e674020a4df84b6fa439b74438ff8227616a76cf3abd1a6f82fdfb7f34391c4e541f96791e404e8e84cb3f364d72e92771
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -2,11 +2,11 @@ PATH
|
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
4
|
erc20 (0.0.0)
|
5
|
-
|
6
|
-
|
7
|
-
|
5
|
+
eth (>= 0.5.13)
|
6
|
+
faye-websocket (>= 0.11.3)
|
7
|
+
json (>= 2.10.1)
|
8
|
+
jsonrpc-client (>= 0.1.4)
|
8
9
|
loog (> 0)
|
9
|
-
websocket-client-simple (> 0)
|
10
10
|
|
11
11
|
GEM
|
12
12
|
remote: https://rubygems.org/
|
@@ -71,13 +71,16 @@ GEM
|
|
71
71
|
scrypt (~> 3.0)
|
72
72
|
ethon (0.16.0)
|
73
73
|
ffi (>= 1.15.0)
|
74
|
-
|
74
|
+
eventmachine (1.2.7)
|
75
75
|
faraday (2.12.2)
|
76
76
|
faraday-net_http (>= 2.0, < 3.5)
|
77
77
|
json
|
78
78
|
logger
|
79
79
|
faraday-net_http (3.4.0)
|
80
80
|
net-http (>= 0.5.0)
|
81
|
+
faye-websocket (0.11.3)
|
82
|
+
eventmachine (>= 0.12.0)
|
83
|
+
websocket-driver (>= 0.5.1)
|
81
84
|
ffi (1.17.1-arm64-darwin)
|
82
85
|
ffi (1.17.1-x64-mingw-ucrt)
|
83
86
|
ffi (1.17.1-x86_64-darwin)
|
@@ -93,7 +96,7 @@ GEM
|
|
93
96
|
pp (>= 0.6.0)
|
94
97
|
rdoc (>= 4.0.0)
|
95
98
|
reline (>= 0.4.2)
|
96
|
-
json (2.10.
|
99
|
+
json (2.10.1)
|
97
100
|
jsonrpc-client (0.1.4)
|
98
101
|
faraday
|
99
102
|
multi_json (>= 1.1.0)
|
@@ -115,7 +118,6 @@ GEM
|
|
115
118
|
minitest-retry (0.2.5)
|
116
119
|
minitest (>= 5.0)
|
117
120
|
multi_json (1.15.0)
|
118
|
-
mutex_m (0.3.0)
|
119
121
|
net-http (0.6.0)
|
120
122
|
uri
|
121
123
|
nokogiri (1.18.2-arm64-darwin)
|
@@ -251,12 +253,10 @@ GEM
|
|
251
253
|
unicode-emoji (4.0.4)
|
252
254
|
uri (1.0.2)
|
253
255
|
useragent (0.16.11)
|
254
|
-
websocket (
|
255
|
-
websocket-client-simple (0.9.0)
|
256
|
+
websocket-driver (0.7.7)
|
256
257
|
base64
|
257
|
-
|
258
|
-
|
259
|
-
websocket
|
258
|
+
websocket-extensions (>= 0.1.0)
|
259
|
+
websocket-extensions (0.1.5)
|
260
260
|
yard (0.9.37)
|
261
261
|
zeitwerk (2.7.1)
|
262
262
|
|
@@ -273,6 +273,7 @@ DEPENDENCIES
|
|
273
273
|
backtrace (> 0)
|
274
274
|
donce (> 0)
|
275
275
|
erc20!
|
276
|
+
faraday (> 0)
|
276
277
|
loog (> 0)
|
277
278
|
minitest (= 5.25.4)
|
278
279
|
minitest-reporters (= 1.7.1)
|
data/README.md
CHANGED
@@ -23,18 +23,19 @@ require 'erc20'
|
|
23
23
|
w = ERC20::Wallet.new(
|
24
24
|
contract: ERC20::Wallet.USDT, # hex of it
|
25
25
|
host: 'mainnet.infura.io',
|
26
|
-
|
26
|
+
http_path: '/v3/<your-infura-key>',
|
27
|
+
ws_path: '/ws/v3/<your-infura-key>',
|
27
28
|
log: $stdout
|
28
29
|
)
|
29
30
|
|
30
|
-
# Check
|
31
|
+
# Check how many ERC20 tokens are on the given address:
|
31
32
|
usdt = w.balance(address)
|
32
33
|
|
33
|
-
# Send a few tokens to someone and get transaction hash:
|
34
|
-
|
34
|
+
# Send a few ERC20 tokens to someone and get transaction hash:
|
35
|
+
hex = w.pay(private_key, to_address, amount)
|
35
36
|
|
36
|
-
# Stay waiting, and trigger the block when
|
37
|
-
addresses = ['0x...', '0x...']
|
37
|
+
# Stay waiting, and trigger the block when new ERC20 payments show up:
|
38
|
+
addresses = ['0x...', '0x...'] # only wait for payments to these addresses
|
38
39
|
w.accept(addresses) do |event|
|
39
40
|
puts event[:amount] # how much
|
40
41
|
puts event[:from] # who sent the payment
|
@@ -55,6 +56,22 @@ To get address from private one:
|
|
55
56
|
public_hex = Eth::Key.new(priv: key).address
|
56
57
|
```
|
57
58
|
|
59
|
+
To connect to the server via [HTTP proxy] with [basic authentication]:
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
w = ERC20::Wallet.new(
|
63
|
+
host: 'go.getblock.io',
|
64
|
+
http_path: '/<your-rpc-getblock-key>',
|
65
|
+
ws_path: '/<your-ws-getblock-key>',
|
66
|
+
proxy: 'http://jeffrey:swordfish@example.com:3128' # here!
|
67
|
+
)
|
68
|
+
```
|
69
|
+
|
70
|
+
You can use [squid-proxy] [Docker] image to set up your own [HTTP proxy] server.
|
71
|
+
|
72
|
+
Of course, this library works with [Polygon], [Optimism],
|
73
|
+
and other forks of [Etherium].
|
74
|
+
|
58
75
|
## How to contribute
|
59
76
|
|
60
77
|
Read
|
@@ -78,3 +95,9 @@ If it's clean and you don't see any error messages, submit your pull request.
|
|
78
95
|
[Infura]: https://infura.io/
|
79
96
|
[Alchemy]: https://alchemy.com/
|
80
97
|
[GetBlock]: https://getblock.io/
|
98
|
+
[basic authentication]: https://en.wikipedia.org/wiki/Basic_access_authentication
|
99
|
+
[HTTP proxy]: https://en.wikipedia.org/wiki/Proxy_server
|
100
|
+
[squid-proxy]: https://github.com/yegor256/squid-proxy
|
101
|
+
[Docker]: https://www.docker.com/
|
102
|
+
[Polygon]: https://polygon.technology/
|
103
|
+
[Optimism]: https://www.optimism.io/
|
data/Rakefile
CHANGED
data/erc20.gemspec
CHANGED
@@ -31,18 +31,20 @@ Gem::Specification.new do |s|
|
|
31
31
|
s.license = 'MIT'
|
32
32
|
s.summary = 'Sending and receiving ERC20 tokens in Etherium network'
|
33
33
|
s.description =
|
34
|
-
'A simple library for making ERC20 manipulations as easy as they' \
|
35
|
-
'can be for cryptocurrency newbies'
|
34
|
+
'A simple library for making ERC20 manipulations as easy as they ' \
|
35
|
+
'can be for cryptocurrency newbies: checking balance, sending payments, ' \
|
36
|
+
'and monitoring addresses for incoming payments. The library expects ' \
|
37
|
+
'Etherium node to provide JSON RPC and Websockets API.'
|
36
38
|
s.authors = ['Yegor Bugayenko']
|
37
39
|
s.email = 'yegor256@gmail.com'
|
38
40
|
s.homepage = 'http://github.com/yegor256/erc20.rb'
|
39
41
|
s.files = `git ls-files`.split($RS)
|
40
42
|
s.rdoc_options = ['--charset=UTF-8']
|
41
43
|
s.extra_rdoc_files = ['README.md', 'LICENSE.txt']
|
42
|
-
s.add_dependency '
|
43
|
-
s.add_dependency '
|
44
|
-
s.add_dependency '
|
44
|
+
s.add_dependency 'eth', '>=0.5.13'
|
45
|
+
s.add_dependency 'faye-websocket', '>=0.11.3'
|
46
|
+
s.add_dependency 'json', '>=2.10.1'
|
47
|
+
s.add_dependency 'jsonrpc-client', '>=0.1.4'
|
45
48
|
s.add_dependency 'loog', '>0'
|
46
|
-
s.add_dependency 'websocket-client-simple', '>0'
|
47
49
|
s.metadata['rubygems_mfa_required'] = 'true'
|
48
50
|
end
|
data/lib/erc20/wallet.rb
CHANGED
@@ -21,14 +21,17 @@
|
|
21
21
|
# SOFTWARE.
|
22
22
|
|
23
23
|
require 'eth'
|
24
|
+
require 'eventmachine'
|
25
|
+
require 'faye/websocket'
|
26
|
+
require 'json'
|
24
27
|
require 'jsonrpc/client'
|
25
28
|
require 'loog'
|
26
|
-
require '
|
29
|
+
require 'uri'
|
27
30
|
require_relative '../erc20'
|
28
31
|
|
29
|
-
# A wallet.
|
32
|
+
# A wallet with ERC20 tokens on Etherium.
|
30
33
|
#
|
31
|
-
#
|
34
|
+
# Objects of this class are thread-safe.
|
32
35
|
#
|
33
36
|
# Author:: Yegor Bugayenko (yegor256@gmail.com)
|
34
37
|
# Copyright:: Copyright (c) 2025 Yegor Bugayenko
|
@@ -37,34 +40,32 @@ class ERC20::Wallet
|
|
37
40
|
# Address of USDT contract.
|
38
41
|
USDT = '0xdac17f958d2ee523a2206206994597c13d831ec7'
|
39
42
|
|
43
|
+
# These properties are read-only:
|
44
|
+
attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path
|
45
|
+
|
40
46
|
# Constructor.
|
41
47
|
# @param [String] contract Hex of the contract in Etherium
|
42
|
-
# @param [String] rpc The URL of Etherium JSON-RPC provider
|
43
48
|
# @param [Integer] chain The ID of the chain (1 for mainnet)
|
44
49
|
# @param [String] host The host to connect to
|
45
50
|
# @param [Integer] port TCP port to use
|
46
|
-
# @param [String]
|
51
|
+
# @param [String] http_path The path in the connection URL, for HTTP RPC
|
52
|
+
# @param [String] ws_path The path in the connection URL, for Websockets
|
47
53
|
# @param [Boolean] ssl Should we use SSL (for https and wss)
|
54
|
+
# @param [String] proxy The URL of the proxy to use
|
48
55
|
# @param [Object] log The destination for logs
|
49
|
-
def initialize(contract: USDT,
|
50
|
-
host: nil, port: 443,
|
56
|
+
def initialize(contract: USDT, chain: 1, log: $stdout,
|
57
|
+
host: nil, port: 443, http_path: '/', ws_path: '/',
|
58
|
+
ssl: true, proxy: nil)
|
51
59
|
@contract = contract
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
raise 'Either rpc or host+port+path are required' unless host && port && path
|
58
|
-
@rpc = "http#{ssl ? 's' : ''}://#{host}:#{port}#{path}"
|
59
|
-
end
|
60
|
-
if wss
|
61
|
-
@wss = wss
|
62
|
-
else
|
63
|
-
raise 'Either wss or host+port+path are required' unless host && port && path
|
64
|
-
@wss = "http#{ssl ? 's' : ''}://#{host}:#{port}#{path}"
|
65
|
-
end
|
60
|
+
@host = host
|
61
|
+
@port = port
|
62
|
+
@ssl = ssl
|
63
|
+
@http_path = http_path
|
64
|
+
@ws_path = ws_path
|
66
65
|
@log = log
|
67
66
|
@chain = chain
|
67
|
+
@proxy = proxy
|
68
|
+
@mutex = Mutex.new
|
68
69
|
end
|
69
70
|
|
70
71
|
# Get balance of a public address.
|
@@ -86,7 +87,7 @@ class ERC20::Wallet
|
|
86
87
|
# @param [String] priv Private key, in hex
|
87
88
|
# @param [String] address Public key, in hex
|
88
89
|
# @param [Integer] amount The amount of ERC20 tokens to send
|
89
|
-
# @param [Integer] gas_limit How much gas you
|
90
|
+
# @param [Integer] gas_limit How much gas you're ready to spend
|
90
91
|
# @param [Integer] gas_price How much gas you pay per computation unit
|
91
92
|
# @return [String] Transaction hash
|
92
93
|
def pay(priv, address, amount, gas_limit: nil, gas_price: nil)
|
@@ -98,93 +99,160 @@ class ERC20::Wallet
|
|
98
99
|
data = "0x#{func}#{to_padded}#{amt_padded}"
|
99
100
|
key = Eth::Key.new(priv: priv)
|
100
101
|
from = key.address
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
102
|
+
tnx =
|
103
|
+
@mutex.synchronize do
|
104
|
+
nonce = jsonrpc.eth_getTransactionCount(from, 'pending').to_i(16)
|
105
|
+
tx = Eth::Tx.new(
|
106
|
+
{
|
107
|
+
nonce:,
|
108
|
+
gas_price: gas_price || gas_best_price,
|
109
|
+
gas_limit: gas_limit || gas_estimate(from, data),
|
110
|
+
to: @contract,
|
111
|
+
value: 0,
|
112
|
+
data: data,
|
113
|
+
chain_id: @chain
|
114
|
+
}
|
115
|
+
)
|
116
|
+
tx.sign(key)
|
117
|
+
hex = "0x#{tx.hex}"
|
118
|
+
jsonrpc.eth_sendRawTransaction(hex)
|
119
|
+
end
|
120
|
+
@log.debug("Sent #{amount} from #{from} to #{address}: #{tnx}")
|
121
|
+
tnx
|
118
122
|
end
|
119
123
|
|
120
124
|
# Wait for incoming transactions and let the block know when they
|
121
125
|
# arrive. It's a blocking call, it's better to run it in a separate
|
122
|
-
# thread.
|
126
|
+
# thread. It will never finish. In order to stop it, you should do
|
127
|
+
# +Thread.kill+.
|
128
|
+
#
|
129
|
+
# The array with the list of addresses (+addresses+) may change its
|
130
|
+
# content on-fly. The +accept()+ method will +eht_subscribe+ to the addresses
|
131
|
+
# that are added and will +eth_unsubscribe+ from those that are removed.
|
132
|
+
# Once we actually start listening, the +active+ array will be updated
|
133
|
+
# with the list of addresses.
|
134
|
+
#
|
135
|
+
# Both +addresses+ and +active+ must have two methods implemented: +to_a()+
|
136
|
+
# and +append()+. Only these methods will be called.
|
123
137
|
#
|
124
138
|
# @param [Array<String>] addresses Addresses to monitor
|
125
|
-
# @param [Array]
|
139
|
+
# @param [Array] active List of addresses that we are actually listening to
|
126
140
|
# @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
|
127
|
-
|
128
|
-
|
141
|
+
# @param [Integer] delay How many seconds to wait between +eth_subscribe+ calls
|
142
|
+
def accept(addresses, active = [], raw: false, delay: 1)
|
143
|
+
EventMachine.run do
|
144
|
+
u = url(http: false)
|
145
|
+
@log.debug("Connecting to #{u.hostname}:#{u.port}...")
|
146
|
+
ws = Faye::WebSocket::Client.new(u.to_s, [], proxy: @proxy ? { origin: @proxy } : {})
|
129
147
|
log = @log
|
130
148
|
contract = @contract
|
131
|
-
|
149
|
+
id = rand(99_999)
|
150
|
+
attempt = []
|
132
151
|
ws.on(:open) do
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
id: 1,
|
137
|
-
method: 'eth_subscribe',
|
138
|
-
params: [
|
139
|
-
'logs',
|
140
|
-
{
|
141
|
-
address: contract,
|
142
|
-
topics: [
|
143
|
-
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
|
144
|
-
nil,
|
145
|
-
addresses.map { |a| "0x000000000000000000000000#{a[2..]}" }
|
146
|
-
]
|
147
|
-
}
|
148
|
-
]
|
149
|
-
}
|
150
|
-
ws.send(msg.to_json)
|
151
|
-
connected.append(1)
|
152
|
-
log.debug("Subscribed to #{addresses.count} addresses")
|
152
|
+
verbose do
|
153
|
+
log.debug("Connected to ws://#{u.hostname}:#{u.port}")
|
154
|
+
end
|
153
155
|
end
|
154
156
|
ws.on(:message) do |msg|
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
157
|
+
verbose do
|
158
|
+
data =
|
159
|
+
begin
|
160
|
+
JSON.parse(msg.data)
|
161
|
+
rescue StandardError
|
162
|
+
{}
|
163
|
+
end
|
164
|
+
if data['id']
|
165
|
+
before = active.to_a
|
166
|
+
attempt.each do |a|
|
167
|
+
active.append(a) unless before.include?(a)
|
168
|
+
end
|
169
|
+
log.debug(
|
170
|
+
"Subscribed ##{id} to #{active.to_a.size} addresses: " \
|
171
|
+
"#{active.to_a.map { |a| a[0..6] }.join(', ')}"
|
172
|
+
)
|
173
|
+
elsif data['method'] == 'eth_subscription' && data.dig('params', 'result')
|
174
|
+
event = data['params']['result']
|
175
|
+
if raw
|
176
|
+
log.debug("New event arrived from #{event['address']}")
|
177
|
+
else
|
178
|
+
event = {
|
179
|
+
amount: event['data'].to_i(16),
|
180
|
+
from: "0x#{event['topics'][1][26..].downcase}",
|
181
|
+
to: "0x#{event['topics'][2][26..].downcase}"
|
182
|
+
}
|
183
|
+
log.debug("Payment of #{event[:amount]} tokens arrived from #{event[:from]} to #{event[:to]}")
|
184
|
+
end
|
185
|
+
yield event
|
160
186
|
end
|
161
|
-
if data['method'] == 'eth_subscription' && data.dig('params', 'result')
|
162
|
-
event = data['params']['result']
|
163
|
-
unless raw
|
164
|
-
event = {
|
165
|
-
amount: event['data'].to_i(16),
|
166
|
-
from: "0x#{event['topics'][1][26..].downcase}",
|
167
|
-
to: "0x#{event['topics'][2][26..].downcase}"
|
168
|
-
}
|
169
|
-
end
|
170
|
-
log.debug("New event arrived from #{event['address']}")
|
171
|
-
yield event
|
172
187
|
end
|
173
188
|
end
|
174
|
-
ws.on(:close) do
|
175
|
-
|
189
|
+
ws.on(:close) do
|
190
|
+
verbose do
|
191
|
+
log.debug("Disconnected from ws://#{u.hostname}:#{u.port}")
|
192
|
+
end
|
176
193
|
end
|
177
194
|
ws.on(:error) do |e|
|
178
|
-
|
195
|
+
verbose do
|
196
|
+
log.debug("Error at #{u.hostname}: #{e.message}")
|
197
|
+
end
|
198
|
+
end
|
199
|
+
EventMachine.add_periodic_timer(delay) do
|
200
|
+
next if active.to_a.sort == addresses.to_a.sort
|
201
|
+
attempt = addresses.to_a
|
202
|
+
ws.send(
|
203
|
+
{
|
204
|
+
jsonrpc: '2.0',
|
205
|
+
id:,
|
206
|
+
method: 'eth_subscribe',
|
207
|
+
params: [
|
208
|
+
'logs',
|
209
|
+
{
|
210
|
+
address: contract,
|
211
|
+
topics: [
|
212
|
+
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
|
213
|
+
nil,
|
214
|
+
addresses.to_a.map { |a| "0x000000000000000000000000#{a[2..]}" }
|
215
|
+
]
|
216
|
+
}
|
217
|
+
]
|
218
|
+
}.to_json
|
219
|
+
)
|
220
|
+
log.debug(
|
221
|
+
"Requested to subscribe ##{id} to #{addresses.to_a.size} addresses: " \
|
222
|
+
"#{addresses.to_a.map { |a| a[0..6] }.join(', ')}"
|
223
|
+
)
|
179
224
|
end
|
180
225
|
end
|
181
226
|
end
|
182
227
|
|
183
228
|
private
|
184
229
|
|
230
|
+
def verbose
|
231
|
+
yield
|
232
|
+
rescue StandardError => e
|
233
|
+
@log.error(Backtrace.new(e).to_s)
|
234
|
+
raise e
|
235
|
+
end
|
236
|
+
|
237
|
+
def url(http: true)
|
238
|
+
URI.parse("#{http ? 'http' : 'ws'}#{@ssl ? 's' : ''}://#{@host}:#{@port}#{http ? @http_path : @ws_path}")
|
239
|
+
end
|
240
|
+
|
185
241
|
def jsonrpc
|
186
242
|
JSONRPC.logger = Loog::NULL
|
187
|
-
|
243
|
+
connection =
|
244
|
+
if @proxy
|
245
|
+
uri = URI.parse(@proxy)
|
246
|
+
Faraday.new do |f|
|
247
|
+
f.adapter(Faraday.default_adapter)
|
248
|
+
f.proxy = {
|
249
|
+
uri: "#{uri.scheme}://#{uri.hostname}:#{uri.port}",
|
250
|
+
user: uri.user,
|
251
|
+
password: uri.password
|
252
|
+
}
|
253
|
+
end
|
254
|
+
end
|
255
|
+
JSONRPC::Client.new(url, connection:)
|
188
256
|
end
|
189
257
|
|
190
258
|
def gas_estimate(from, data)
|
data/lib/erc20.rb
CHANGED
data/test/erc20/test_wallet.rb
CHANGED
@@ -23,22 +23,25 @@
|
|
23
23
|
require 'backtrace'
|
24
24
|
require 'donce'
|
25
25
|
require 'eth'
|
26
|
+
require 'faraday'
|
26
27
|
require 'loog'
|
28
|
+
require 'minitest/autorun'
|
27
29
|
require 'random-port'
|
28
30
|
require 'shellwords'
|
31
|
+
require 'threads'
|
29
32
|
require 'typhoeus'
|
30
|
-
require 'minitest/autorun'
|
31
33
|
require_relative '../../lib/erc20'
|
32
34
|
require_relative '../../lib/erc20/wallet'
|
35
|
+
require_relative '../test__helper'
|
33
36
|
|
34
37
|
# Test.
|
35
38
|
# Author:: Yegor Bugayenko (yegor256@gmail.com)
|
36
39
|
# Copyright:: Copyright (c) 2025 Yegor Bugayenko
|
37
40
|
# License:: MIT
|
38
41
|
class TestWallet < Minitest::Test
|
39
|
-
# At this address, in
|
42
|
+
# At this address, in Etherium mainnet, there are a few USDT tokens. I won't
|
40
43
|
# move them anyway, that's why tests can use this address forever.
|
41
|
-
|
44
|
+
STABLE = '0xEB2fE8872A6f1eDb70a2632EA1f869AB131532f6'
|
42
45
|
|
43
46
|
# One guy private hex.
|
44
47
|
JEFF = '81a9b2114d53731ecc84b261ef6c0387dde34d5907fe7b441240cc21d61bf80a'
|
@@ -47,7 +50,7 @@ class TestWallet < Minitest::Test
|
|
47
50
|
WALTER = '91f9111b1744d55361e632771a4e53839e9442a9fef45febc0a5c838c686a15b'
|
48
51
|
|
49
52
|
def test_checks_balance_on_mainnet
|
50
|
-
b = mainnet.balance(
|
53
|
+
b = mainnet.balance(STABLE)
|
51
54
|
refute_nil(b)
|
52
55
|
assert_equal(27_258_889, b)
|
53
56
|
end
|
@@ -61,18 +64,28 @@ class TestWallet < Minitest::Test
|
|
61
64
|
|
62
65
|
def test_fails_with_invalid_infura_key
|
63
66
|
w = ERC20::Wallet.new(
|
64
|
-
|
65
|
-
|
66
|
-
log:
|
67
|
+
host: 'mainnet.infura.io',
|
68
|
+
http_path: '/v3/invalid-key-here',
|
69
|
+
log: loog
|
67
70
|
)
|
68
|
-
assert_raises(StandardError) { w.balance(
|
71
|
+
assert_raises(StandardError) { w.balance(STABLE) }
|
69
72
|
end
|
70
73
|
|
71
74
|
def test_checks_balance_on_testnet
|
72
|
-
|
73
|
-
b
|
75
|
+
b = testnet.balance(STABLE)
|
76
|
+
refute_nil(b)
|
77
|
+
assert_predicate(b, :zero?)
|
78
|
+
end
|
79
|
+
|
80
|
+
def test_checks_balance_on_polygon
|
81
|
+
w = ERC20::Wallet.new(
|
82
|
+
contract: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
|
83
|
+
host: 'polygon-mainnet.infura.io', http_path: "/v3/#{env('INFURA_KEY')}",
|
84
|
+
log: loog
|
85
|
+
)
|
86
|
+
b = w.balance(STABLE)
|
74
87
|
refute_nil(b)
|
75
|
-
assert_predicate(b, :
|
88
|
+
assert_predicate(b, :zero?)
|
76
89
|
end
|
77
90
|
|
78
91
|
def test_checks_balance_on_hardhat
|
@@ -82,6 +95,15 @@ class TestWallet < Minitest::Test
|
|
82
95
|
end
|
83
96
|
end
|
84
97
|
|
98
|
+
def test_checks_balance_on_hardhat_in_threads
|
99
|
+
on_hardhat do |wallet|
|
100
|
+
Threads.new.assert do
|
101
|
+
b = wallet.balance(Eth::Key.new(priv: JEFF).address.to_s)
|
102
|
+
assert_equal(123_000_100_000, b)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
85
107
|
def test_pays_on_hardhat
|
86
108
|
on_hardhat do |wallet|
|
87
109
|
to = Eth::Key.new(priv: WALTER).address.to_s
|
@@ -94,21 +116,34 @@ class TestWallet < Minitest::Test
|
|
94
116
|
end
|
95
117
|
end
|
96
118
|
|
119
|
+
def test_pays_on_hardhat_in_threads
|
120
|
+
on_hardhat do |wallet|
|
121
|
+
to = Eth::Key.new(priv: WALTER).address.to_s
|
122
|
+
before = wallet.balance(to)
|
123
|
+
sum = 42_000
|
124
|
+
mul = 10
|
125
|
+
Threads.new(mul).assert do
|
126
|
+
wallet.pay(JEFF, to, sum)
|
127
|
+
end
|
128
|
+
assert_equal(before + (sum * mul), wallet.balance(to))
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
97
132
|
def test_accepts_payments_on_hardhat
|
98
133
|
walter = Eth::Key.new(priv: WALTER).address.to_s.downcase
|
99
134
|
jeff = Eth::Key.new(priv: JEFF).address.to_s.downcase
|
100
135
|
on_hardhat do |wallet|
|
101
|
-
|
136
|
+
active = []
|
102
137
|
event = nil
|
103
138
|
daemon =
|
104
139
|
Thread.new do
|
105
|
-
wallet.accept([walter, jeff],
|
140
|
+
wallet.accept([walter, jeff], active) do |e|
|
106
141
|
event = e
|
107
142
|
end
|
108
143
|
rescue StandardError => e
|
109
|
-
|
144
|
+
loog.error(Backtrace.new(e))
|
110
145
|
end
|
111
|
-
wait_for { !
|
146
|
+
wait_for { !active.empty? }
|
112
147
|
sum = 77_000
|
113
148
|
wallet.pay(JEFF, walter, sum)
|
114
149
|
wait_for { !event.nil? }
|
@@ -120,8 +155,112 @@ class TestWallet < Minitest::Test
|
|
120
155
|
end
|
121
156
|
end
|
122
157
|
|
158
|
+
def test_accepts_payments_on_changing_addresses_on_hardhat
|
159
|
+
walter = Eth::Key.new(priv: WALTER).address.to_s.downcase
|
160
|
+
jeff = Eth::Key.new(priv: JEFF).address.to_s.downcase
|
161
|
+
addresses = Primitivo.new([walter])
|
162
|
+
on_hardhat do |wallet|
|
163
|
+
active = Primitivo.new([])
|
164
|
+
event = nil
|
165
|
+
daemon =
|
166
|
+
Thread.new do
|
167
|
+
wallet.accept(addresses, active) do |e|
|
168
|
+
event = e
|
169
|
+
end
|
170
|
+
rescue StandardError => e
|
171
|
+
loog.error(Backtrace.new(e))
|
172
|
+
end
|
173
|
+
wait_for { active.to_a.include?(walter) }
|
174
|
+
sum1 = 453_000
|
175
|
+
wallet.pay(JEFF, walter, sum1)
|
176
|
+
wait_for { !event.nil? }
|
177
|
+
assert_equal(sum1, event[:amount])
|
178
|
+
sum2 = 22_000
|
179
|
+
event = nil
|
180
|
+
addresses.append(jeff)
|
181
|
+
wait_for { active.to_a.include?(jeff) }
|
182
|
+
wallet.pay(WALTER, jeff, sum2)
|
183
|
+
wait_for { !event.nil? }
|
184
|
+
assert_equal(sum2, event[:amount])
|
185
|
+
daemon.kill
|
186
|
+
daemon.join(30)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def test_accepts_payments_on_hardhat_via_proxy
|
191
|
+
via_proxy do |proxy|
|
192
|
+
walter = Eth::Key.new(priv: WALTER).address.to_s.downcase
|
193
|
+
jeff = Eth::Key.new(priv: JEFF).address.to_s.downcase
|
194
|
+
on_hardhat do |w|
|
195
|
+
wallet = through_proxy(w, proxy)
|
196
|
+
active = []
|
197
|
+
event = nil
|
198
|
+
daemon =
|
199
|
+
Thread.new do
|
200
|
+
wallet.accept([walter, jeff], active) do |e|
|
201
|
+
event = e
|
202
|
+
end
|
203
|
+
rescue StandardError => e
|
204
|
+
loog.error(Backtrace.new(e))
|
205
|
+
end
|
206
|
+
wait_for { !active.empty? }
|
207
|
+
sum = 55_000
|
208
|
+
wallet.pay(JEFF, walter, sum)
|
209
|
+
wait_for { !event.nil? }
|
210
|
+
daemon.kill
|
211
|
+
daemon.join(30)
|
212
|
+
assert_equal(sum, event[:amount])
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def test_accepts_payments_on_mainnet
|
218
|
+
active = []
|
219
|
+
failed = false
|
220
|
+
net = mainnet
|
221
|
+
daemon =
|
222
|
+
Thread.new do
|
223
|
+
net.accept([STABLE], active) do |_|
|
224
|
+
# ignore it
|
225
|
+
end
|
226
|
+
rescue StandardError => e
|
227
|
+
failed = true
|
228
|
+
loog.error(Backtrace.new(e))
|
229
|
+
end
|
230
|
+
wait_for { !active.empty? }
|
231
|
+
daemon.kill
|
232
|
+
daemon.join(30)
|
233
|
+
refute(failed)
|
234
|
+
end
|
235
|
+
|
236
|
+
def test_checks_balance_via_proxy
|
237
|
+
via_proxy do |proxy|
|
238
|
+
on_hardhat do |w|
|
239
|
+
wallet = through_proxy(w, proxy)
|
240
|
+
b = wallet.balance(Eth::Key.new(priv: JEFF).address.to_s)
|
241
|
+
assert_equal(123_000_100_000, b)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def test_checks_balance_via_proxy_on_mainnet
|
247
|
+
via_proxy do |proxy|
|
248
|
+
on_hardhat do
|
249
|
+
w = ERC20::Wallet.new(
|
250
|
+
host: 'mainnet.infura.io', http_path: "/v3/#{env('INFURA_KEY')}",
|
251
|
+
proxy:, log: loog
|
252
|
+
)
|
253
|
+
assert_equal(27_258_889, w.balance(STABLE))
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
123
258
|
private
|
124
259
|
|
260
|
+
def loog
|
261
|
+
ENV['RAKE'] ? Loog::ERRORS : Loog::VERBOSE
|
262
|
+
end
|
263
|
+
|
125
264
|
def wait_for
|
126
265
|
start = Time.now
|
127
266
|
loop do
|
@@ -145,29 +284,66 @@ class TestWallet < Minitest::Test
|
|
145
284
|
|
146
285
|
def mainnet
|
147
286
|
[
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
287
|
+
{
|
288
|
+
host: 'mainnet.infura.io',
|
289
|
+
http_path: "/v3/#{env('INFURA_KEY')}",
|
290
|
+
ws_path: "/ws/v3/#{env('INFURA_KEY')}"
|
291
|
+
},
|
292
|
+
{
|
293
|
+
host: 'go.getblock.io',
|
294
|
+
http_path: "/#{env('GETBLOCK_KEY')}",
|
295
|
+
ws_path: "/#{env('GETBLOCK_WS_KEY')}"
|
296
|
+
}
|
297
|
+
].map do |server|
|
298
|
+
ERC20::Wallet.new(host: server[:host], http_path: server[:http_path], ws_path: server[:ws_path], log: loog)
|
152
299
|
end.sample
|
153
300
|
end
|
154
301
|
|
155
302
|
def testnet
|
156
303
|
[
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
304
|
+
{
|
305
|
+
host: 'sepolia.infura.io',
|
306
|
+
http_path: "/v3/#{env('INFURA_KEY')}",
|
307
|
+
ws_path: "/ws/v3/#{env('INFURA_KEY')}"
|
308
|
+
},
|
309
|
+
{
|
310
|
+
host: 'go.getblock.io',
|
311
|
+
http_path: "/#{env('GETBLOCK_SEPOILA_KEY')}",
|
312
|
+
ws_path: "/#{env('GETBLOCK_SEPOILA_KEY')}"
|
313
|
+
}
|
314
|
+
].map do |server|
|
315
|
+
ERC20::Wallet.new(host: server[:host], http_path: server[:http_path], ws_path: server[:ws_path], log: loog)
|
161
316
|
end.sample
|
162
317
|
end
|
163
318
|
|
319
|
+
def through_proxy(wallet, proxy)
|
320
|
+
ERC20::Wallet.new(
|
321
|
+
contract: wallet.contract, chain: wallet.chain,
|
322
|
+
host: donce_host, port: wallet.port, http_path: wallet.http_path, ws_path: wallet.ws_path,
|
323
|
+
ssl: wallet.ssl, proxy:, log: loog
|
324
|
+
)
|
325
|
+
end
|
326
|
+
|
327
|
+
def via_proxy
|
328
|
+
RandomPort::Pool::SINGLETON.acquire do |port|
|
329
|
+
donce(
|
330
|
+
image: 'yegor256/squid-proxy:latest',
|
331
|
+
ports: { port => 3128 },
|
332
|
+
env: { 'USERNAME' => 'jeffrey', 'PASSWORD' => 'swordfish' },
|
333
|
+
root: true, log: loog
|
334
|
+
) do
|
335
|
+
yield "http://jeffrey:swordfish@localhost:#{port}"
|
336
|
+
end
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
164
340
|
def on_hardhat
|
165
341
|
RandomPort::Pool::SINGLETON.acquire do |port|
|
166
342
|
donce(
|
167
343
|
home: File.join(__dir__, '../../hardhat'),
|
168
344
|
ports: { port => 8545 },
|
169
345
|
command: 'npx hardhat node',
|
170
|
-
log:
|
346
|
+
log: loog
|
171
347
|
) do
|
172
348
|
wait_for_port(port)
|
173
349
|
cmd = [
|
@@ -180,16 +356,30 @@ class TestWallet < Minitest::Test
|
|
180
356
|
home: File.join(__dir__, '../../hardhat'),
|
181
357
|
command: "/bin/bash -c #{Shellwords.escape(cmd)}",
|
182
358
|
build_args: { 'HOST' => donce_host, 'PORT' => port },
|
183
|
-
log:
|
359
|
+
log: loog,
|
184
360
|
root: true
|
185
361
|
).split("\n").last
|
186
362
|
wallet = ERC20::Wallet.new(
|
187
363
|
contract:, chain: 4242,
|
188
|
-
host: 'localhost', port:,
|
189
|
-
log:
|
364
|
+
host: 'localhost', port:, http_path: '/', ws_path: '/', ssl: false,
|
365
|
+
log: loog
|
190
366
|
)
|
191
367
|
yield wallet
|
192
368
|
end
|
193
369
|
end
|
194
370
|
end
|
371
|
+
|
372
|
+
class Primitivo
|
373
|
+
def initialize(array)
|
374
|
+
@array = array
|
375
|
+
end
|
376
|
+
|
377
|
+
def to_a
|
378
|
+
@array.to_a
|
379
|
+
end
|
380
|
+
|
381
|
+
def append(item)
|
382
|
+
@array.append(item)
|
383
|
+
end
|
384
|
+
end
|
195
385
|
end
|
data/test/test__helper.rb
CHANGED
@@ -35,5 +35,7 @@ require 'minitest/reporters'
|
|
35
35
|
Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new]
|
36
36
|
|
37
37
|
# To make tests retry on failure:
|
38
|
-
|
39
|
-
|
38
|
+
if ENV['RAKE']
|
39
|
+
require 'minitest/retry'
|
40
|
+
Minitest::Retry.use!(methods_to_skip: [])
|
41
|
+
end
|
metadata
CHANGED
@@ -1,73 +1,73 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: erc20
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Yegor Bugayenko
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-02-
|
11
|
+
date: 2025-02-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: eth
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - "
|
17
|
+
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
19
|
+
version: 0.5.13
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
|
-
- - "
|
24
|
+
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
26
|
+
version: 0.5.13
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: faye-websocket
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
|
-
- - "
|
31
|
+
- - ">="
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
33
|
+
version: 0.11.3
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
|
-
- - "
|
38
|
+
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
40
|
+
version: 0.11.3
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
42
|
+
name: json
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
|
-
- - "
|
45
|
+
- - ">="
|
46
46
|
- !ruby/object:Gem::Version
|
47
|
-
version:
|
47
|
+
version: 2.10.1
|
48
48
|
type: :runtime
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
|
-
- - "
|
52
|
+
- - ">="
|
53
53
|
- !ruby/object:Gem::Version
|
54
|
-
version:
|
54
|
+
version: 2.10.1
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
|
-
name:
|
56
|
+
name: jsonrpc-client
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
58
58
|
requirements:
|
59
|
-
- - "
|
59
|
+
- - ">="
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version:
|
61
|
+
version: 0.1.4
|
62
62
|
type: :runtime
|
63
63
|
prerelease: false
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
65
65
|
requirements:
|
66
|
-
- - "
|
66
|
+
- - ">="
|
67
67
|
- !ruby/object:Gem::Version
|
68
|
-
version:
|
68
|
+
version: 0.1.4
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
|
-
name:
|
70
|
+
name: loog
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
72
72
|
requirements:
|
73
73
|
- - ">"
|
@@ -80,8 +80,10 @@ dependencies:
|
|
80
80
|
- - ">"
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '0'
|
83
|
-
description: A simple library for making ERC20 manipulations as easy as
|
84
|
-
for cryptocurrency newbies
|
83
|
+
description: 'A simple library for making ERC20 manipulations as easy as they can
|
84
|
+
be for cryptocurrency newbies: checking balance, sending payments, and monitoring
|
85
|
+
addresses for incoming payments. The library expects Etherium node to provide JSON
|
86
|
+
RPC and Websockets API.'
|
85
87
|
email: yegor256@gmail.com
|
86
88
|
executables: []
|
87
89
|
extensions: []
|