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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14d84ab9ee2ac821f5f404dcea22437f622fe82bf5565a04c695f718974ba129
4
- data.tar.gz: d2ec55f58ef13c62abfb0b870d541d3cc5bd8a32f5c685eda64fb484558af332
3
+ metadata.gz: 47883e48393686ed5d357fb9f5f762e0744a30a8f0999724dd2ddba369ac75ae
4
+ data.tar.gz: bad63b9aa6269a79cc2276c10c0f6aec02d8bf94bf18057b89fc0e9f896833be
5
5
  SHA512:
6
- metadata.gz: 2ef86114757f0b841b022a8cef64330610d68425d13e900487e4d99589065ed19640f393986f3ea62003fe2d24ae93513406611d4725d58dcc3165e7e70a5ffa
7
- data.tar.gz: 92855745194e5cf9b6ff1902f0f7e4f44606aa4e306313668e7fb01849ab91e10fccf99e6f7ac6dfa0a6e176abc6d7aaa30f279b88e7c0f94b1133380f61d643
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[:amount] # how much
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
- # It is NOT thread-safe!
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 are ready to spend
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
- nonce = jsonrpc.eth_getTransactionCount(from, 'pending').to_i(16)
102
- tx = Eth::Tx.new(
103
- {
104
- nonce:,
105
- gas_price: gas_price || gas_best_price,
106
- gas_limit: gas_limit || gas_estimate(from, data),
107
- to: @contract,
108
- value: 0,
109
- data: data,
110
- chain_id: @chain
111
- }
112
- )
113
- tx.sign(key)
114
- hex = "0x#{tx.hex}"
115
- jsonrpc.eth_sendRawTransaction(hex)
116
- @log.debug("Sent #{amount} from #{from} to #{address}: #{hex}")
117
- hex
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] ready When connected, TRUE will be added to this 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
- def accept(addresses, connected: [], raw: false)
129
- EM.run do
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
- log.debug("Connected to #{@host}")
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: 1,
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
- connected.append(1)
156
- log.debug("Subscribed to #{addresses.count} addresses")
157
- end
158
- ws.on(:message) do |msg|
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
@@ -27,5 +27,5 @@
27
27
  # License:: MIT
28
28
  module ERC20
29
29
  # Current version of the gem (changed by .rultor.yml on every release)
30
- VERSION = '0.0.5'
30
+ VERSION = '0.0.7'
31
31
  end
@@ -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 few USDT tokens. I won't
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
- connected = []
137
+ active = []
114
138
  event = nil
115
139
  daemon =
116
140
  Thread.new do
117
- wallet.accept([walter, jeff], connected:) do |e|
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 { !connected.empty? }
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
- connected = []
198
+ active = []
142
199
  event = nil
143
200
  daemon =
144
201
  Thread.new do
145
- wallet.accept([walter, jeff], connected:) do |e|
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 { !connected.empty? }
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
- connected = []
220
+ active = []
164
221
  failed = false
165
222
  net = mainnet
166
223
  daemon =
167
224
  Thread.new do
168
- net.accept([STABLE], connected:) do |_|
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 { !connected.empty? }
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::NULL : Loog::VERBOSE
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.5
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 00:00:00.000000000 Z
11
+ date: 2025-02-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: eth