erc20 0.0.5 → 0.0.7
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/README.md +2 -1
- data/lib/erc20/wallet.rb +106 -57
- data/lib/erc20.rb +1 -1
- data/test/erc20/test_wallet.rb +83 -12
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 47883e48393686ed5d357fb9f5f762e0744a30a8f0999724dd2ddba369ac75ae
|
4
|
+
data.tar.gz: bad63b9aa6269a79cc2276c10c0f6aec02d8bf94bf18057b89fc0e9f896833be
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bf78c2548b09b33c9e67ba7476d9e14576f9be07ca1cbb1620067899451c96c587a87849d7c86c88ec449254362db9d921a4d0fde9534954b8ad9c48405f4b51
|
7
|
+
data.tar.gz: 224500ace20e3abaa25bbdc4a6be32fe7fca7b57b7d7b2bf4a4b01a8dac50a3fb45bf2a70c13f2f737b502a5ea80c4f452eeda5d0737ed6c5087d1458b590f20
|
data/README.md
CHANGED
@@ -37,7 +37,8 @@ hex = w.pay(private_key, to_address, amount)
|
|
37
37
|
# Stay waiting, and trigger the block when new ERC20 payments show up:
|
38
38
|
addresses = ['0x...', '0x...'] # only wait for payments to these addresses
|
39
39
|
w.accept(addresses) do |event|
|
40
|
-
puts event[:
|
40
|
+
puts event[:txt] # hash of transaction
|
41
|
+
puts event[:amount] # how much, in tokens (1000000 = $1 USDT)
|
41
42
|
puts event[:from] # who sent the payment
|
42
43
|
puts event[:to] # who was the receiver
|
43
44
|
end
|
data/lib/erc20/wallet.rb
CHANGED
@@ -29,9 +29,9 @@ require 'loog'
|
|
29
29
|
require 'uri'
|
30
30
|
require_relative '../erc20'
|
31
31
|
|
32
|
-
# A wallet.
|
32
|
+
# A wallet with ERC20 tokens on Etherium.
|
33
33
|
#
|
34
|
-
#
|
34
|
+
# Objects of this class are thread-safe.
|
35
35
|
#
|
36
36
|
# Author:: Yegor Bugayenko (yegor256@gmail.com)
|
37
37
|
# Copyright:: Copyright (c) 2025 Yegor Bugayenko
|
@@ -65,6 +65,7 @@ class ERC20::Wallet
|
|
65
65
|
@log = log
|
66
66
|
@chain = chain
|
67
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,23 +99,26 @@ 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
|
@@ -122,22 +126,84 @@ class ERC20::Wallet
|
|
122
126
|
# thread. It will never finish. In order to stop it, you should do
|
123
127
|
# +Thread.kill+.
|
124
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.
|
137
|
+
#
|
125
138
|
# @param [Array<String>] addresses Addresses to monitor
|
126
|
-
# @param [Array]
|
139
|
+
# @param [Array] active List of addresses that we are actually listening to
|
127
140
|
# @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
|
128
|
-
|
129
|
-
|
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
|
130
144
|
u = url(http: false)
|
131
|
-
@log.debug("Connecting to #{u}...")
|
132
|
-
ws = Faye::WebSocket::Client.new(u, [], proxy: @proxy ? { origin: @proxy } : {})
|
145
|
+
@log.debug("Connecting to #{u.hostname}:#{u.port}...")
|
146
|
+
ws = Faye::WebSocket::Client.new(u.to_s, [], proxy: @proxy ? { origin: @proxy } : {})
|
133
147
|
log = @log
|
134
148
|
contract = @contract
|
149
|
+
id = rand(99_999)
|
150
|
+
attempt = []
|
135
151
|
ws.on(:open) do
|
136
|
-
|
152
|
+
verbose do
|
153
|
+
log.debug("Connected to ws://#{u.hostname}:#{u.port}")
|
154
|
+
end
|
155
|
+
end
|
156
|
+
ws.on(:message) do |msg|
|
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
|
+
txn: event['transactionHash']
|
183
|
+
}
|
184
|
+
log.debug("Payment of #{event[:amount]} tokens arrived from #{event[:from]} to #{event[:to]}")
|
185
|
+
end
|
186
|
+
yield event
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
ws.on(:close) do
|
191
|
+
verbose do
|
192
|
+
log.debug("Disconnected from ws://#{u.hostname}:#{u.port}")
|
193
|
+
end
|
194
|
+
end
|
195
|
+
ws.on(:error) do |e|
|
196
|
+
verbose do
|
197
|
+
log.debug("Error at #{u.hostname}: #{e.message}")
|
198
|
+
end
|
199
|
+
end
|
200
|
+
EventMachine.add_periodic_timer(delay) do
|
201
|
+
next if active.to_a.sort == addresses.to_a.sort
|
202
|
+
attempt = addresses.to_a
|
137
203
|
ws.send(
|
138
204
|
{
|
139
205
|
jsonrpc: '2.0',
|
140
|
-
id
|
206
|
+
id:,
|
141
207
|
method: 'eth_subscribe',
|
142
208
|
params: [
|
143
209
|
'logs',
|
@@ -146,48 +212,31 @@ class ERC20::Wallet
|
|
146
212
|
topics: [
|
147
213
|
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
|
148
214
|
nil,
|
149
|
-
addresses.map { |a| "0x000000000000000000000000#{a[2..]}" }
|
215
|
+
addresses.to_a.map { |a| "0x000000000000000000000000#{a[2..]}" }
|
150
216
|
]
|
151
217
|
}
|
152
218
|
]
|
153
219
|
}.to_json
|
154
220
|
)
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
data =
|
160
|
-
begin
|
161
|
-
JSON.parse(msg.data)
|
162
|
-
rescue StandardError
|
163
|
-
{}
|
164
|
-
end
|
165
|
-
if data['method'] == 'eth_subscription' && data.dig('params', 'result')
|
166
|
-
event = data['params']['result']
|
167
|
-
unless raw
|
168
|
-
event = {
|
169
|
-
amount: event['data'].to_i(16),
|
170
|
-
from: "0x#{event['topics'][1][26..].downcase}",
|
171
|
-
to: "0x#{event['topics'][2][26..].downcase}"
|
172
|
-
}
|
173
|
-
end
|
174
|
-
log.debug("New event arrived from #{event['address']}")
|
175
|
-
yield event
|
176
|
-
end
|
177
|
-
end
|
178
|
-
ws.on(:close) do |_e|
|
179
|
-
log.debug("Disconnected from #{@host}")
|
180
|
-
end
|
181
|
-
ws.on(:error) do |e|
|
182
|
-
log.debug("Error at #{@host}: #{e.message}")
|
221
|
+
log.debug(
|
222
|
+
"Requested to subscribe ##{id} to #{addresses.to_a.size} addresses: " \
|
223
|
+
"#{addresses.to_a.map { |a| a[0..6] }.join(', ')}"
|
224
|
+
)
|
183
225
|
end
|
184
226
|
end
|
185
227
|
end
|
186
228
|
|
187
229
|
private
|
188
230
|
|
231
|
+
def verbose
|
232
|
+
yield
|
233
|
+
rescue StandardError => e
|
234
|
+
@log.error(Backtrace.new(e).to_s)
|
235
|
+
raise e
|
236
|
+
end
|
237
|
+
|
189
238
|
def url(http: true)
|
190
|
-
"#{http ? 'http' : 'ws'}#{@ssl ? 's' : ''}://#{@host}:#{@port}#{http ? @http_path : @ws_path}"
|
239
|
+
URI.parse("#{http ? 'http' : 'ws'}#{@ssl ? 's' : ''}://#{@host}:#{@port}#{http ? @http_path : @ws_path}")
|
191
240
|
end
|
192
241
|
|
193
242
|
def jsonrpc
|
data/lib/erc20.rb
CHANGED
data/test/erc20/test_wallet.rb
CHANGED
@@ -28,6 +28,7 @@ require 'loog'
|
|
28
28
|
require 'minitest/autorun'
|
29
29
|
require 'random-port'
|
30
30
|
require 'shellwords'
|
31
|
+
require 'threads'
|
31
32
|
require 'typhoeus'
|
32
33
|
require_relative '../../lib/erc20'
|
33
34
|
require_relative '../../lib/erc20/wallet'
|
@@ -38,7 +39,7 @@ require_relative '../test__helper'
|
|
38
39
|
# Copyright:: Copyright (c) 2025 Yegor Bugayenko
|
39
40
|
# License:: MIT
|
40
41
|
class TestWallet < Minitest::Test
|
41
|
-
# At this address, in Etherium mainnet, there are a
|
42
|
+
# At this address, in Etherium mainnet, there are a ~$27 USDT. I won't
|
42
43
|
# move them anyway, that's why tests can use this address forever.
|
43
44
|
STABLE = '0xEB2fE8872A6f1eDb70a2632EA1f869AB131532f6'
|
44
45
|
|
@@ -94,6 +95,15 @@ class TestWallet < Minitest::Test
|
|
94
95
|
end
|
95
96
|
end
|
96
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
|
+
|
97
107
|
def test_pays_on_hardhat
|
98
108
|
on_hardhat do |wallet|
|
99
109
|
to = Eth::Key.new(priv: WALTER).address.to_s
|
@@ -101,26 +111,40 @@ class TestWallet < Minitest::Test
|
|
101
111
|
sum = 42_000
|
102
112
|
from = Eth::Key.new(priv: JEFF).address.to_s
|
103
113
|
assert_operator(wallet.balance(from), :>, sum * 2)
|
104
|
-
wallet.pay(JEFF, to, sum)
|
114
|
+
txn = wallet.pay(JEFF, to, sum)
|
115
|
+
assert_equal(66, txn.length)
|
105
116
|
assert_equal(before + sum, wallet.balance(to))
|
106
117
|
end
|
107
118
|
end
|
108
119
|
|
120
|
+
def test_pays_on_hardhat_in_threads
|
121
|
+
on_hardhat do |wallet|
|
122
|
+
to = Eth::Key.new(priv: WALTER).address.to_s
|
123
|
+
before = wallet.balance(to)
|
124
|
+
sum = 42_000
|
125
|
+
mul = 10
|
126
|
+
Threads.new(mul).assert do
|
127
|
+
wallet.pay(JEFF, to, sum)
|
128
|
+
end
|
129
|
+
assert_equal(before + (sum * mul), wallet.balance(to))
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
109
133
|
def test_accepts_payments_on_hardhat
|
110
134
|
walter = Eth::Key.new(priv: WALTER).address.to_s.downcase
|
111
135
|
jeff = Eth::Key.new(priv: JEFF).address.to_s.downcase
|
112
136
|
on_hardhat do |wallet|
|
113
|
-
|
137
|
+
active = []
|
114
138
|
event = nil
|
115
139
|
daemon =
|
116
140
|
Thread.new do
|
117
|
-
wallet.accept([walter, jeff],
|
141
|
+
wallet.accept([walter, jeff], active) do |e|
|
118
142
|
event = e
|
119
143
|
end
|
120
144
|
rescue StandardError => e
|
121
145
|
loog.error(Backtrace.new(e))
|
122
146
|
end
|
123
|
-
wait_for { !
|
147
|
+
wait_for { !active.empty? }
|
124
148
|
sum = 77_000
|
125
149
|
wallet.pay(JEFF, walter, sum)
|
126
150
|
wait_for { !event.nil? }
|
@@ -129,6 +153,39 @@ class TestWallet < Minitest::Test
|
|
129
153
|
assert_equal(sum, event[:amount])
|
130
154
|
assert_equal(jeff, event[:from])
|
131
155
|
assert_equal(walter, event[:to])
|
156
|
+
assert_equal(66, event[:txn].length)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def test_accepts_payments_on_changing_addresses_on_hardhat
|
161
|
+
walter = Eth::Key.new(priv: WALTER).address.to_s.downcase
|
162
|
+
jeff = Eth::Key.new(priv: JEFF).address.to_s.downcase
|
163
|
+
addresses = Primitivo.new([walter])
|
164
|
+
on_hardhat do |wallet|
|
165
|
+
active = Primitivo.new([])
|
166
|
+
event = nil
|
167
|
+
daemon =
|
168
|
+
Thread.new do
|
169
|
+
wallet.accept(addresses, active) do |e|
|
170
|
+
event = e
|
171
|
+
end
|
172
|
+
rescue StandardError => e
|
173
|
+
loog.error(Backtrace.new(e))
|
174
|
+
end
|
175
|
+
wait_for { active.to_a.include?(walter) }
|
176
|
+
sum1 = 453_000
|
177
|
+
wallet.pay(JEFF, walter, sum1)
|
178
|
+
wait_for { !event.nil? }
|
179
|
+
assert_equal(sum1, event[:amount])
|
180
|
+
sum2 = 22_000
|
181
|
+
event = nil
|
182
|
+
addresses.append(jeff)
|
183
|
+
wait_for { active.to_a.include?(jeff) }
|
184
|
+
wallet.pay(WALTER, jeff, sum2)
|
185
|
+
wait_for { !event.nil? }
|
186
|
+
assert_equal(sum2, event[:amount])
|
187
|
+
daemon.kill
|
188
|
+
daemon.join(30)
|
132
189
|
end
|
133
190
|
end
|
134
191
|
|
@@ -138,17 +195,17 @@ class TestWallet < Minitest::Test
|
|
138
195
|
jeff = Eth::Key.new(priv: JEFF).address.to_s.downcase
|
139
196
|
on_hardhat do |w|
|
140
197
|
wallet = through_proxy(w, proxy)
|
141
|
-
|
198
|
+
active = []
|
142
199
|
event = nil
|
143
200
|
daemon =
|
144
201
|
Thread.new do
|
145
|
-
wallet.accept([walter, jeff],
|
202
|
+
wallet.accept([walter, jeff], active) do |e|
|
146
203
|
event = e
|
147
204
|
end
|
148
205
|
rescue StandardError => e
|
149
206
|
loog.error(Backtrace.new(e))
|
150
207
|
end
|
151
|
-
wait_for { !
|
208
|
+
wait_for { !active.empty? }
|
152
209
|
sum = 55_000
|
153
210
|
wallet.pay(JEFF, walter, sum)
|
154
211
|
wait_for { !event.nil? }
|
@@ -160,19 +217,19 @@ class TestWallet < Minitest::Test
|
|
160
217
|
end
|
161
218
|
|
162
219
|
def test_accepts_payments_on_mainnet
|
163
|
-
|
220
|
+
active = []
|
164
221
|
failed = false
|
165
222
|
net = mainnet
|
166
223
|
daemon =
|
167
224
|
Thread.new do
|
168
|
-
net.accept([STABLE],
|
225
|
+
net.accept([STABLE], active) do |_|
|
169
226
|
# ignore it
|
170
227
|
end
|
171
228
|
rescue StandardError => e
|
172
229
|
failed = true
|
173
230
|
loog.error(Backtrace.new(e))
|
174
231
|
end
|
175
|
-
wait_for { !
|
232
|
+
wait_for { !active.empty? }
|
176
233
|
daemon.kill
|
177
234
|
daemon.join(30)
|
178
235
|
refute(failed)
|
@@ -203,7 +260,7 @@ class TestWallet < Minitest::Test
|
|
203
260
|
private
|
204
261
|
|
205
262
|
def loog
|
206
|
-
ENV['RAKE'] ? Loog::
|
263
|
+
ENV['RAKE'] ? Loog::ERRORS : Loog::VERBOSE
|
207
264
|
end
|
208
265
|
|
209
266
|
def wait_for
|
@@ -313,4 +370,18 @@ class TestWallet < Minitest::Test
|
|
313
370
|
end
|
314
371
|
end
|
315
372
|
end
|
373
|
+
|
374
|
+
class Primitivo
|
375
|
+
def initialize(array)
|
376
|
+
@array = array
|
377
|
+
end
|
378
|
+
|
379
|
+
def to_a
|
380
|
+
@array.to_a
|
381
|
+
end
|
382
|
+
|
383
|
+
def append(item)
|
384
|
+
@array.append(item)
|
385
|
+
end
|
386
|
+
end
|
316
387
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
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.7
|
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
14
|
name: eth
|