erc20 0.0.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14d84ab9ee2ac821f5f404dcea22437f622fe82bf5565a04c695f718974ba129
4
- data.tar.gz: d2ec55f58ef13c62abfb0b870d541d3cc5bd8a32f5c685eda64fb484558af332
3
+ metadata.gz: ddbc3966b9eec2d455e33dde71df0427a7a99e4d59f0df69191d8f617434c8bf
4
+ data.tar.gz: 0d577a397eee74c5dae388ca620f8a681c4bec2af8d9b6cf5514ca32cbebc992
5
5
  SHA512:
6
- metadata.gz: 2ef86114757f0b841b022a8cef64330610d68425d13e900487e4d99589065ed19640f393986f3ea62003fe2d24ae93513406611d4725d58dcc3165e7e70a5ffa
7
- data.tar.gz: 92855745194e5cf9b6ff1902f0f7e4f44606aa4e306313668e7fb01849ab91e10fccf99e6f7ac6dfa0a6e176abc6d7aaa30f279b88e7c0f94b1133380f61d643
6
+ metadata.gz: 1f87ab9c39b5476b9b90bd44020f21970edc8830826129b0a2c6386d562887627e346c55a1e8bb1fcf3e9983d1977e6e0851b7e2fdc162217898951988b2173f
7
+ data.tar.gz: 02d6b7e7058dd6c6f3a74b81ece853e674020a4df84b6fa439b74438ff8227616a76cf3abd1a6f82fdfb7f34391c4e541f96791e404e8e84cb3f364d72e92771
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,83 @@ 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
+ }
183
+ log.debug("Payment of #{event[:amount]} tokens arrived from #{event[:from]} to #{event[:to]}")
184
+ end
185
+ yield event
186
+ end
187
+ end
188
+ end
189
+ ws.on(:close) do
190
+ verbose do
191
+ log.debug("Disconnected from ws://#{u.hostname}:#{u.port}")
192
+ end
193
+ end
194
+ ws.on(:error) do |e|
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
137
202
  ws.send(
138
203
  {
139
204
  jsonrpc: '2.0',
140
- id: 1,
205
+ id:,
141
206
  method: 'eth_subscribe',
142
207
  params: [
143
208
  'logs',
@@ -146,48 +211,31 @@ class ERC20::Wallet
146
211
  topics: [
147
212
  '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
148
213
  nil,
149
- addresses.map { |a| "0x000000000000000000000000#{a[2..]}" }
214
+ addresses.to_a.map { |a| "0x000000000000000000000000#{a[2..]}" }
150
215
  ]
151
216
  }
152
217
  ]
153
218
  }.to_json
154
219
  )
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}")
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
+ )
183
224
  end
184
225
  end
185
226
  end
186
227
 
187
228
  private
188
229
 
230
+ def verbose
231
+ yield
232
+ rescue StandardError => e
233
+ @log.error(Backtrace.new(e).to_s)
234
+ raise e
235
+ end
236
+
189
237
  def url(http: true)
190
- "#{http ? 'http' : 'ws'}#{@ssl ? 's' : ''}://#{@host}:#{@port}#{http ? @http_path : @ws_path}"
238
+ URI.parse("#{http ? 'http' : 'ws'}#{@ssl ? 's' : ''}://#{@host}:#{@port}#{http ? @http_path : @ws_path}")
191
239
  end
192
240
 
193
241
  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.6'
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'
@@ -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
@@ -106,21 +116,34 @@ class TestWallet < Minitest::Test
106
116
  end
107
117
  end
108
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
+
109
132
  def test_accepts_payments_on_hardhat
110
133
  walter = Eth::Key.new(priv: WALTER).address.to_s.downcase
111
134
  jeff = Eth::Key.new(priv: JEFF).address.to_s.downcase
112
135
  on_hardhat do |wallet|
113
- connected = []
136
+ active = []
114
137
  event = nil
115
138
  daemon =
116
139
  Thread.new do
117
- wallet.accept([walter, jeff], connected:) do |e|
140
+ wallet.accept([walter, jeff], active) do |e|
118
141
  event = e
119
142
  end
120
143
  rescue StandardError => e
121
144
  loog.error(Backtrace.new(e))
122
145
  end
123
- wait_for { !connected.empty? }
146
+ wait_for { !active.empty? }
124
147
  sum = 77_000
125
148
  wallet.pay(JEFF, walter, sum)
126
149
  wait_for { !event.nil? }
@@ -132,23 +155,55 @@ class TestWallet < Minitest::Test
132
155
  end
133
156
  end
134
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
+
135
190
  def test_accepts_payments_on_hardhat_via_proxy
136
191
  via_proxy do |proxy|
137
192
  walter = Eth::Key.new(priv: WALTER).address.to_s.downcase
138
193
  jeff = Eth::Key.new(priv: JEFF).address.to_s.downcase
139
194
  on_hardhat do |w|
140
195
  wallet = through_proxy(w, proxy)
141
- connected = []
196
+ active = []
142
197
  event = nil
143
198
  daemon =
144
199
  Thread.new do
145
- wallet.accept([walter, jeff], connected:) do |e|
200
+ wallet.accept([walter, jeff], active) do |e|
146
201
  event = e
147
202
  end
148
203
  rescue StandardError => e
149
204
  loog.error(Backtrace.new(e))
150
205
  end
151
- wait_for { !connected.empty? }
206
+ wait_for { !active.empty? }
152
207
  sum = 55_000
153
208
  wallet.pay(JEFF, walter, sum)
154
209
  wait_for { !event.nil? }
@@ -160,19 +215,19 @@ class TestWallet < Minitest::Test
160
215
  end
161
216
 
162
217
  def test_accepts_payments_on_mainnet
163
- connected = []
218
+ active = []
164
219
  failed = false
165
220
  net = mainnet
166
221
  daemon =
167
222
  Thread.new do
168
- net.accept([STABLE], connected:) do |_|
223
+ net.accept([STABLE], active) do |_|
169
224
  # ignore it
170
225
  end
171
226
  rescue StandardError => e
172
227
  failed = true
173
228
  loog.error(Backtrace.new(e))
174
229
  end
175
- wait_for { !connected.empty? }
230
+ wait_for { !active.empty? }
176
231
  daemon.kill
177
232
  daemon.join(30)
178
233
  refute(failed)
@@ -203,7 +258,7 @@ class TestWallet < Minitest::Test
203
258
  private
204
259
 
205
260
  def loog
206
- ENV['RAKE'] ? Loog::NULL : Loog::VERBOSE
261
+ ENV['RAKE'] ? Loog::ERRORS : Loog::VERBOSE
207
262
  end
208
263
 
209
264
  def wait_for
@@ -313,4 +368,18 @@ class TestWallet < Minitest::Test
313
368
  end
314
369
  end
315
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
316
385
  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.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 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