erc20 0.0.5 → 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
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