erc20 0.2.8 → 0.3.0

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.
data/erc20.gemspec CHANGED
@@ -7,7 +7,7 @@ require 'English'
7
7
  require_relative 'lib/erc20/erc20'
8
8
 
9
9
  Gem::Specification.new do |s|
10
- s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to? :required_rubygems_version=
10
+ s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to?(:required_rubygems_version=)
11
11
  s.required_ruby_version = '~>3.0'
12
12
  s.name = 'erc20'
13
13
  s.version = ERC20::VERSION
@@ -25,12 +25,12 @@ Gem::Specification.new do |s|
25
25
  s.rdoc_options = ['--charset=UTF-8']
26
26
  s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
27
27
  s.extra_rdoc_files = ['README.md', 'LICENSE.txt']
28
- s.add_dependency 'elapsed', '~>0.2'
29
- s.add_dependency 'eth', '~>0.5'
30
- s.add_dependency 'faye-websocket', '~>0.11'
31
- s.add_dependency 'json', '~>2.10'
32
- s.add_dependency 'jsonrpc-client', '~>0.1'
33
- s.add_dependency 'loog', '~>0.4'
34
- s.add_dependency 'slop', '~>4.0'
28
+ s.add_dependency('elapsed', '~>0.2')
29
+ s.add_dependency('eth', '~>0.5')
30
+ s.add_dependency('faye-websocket', '~>0.11')
31
+ s.add_dependency('json', '~>2.10')
32
+ s.add_dependency('jsonrpc-client', '~>0.1')
33
+ s.add_dependency('loog', '~>0.4')
34
+ s.add_dependency('thor', '~>1.3')
35
35
  s.metadata['rubygems_mfa_required'] = 'true'
36
36
  end
data/features/cli.feature CHANGED
@@ -2,16 +2,12 @@
2
2
  # SPDX-License-Identifier: MIT
3
3
 
4
4
  Feature: Command Line Processing
5
- As a simple ETH/ERC20 user I want to send payments
5
+ As a simple ETH/ERC20 user I want to use it
6
6
 
7
7
  Scenario: Help can be printed
8
- When I run bin/erc20 with "--help"
9
- Then Exit code is zero
10
- And Stdout contains "--help"
11
-
12
- Scenario: Gas price price can be retrieved
13
- When I run bin/erc20 with "price --attempts=4"
8
+ When I run bin/erc20 with "help"
14
9
  Then Exit code is zero
10
+ And Stdout contains "help"
15
11
 
16
12
  Scenario: ETH private key can be generated
17
13
  When I run bin/erc20 with "key"
@@ -20,11 +16,3 @@ Feature: Command Line Processing
20
16
  Scenario: ETH address can be created
21
17
  When I run bin/erc20 with "address 46feba063e9b59a8ae0dba68abd39a3cb8f52089e776576d6eb1bb5bfec123d1"
22
18
  Then Exit code is zero
23
-
24
- Scenario: ERC20 balance can be checked
25
- When I run bin/erc20 with "balance 0x7232148927F8a580053792f44D4d59d40Fd00ABD --verbose"
26
- Then Exit code is zero
27
-
28
- Scenario: ETH balance can be checked
29
- When I run bin/erc20 with "eth_balance 0x7232148927F8a580053792f44D4d59d40Fd00ABD --verbose"
30
- Then Exit code is zero
@@ -0,0 +1,17 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ Feature: Command Line Processing
5
+ As a simple ETH/ERC20 user I want to use it live
6
+
7
+ Scenario: Gas price price can be retrieved
8
+ When I run bin/erc20 with "price --attempts=4"
9
+ Then Exit code is zero
10
+
11
+ Scenario: ERC20 balance can be checked
12
+ When I run bin/erc20 with "balance 0x7232148927F8a580053792f44D4d59d40Fd00ABD --verbose --attempts=4"
13
+ Then Exit code is zero
14
+
15
+ Scenario: ETH balance can be checked
16
+ When I run bin/erc20 with "eth_balance 0x7232148927F8a580053792f44D4d59d40Fd00ABD --verbose --attempts=4"
17
+ Then Exit code is zero
@@ -1,11 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'English'
3
4
  # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
4
5
  # SPDX-License-Identifier: MIT
5
6
 
6
7
  require 'tmpdir'
7
- require 'slop'
8
- require 'English'
9
8
  require_relative '../../lib/erc20'
10
9
 
11
10
  Before do
@@ -13,10 +12,6 @@ Before do
13
12
  @dir = Dir.mktmpdir('test')
14
13
  FileUtils.mkdir_p(@dir)
15
14
  Dir.chdir(@dir)
16
- @opts =
17
- Slop.parse ['-v'] do |o|
18
- o.bool '-v', '--verbose'
19
- end
20
15
  end
21
16
 
22
17
  After do
@@ -36,19 +31,19 @@ When(%r{^I run bin/erc20 with "([^"]*)"$}) do |arg|
36
31
  end
37
32
 
38
33
  Then(/^Stdout contains "([^"]*)"$/) do |txt|
39
- raise "STDOUT doesn't contain '#{txt}':\n#{@stdout}" unless @stdout.include?(txt)
34
+ raise(StandardError, "STDOUT doesn't contain '#{txt}':\n#{@stdout}") unless @stdout.include?(txt)
40
35
  end
41
36
 
42
37
  Then(/^Stdout is empty$/) do
43
- raise "STDOUT is not empty:\n#{@stdout}" unless @stdout == ''
38
+ raise(StandardError, "STDOUT is not empty:\n#{@stdout}") unless @stdout == ''
44
39
  end
45
40
 
46
41
  Then(/^Exit code is zero$/) do
47
- raise "Non-zero exit #{@exitstatus}:\n#{@stdout}" unless @exitstatus.zero?
42
+ raise(StandardError, "Non-zero exit #{@exitstatus}:\n#{@stdout}") unless @exitstatus.zero?
48
43
  end
49
44
 
50
45
  Then(/^Exit code is not zero$/) do
51
- raise 'Zero exit code' if @exitstatus.zero?
46
+ raise(StandardError, 'Zero exit code') if @exitstatus.zero?
52
47
  end
53
48
 
54
49
  When(/^I run bash with "([^"]*)"$/) do |text|
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cucumber'
3
4
  # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
4
5
  # SPDX-License-Identifier: MIT
5
6
 
6
7
  require 'simplecov'
7
- require 'cucumber'
8
8
  require_relative '../../lib/erc20'
data/lib/erc20/erc20.rb CHANGED
@@ -24,6 +24,5 @@
24
24
  # Copyright:: Copyright (c) 2025 Yegor Bugayenko
25
25
  # License:: MIT
26
26
  module ERC20
27
- # Current version of the gem (changed by the +.rultor.yml+ on every release)
28
- VERSION = '0.2.8'
27
+ VERSION = '0.3.0' unless defined?(VERSION)
29
28
  end
@@ -12,14 +12,9 @@ require_relative 'wallet'
12
12
  # Copyright:: Copyright (c) 2025 Yegor Bugayenko
13
13
  # License:: MIT
14
14
  class ERC20::FakeWallet
15
- # Transaction hash always returned:
16
15
  TXN_HASH = '0x172de9cda30537eae68ab4a96163ebbb8f8a85293b8737dd2e5deb4714b14623'
17
16
 
18
- # Fakes:
19
- attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path
20
-
21
- # Full history of all method calls:
22
- attr_reader :history
17
+ attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path, :history
23
18
 
24
19
  # Ctor.
25
20
  def initialize
@@ -77,7 +72,7 @@ class ERC20::FakeWallet
77
72
  42_000_000
78
73
  end
79
74
 
80
- # How much gas units is required in order to send ERC20 transaction.
75
+ # How many gas units are required to send an ERC20 transaction.
81
76
  #
82
77
  # @param [String] from The departing address, in hex
83
78
  # @param [String] to Arriving address, in hex
@@ -137,7 +132,7 @@ class ERC20::FakeWallet
137
132
  sleep(delay)
138
133
  a = addresses.to_a.sample
139
134
  next if a.nil?
140
- event =
135
+ yield(
141
136
  if raw
142
137
  {}
143
138
  else
@@ -148,7 +143,7 @@ class ERC20::FakeWallet
148
143
  txn: TXN_HASH
149
144
  }
150
145
  end
151
- yield event
146
+ )
152
147
  end
153
148
  end
154
149
  end
data/lib/erc20/wallet.rb CHANGED
@@ -58,10 +58,9 @@ require_relative 'erc20'
58
58
  # Copyright:: Copyright (c) 2025 Yegor Bugayenko
59
59
  # License:: MIT
60
60
  class ERC20::Wallet
61
- # Address of USDT contract.
62
61
  USDT = '0xdac17f958d2ee523a2206206994597c13d831ec7'
62
+ TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
63
63
 
64
- # These properties are read-only:
65
64
  attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path
66
65
 
67
66
  # Constructor.
@@ -73,36 +72,50 @@ class ERC20::Wallet
73
72
  # @param [String] ws_path The path in the connection URL, for Websockets
74
73
  # @param [Boolean] ssl Should we use SSL (for https and wss)
75
74
  # @param [String] proxy The URL of the proxy to use
75
+ # @param [Integer] attempts How many times to retry a failed HTTP RPC call before giving up
76
+ # @param [Array<String>] fallbacks Alternative HTTP RPC endpoint URLs to try when the primary one fails
76
77
  # @param [Object] log The destination for logs
77
- def initialize(contract: USDT, chain: 1, log: $stdout,
78
- host: nil, port: 443, http_path: '/', ws_path: '/',
79
- ssl: true, proxy: nil)
80
- raise 'Contract can\'t be nil' unless contract
81
- raise 'Contract must be a String' unless contract.is_a?(String)
82
- raise 'Invalid format of the contract' unless /^0x[0-9a-fA-F]{40}$/.match?(contract)
78
+ def initialize(
79
+ contract: USDT, chain: 1, log: $stdout,
80
+ host: nil, port: 443, http_path: '/', ws_path: '/',
81
+ ssl: true, proxy: nil, attempts: 1, fallbacks: []
82
+ )
83
+ raise(ArgumentError, 'Contract can\'t be nil') unless contract
84
+ raise(ArgumentError, 'Contract must be a String') unless contract.is_a?(String)
85
+ raise(ArgumentError, 'Invalid format of the contract') unless /^0x[0-9a-fA-F]{40}$/.match?(contract)
83
86
  @contract = contract
84
- raise 'Host can\'t be nil' unless host
85
- raise 'Host must be a String' unless host.is_a?(String)
87
+ raise(ArgumentError, 'Host can\'t be nil') unless host
88
+ raise(ArgumentError, 'Host must be a String') unless host.is_a?(String)
86
89
  @host = host
87
- raise 'Port can\'t be nil' unless port
88
- raise 'Port must be an Integer' unless port.is_a?(Integer)
89
- raise 'Port must be a positive Integer' unless port.positive?
90
+ raise(ArgumentError, 'Port can\'t be nil') unless port
91
+ raise(ArgumentError, 'Port must be an Integer') unless port.is_a?(Integer)
92
+ raise(ArgumentError, 'Port must be a positive Integer') unless port.positive?
90
93
  @port = port
91
- raise 'Ssl can\'t be nil' if ssl.nil?
94
+ raise(ArgumentError, 'Ssl can\'t be nil') if ssl.nil?
92
95
  @ssl = ssl
93
- raise 'Http_path can\'t be nil' unless http_path
94
- raise 'Http_path must be a String' unless http_path.is_a?(String)
96
+ raise(ArgumentError, 'Http_path can\'t be nil') unless http_path
97
+ raise(ArgumentError, 'Http_path must be a String') unless http_path.is_a?(String)
95
98
  @http_path = http_path
96
- raise 'Ws_path can\'t be nil' unless ws_path
97
- raise 'Ws_path must be a String' unless ws_path.is_a?(String)
99
+ raise(ArgumentError, 'Ws_path can\'t be nil') unless ws_path
100
+ raise(ArgumentError, 'Ws_path must be a String') unless ws_path.is_a?(String)
98
101
  @ws_path = ws_path
99
- raise 'Log can\'t be nil' unless log
102
+ raise(ArgumentError, 'Log can\'t be nil') unless log
100
103
  @log = log
101
- raise 'Chain can\'t be nil' unless chain
102
- raise 'Chain must be an Integer' unless chain.is_a?(Integer)
103
- raise 'Chain must be a positive Integer' unless chain.positive?
104
+ raise(ArgumentError, 'Chain can\'t be nil') unless chain
105
+ raise(ArgumentError, 'Chain must be an Integer') unless chain.is_a?(Integer)
106
+ raise(ArgumentError, 'Chain must be a positive Integer') unless chain.positive?
104
107
  @chain = chain
105
108
  @proxy = proxy
109
+ raise(ArgumentError, 'Attempts can\'t be nil') unless attempts
110
+ raise(ArgumentError, 'Attempts must be an Integer') unless attempts.is_a?(Integer)
111
+ raise(ArgumentError, 'Attempts must be a positive Integer') unless attempts.positive?
112
+ @attempts = attempts
113
+ raise(ArgumentError, 'Fallbacks can\'t be nil') if fallbacks.nil?
114
+ raise(ArgumentError, 'Fallbacks must be an Array') unless fallbacks.is_a?(Array)
115
+ fallbacks.each do |f|
116
+ raise(ArgumentError, 'Each fallback must be a String') unless f.is_a?(String)
117
+ end
118
+ @fallbacks = fallbacks
106
119
  @mutex = Mutex.new
107
120
  end
108
121
 
@@ -115,16 +128,11 @@ class ERC20::Wallet
115
128
  # @param [String] address Public key, in hex, starting from '0x'
116
129
  # @return [Integer] Balance, in tokens
117
130
  def balance(address)
118
- raise 'Address can\'t be nil' unless address
119
- raise 'Address must be a String' unless address.is_a?(String)
120
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(address)
121
- func = '70a08231' # balanceOf
122
- data = "0x#{func}000000000000000000000000#{address[2..].downcase}"
123
- r =
124
- with_jsonrpc do |jr|
125
- jr.eth_call({ to: @contract, data: data }, 'latest')
126
- end
127
- b = r[2..].to_i(16)
131
+ raise(ArgumentError, 'Address can\'t be nil') unless address
132
+ raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
133
+ raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(address)
134
+ data = "0x70a08231000000000000000000000000#{address[2..].downcase}"
135
+ b = with_jsonrpc { |jr| jr.eth_call({ to: @contract, data: data }, 'latest') }[2..].to_i(16)
128
136
  log_it(:debug, "The balance of #{address} is #{b} ERC20 tokens")
129
137
  b
130
138
  end
@@ -137,14 +145,10 @@ class ERC20::Wallet
137
145
  # @param [String] address Public key, in hex, starting from '0x'
138
146
  # @return [Integer] Balance, in ETH
139
147
  def eth_balance(address)
140
- raise 'Address can\'t be nil' unless address
141
- raise 'Address must be a String' unless address.is_a?(String)
142
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(address)
143
- r =
144
- with_jsonrpc do |jr|
145
- jr.eth_getBalance(address, 'latest')
146
- end
147
- b = r[2..].to_i(16)
148
+ raise(ArgumentError, 'Address can\'t be nil') unless address
149
+ raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
150
+ raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(address)
151
+ b = with_jsonrpc { |jr| jr.eth_getBalance(address, 'latest') }[2..].to_i(16)
148
152
  log_it(:debug, "The balance of #{address} is #{b} ETHs")
149
153
  b
150
154
  end
@@ -154,24 +158,23 @@ class ERC20::Wallet
154
158
  # @param [String] txn Hex of transaction
155
159
  # @return [Integer] Balance, in ERC20 tokens
156
160
  def sum_of(txn)
157
- raise 'Transaction hash can\'t be nil' unless txn
158
- raise 'Transaction hash must be a String' unless txn.is_a?(String)
159
- raise 'Invalid format of the transaction hash' unless /^0x[0-9a-fA-F]{64}$/.match?(txn)
161
+ raise(ArgumentError, 'Transaction hash can\'t be nil') unless txn
162
+ raise(ArgumentError, 'Transaction hash must be a String') unless txn.is_a?(String)
163
+ raise(ArgumentError, 'Invalid format of the transaction hash') unless /^0x[0-9a-fA-F]{64}$/.match?(txn)
160
164
  receipt =
161
165
  with_jsonrpc do |jr|
162
166
  jr.eth_getTransactionReceipt(txn)
163
167
  end
164
- raise "Transaction not found: #{txn}" if receipt.nil?
168
+ raise(StandardError, "Transaction not found: #{txn}") if receipt.nil?
165
169
  logs = receipt['logs'] || []
166
- transfer_event = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
167
170
  logs.each do |log|
168
- next unless log['topics'] && log['topics'][0] == transfer_event
171
+ next unless log['topics'] && log['topics'][0] == TRANSFER
169
172
  next unless log['address'].downcase == @contract.downcase
170
173
  amount = log['data'].to_i(16)
171
174
  log_it(:debug, "Found transfer of #{amount} tokens in transaction #{txn}")
172
175
  return amount
173
176
  end
174
- raise "No transfer event found in transaction #{txn}"
177
+ raise(StandardError, "No transfer event found in transaction #{txn}")
175
178
  end
176
179
 
177
180
  # How many gas units are required to send an ERC20 transaction.
@@ -181,15 +184,15 @@ class ERC20::Wallet
181
184
  # @param [Integer] amount How many ERC20 tokens to send
182
185
  # @return [Integer] Number of gas units required
183
186
  def gas_estimate(from, to, amount)
184
- raise 'Address can\'t be nil' unless from
185
- raise 'Address must be a String' unless from.is_a?(String)
186
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(from)
187
- raise 'Address can\'t be nil' unless to
188
- raise 'Address must be a String' unless to.is_a?(String)
189
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(to)
190
- raise 'Amount can\'t be nil' unless amount
191
- raise "Amount (#{amount}) must be an Integer" unless amount.is_a?(Integer)
192
- raise "Amount (#{amount}) must be a positive Integer" unless amount.positive?
187
+ raise(ArgumentError, 'Address can\'t be nil') unless from
188
+ raise(ArgumentError, 'Address must be a String') unless from.is_a?(String)
189
+ raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(from)
190
+ raise(ArgumentError, 'Address can\'t be nil') unless to
191
+ raise(ArgumentError, 'Address must be a String') unless to.is_a?(String)
192
+ raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(to)
193
+ raise(ArgumentError, 'Amount can\'t be nil') unless amount
194
+ raise(ArgumentError, "Amount (#{amount}) must be an Integer") unless amount.is_a?(Integer)
195
+ raise(ArgumentError, "Amount (#{amount}) must be a positive Integer") unless amount.positive?
193
196
  gas =
194
197
  with_jsonrpc do |jr|
195
198
  jr.eth_estimateGas({ from:, to: @contract, data: to_pay_data(to, amount) }, 'latest').to_i(16)
@@ -198,25 +201,35 @@ class ERC20::Wallet
198
201
  gas
199
202
  end
200
203
 
201
- # What is the price of gas unit in gwei?
204
+ GAS_PRICE_TIP = 1_000_000_000
205
+
206
+ # What is the price of gas unit in wei?
202
207
  #
203
208
  # In Ethereum, gas is a unit that measures the computational work required to
204
209
  # execute operations on the network. Every transaction and smart contract
205
210
  # interaction consumes gas. Gas price is the amount of ETH you're willing to pay
206
- # for each unit of gas, denominated in gwei (1 gwei = 0.000000001 ETH). Higher
211
+ # for each unit of gas, denominated in wei (1 gwei = 0.000000001 ETH). Higher
207
212
  # gas prices incentivize miners to include your transaction sooner, while lower
208
213
  # prices may result in longer confirmation times.
209
214
  #
210
- # @return [Integer] Price of gas unit, in gwei (0.000000001 ETH)
215
+ # The returned price is not the bare EIP-1559 base fee. The base fee alone
216
+ # leaves a zero miner tip (+tip = gasPrice - baseFee = 0+), so proposers have
217
+ # no incentive to include the transaction, and it becomes unmineable the
218
+ # moment the base fee rises (it may grow up to 12.5% per block). To make the
219
+ # price mineable, we double the base fee (a buffer that absorbs several blocks
220
+ # of base-fee growth) and add a priority tip (+GAS_PRICE_TIP+).
221
+ #
222
+ # @return [Integer] Price of gas unit, in wei (1 gwei = 0.000000001 ETH)
211
223
  def gas_price
212
224
  block =
213
225
  with_jsonrpc do |jr|
214
226
  jr.eth_getBlockByNumber('latest', false)
215
227
  end
216
- raise "Can't get gas price, try again later" if block.nil?
217
- gwei = block['baseFeePerGas'].to_i(16)
218
- log_it(:debug, "The cost of one gas unit is #{gwei} gwei")
219
- gwei
228
+ raise(StandardError, "Can't get gas price, try again later") if block.nil?
229
+ base = block['baseFeePerGas'].to_i(16)
230
+ price = (base * 2) + GAS_PRICE_TIP
231
+ log_it(:debug, "The base fee is #{base} wei, the cost of one gas unit is #{price} wei")
232
+ price
220
233
  end
221
234
 
222
235
  # Send a single ERC20 payment from a private address to a public one.
@@ -236,40 +249,40 @@ class ERC20::Wallet
236
249
  # @param [Integer] price How much gas you pay per computation unit
237
250
  # @return [String] Transaction hash
238
251
  def pay(priv, address, amount, limit: nil, price: gas_price)
239
- raise 'Private key can\'t be nil' unless priv
240
- raise 'Private key must be a String' unless priv.is_a?(String)
241
- raise 'Invalid format of private key' unless /^[0-9a-fA-F]{64}$/.match?(priv)
242
- raise 'Address can\'t be nil' unless address
243
- raise 'Address must be a String' unless address.is_a?(String)
244
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(address)
245
- raise 'Amount can\'t be nil' unless amount
246
- raise "Amount (#{amount}) must be an Integer" unless amount.is_a?(Integer)
247
- raise "Amount (#{amount}) must be a positive Integer" unless amount.positive?
252
+ raise(ArgumentError, 'Private key can\'t be nil') unless priv
253
+ raise(ArgumentError, 'Private key must be a String') unless priv.is_a?(String)
254
+ raise(ArgumentError, 'Invalid format of private key') unless /^[0-9a-fA-F]{64}$/.match?(priv)
255
+ raise(ArgumentError, 'Address can\'t be nil') unless address
256
+ raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
257
+ raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(address)
258
+ raise(ArgumentError, 'Amount can\'t be nil') unless amount
259
+ raise(ArgumentError, "Amount (#{amount}) must be an Integer") unless amount.is_a?(Integer)
260
+ raise(ArgumentError, "Amount (#{amount}) must be a positive Integer") unless amount.positive?
248
261
  if limit
249
- raise 'Gas limit must be an Integer' unless limit.is_a?(Integer)
250
- raise "Gas limit #{limit} is below #{Eth::Tx::DEFAULT_GAS_LIMIT}" if limit < Eth::Tx::DEFAULT_GAS_LIMIT
251
- raise "Gas limit #{limit} is above #{Eth::Tx::BLOCK_GAS_LIMIT}" if limit > Eth::Tx::BLOCK_GAS_LIMIT
262
+ raise(ArgumentError, 'Gas limit must be an Integer') unless limit.is_a?(Integer)
263
+ raise(ArgumentError, "Gas limit #{limit} is below #{Eth::Tx::DEFAULT_GAS_LIMIT}") if limit < Eth::Tx::DEFAULT_GAS_LIMIT
264
+ raise(ArgumentError, "Gas limit #{limit} is above #{Eth::Tx::BLOCK_GAS_LIMIT}") if limit > Eth::Tx::BLOCK_GAS_LIMIT
252
265
  end
253
266
  if price
254
- raise 'Gas price must be an Integer' unless price.is_a?(Integer)
255
- raise 'Gas price must be a positive Integer' unless price.positive?
267
+ raise(ArgumentError, 'Gas price must be an Integer') unless price.is_a?(Integer)
268
+ raise(ArgumentError, 'Gas price must be a positive Integer') unless price.positive?
256
269
  end
257
270
  key = Eth::Key.new(priv: priv)
258
271
  from = key.address.to_s
259
272
  tnx =
260
273
  @mutex.synchronize do
261
274
  with_jsonrpc do |jr|
262
- nonce = jr.eth_getTransactionCount(from, 'pending').to_i(16)
263
- h = {
264
- nonce:,
265
- gas_price: price,
266
- gas_limit: limit || gas_estimate(from, address, amount),
267
- to: @contract,
268
- value: 0,
269
- data: to_pay_data(address, amount),
270
- chain_id: @chain
271
- }
272
- tx = Eth::Tx.new(h)
275
+ tx = Eth::Tx.new(
276
+ {
277
+ nonce: jr.eth_getTransactionCount(from, 'pending').to_i(16),
278
+ gas_price: price,
279
+ gas_limit: limit || gas_estimate(from, address, amount),
280
+ to: @contract,
281
+ value: 0,
282
+ data: to_pay_data(address, amount),
283
+ chain_id: @chain
284
+ }
285
+ )
273
286
  tx.sign(key)
274
287
  hex = "0x#{tx.hex}"
275
288
  log_it(:debug, "Sending ERC20 transaction #{hex}")
@@ -288,34 +301,34 @@ class ERC20::Wallet
288
301
  # @param [Integer] price How much gas you pay per computation unit
289
302
  # @return [String] Transaction hash
290
303
  def eth_pay(priv, address, amount, price: gas_price)
291
- raise 'Private key can\'t be nil' unless priv
292
- raise 'Private key must be a String' unless priv.is_a?(String)
293
- raise 'Invalid format of private key' unless /^[0-9a-fA-F]{64}$/.match?(priv)
294
- raise 'Address can\'t be nil' unless address
295
- raise 'Address must be a String' unless address.is_a?(String)
296
- raise 'Invalid format of the address' unless /^0x[0-9a-fA-F]{40}$/.match?(address)
297
- raise 'Amount can\'t be nil' unless amount
298
- raise "Amount (#{amount}) must be an Integer" unless amount.is_a?(Integer)
299
- raise "Amount (#{amount}) must be a positive Integer" unless amount.positive?
304
+ raise(ArgumentError, 'Private key can\'t be nil') unless priv
305
+ raise(ArgumentError, 'Private key must be a String') unless priv.is_a?(String)
306
+ raise(ArgumentError, 'Invalid format of private key') unless /^[0-9a-fA-F]{64}$/.match?(priv)
307
+ raise(ArgumentError, 'Address can\'t be nil') unless address
308
+ raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
309
+ raise(ArgumentError, 'Invalid format of the address') unless /^0x[0-9a-fA-F]{40}$/.match?(address)
310
+ raise(ArgumentError, 'Amount can\'t be nil') unless amount
311
+ raise(ArgumentError, "Amount (#{amount}) must be an Integer") unless amount.is_a?(Integer)
312
+ raise(ArgumentError, "Amount (#{amount}) must be a positive Integer") unless amount.positive?
300
313
  if price
301
- raise 'Gas price must be an Integer' unless price.is_a?(Integer)
302
- raise 'Gas price must be a positive Integer' unless price.positive?
314
+ raise(ArgumentError, 'Gas price must be an Integer') unless price.is_a?(Integer)
315
+ raise(ArgumentError, 'Gas price must be a positive Integer') unless price.positive?
303
316
  end
304
317
  key = Eth::Key.new(priv: priv)
305
318
  from = key.address.to_s
306
319
  tnx =
307
320
  @mutex.synchronize do
308
321
  with_jsonrpc do |jr|
309
- nonce = jr.eth_getTransactionCount(from, 'pending').to_i(16)
310
- h = {
311
- chain_id: @chain,
312
- nonce:,
313
- gas_price: price,
314
- gas_limit: 22_000,
315
- to: address,
316
- value: amount
317
- }
318
- tx = Eth::Tx.new(h)
322
+ tx = Eth::Tx.new(
323
+ {
324
+ chain_id: @chain,
325
+ nonce: jr.eth_getTransactionCount(from, 'pending').to_i(16),
326
+ gas_price: price,
327
+ gas_limit: 22_000,
328
+ to: address,
329
+ value: amount
330
+ }
331
+ )
319
332
  tx.sign(key)
320
333
  hex = "0x#{tx.hex}"
321
334
  log_it(:debug, "Sending ETH transaction #{hex}")
@@ -352,16 +365,16 @@ class ERC20::Wallet
352
365
  # @param [Integer] delay How many seconds to wait between +eth_subscribe+ calls
353
366
  # @param [Integer] subscription_id Unique ID of the subscription
354
367
  def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(99_999), &)
355
- raise 'Addresses can\'t be nil' unless addresses
356
- raise 'Addresses must respond to .to_a()' unless addresses.respond_to?(:to_a)
357
- raise 'Active can\'t be nil' unless active
358
- raise 'Active must respond to .to_a()' unless active.respond_to?(:to_a)
359
- raise 'Active must respond to .append()' unless active.respond_to?(:append)
360
- raise 'Active must respond to .clear()' unless active.respond_to?(:clear)
361
- raise 'Delay must be an Integer' unless delay.is_a?(Integer)
362
- raise 'Delay must be a positive Integer or positive Float' unless delay.positive?
363
- raise 'Subscription ID must be an Integer' unless subscription_id.is_a?(Integer)
364
- raise 'Subscription ID must be a positive Integer' unless subscription_id.positive?
368
+ raise(ArgumentError, 'Addresses can\'t be nil') unless addresses
369
+ raise(ArgumentError, 'Addresses must respond to .to_a()') unless addresses.respond_to?(:to_a)
370
+ raise(ArgumentError, 'Active can\'t be nil') unless active
371
+ raise(ArgumentError, 'Active must respond to .to_a()') unless active.respond_to?(:to_a)
372
+ raise(ArgumentError, 'Active must respond to .append()') unless active.respond_to?(:append)
373
+ raise(ArgumentError, 'Active must respond to .clear()') unless active.respond_to?(:clear)
374
+ raise(ArgumentError, 'Delay must be an Integer') unless delay.is_a?(Integer)
375
+ raise(ArgumentError, 'Delay must be a positive Integer or positive Float') unless delay.positive?
376
+ raise(ArgumentError, 'Subscription ID must be an Integer') unless subscription_id.is_a?(Integer)
377
+ raise(ArgumentError, 'Subscription ID must be a positive Integer') unless subscription_id.positive?
365
378
  EventMachine.run do
366
379
  reaccept(addresses, active, raw:, delay:, subscription_id:, &)
367
380
  end
@@ -389,6 +402,7 @@ class ERC20::Wallet
389
402
  timer =
390
403
  EventMachine.add_periodic_timer(delay) do
391
404
  next if active.to_a.sort == addresses.to_a.sort
405
+ # rubocop:disable Style/Send
392
406
  ws.send(
393
407
  {
394
408
  jsonrpc: '2.0',
@@ -407,6 +421,7 @@ class ERC20::Wallet
407
421
  ]
408
422
  }.to_json
409
423
  )
424
+ # rubocop:enable Style/Send
410
425
  log_it(
411
426
  :debug,
412
427
  "Requested to subscribe ##{subscription_id} to #{addresses.to_a.size} addresses: " \
@@ -448,7 +463,7 @@ class ERC20::Wallet
448
463
  "from #{event[:from]} to #{event[:to]} in #{event[:txn]}"
449
464
  )
450
465
  end
451
- yield event
466
+ yield(event)
452
467
  end
453
468
  end
454
469
  end
@@ -482,13 +497,13 @@ class ERC20::Wallet
482
497
  yield
483
498
  rescue StandardError => e
484
499
  log_it(:error, Backtrace.new(e).to_s)
485
- raise e
500
+ raise(e)
486
501
  end
487
502
 
488
503
  def safe
489
504
  yield
490
505
  rescue StandardError
491
- # ignore it
506
+ nil
492
507
  end
493
508
 
494
509
  def url(http: true)
@@ -503,25 +518,30 @@ class ERC20::Wallet
503
518
  opts[:connection] =
504
519
  Faraday.new do |f|
505
520
  f.adapter(Faraday.default_adapter)
506
- f.proxy = {
507
- uri: "#{uri.scheme}://#{uri.hostname}:#{uri.port}",
508
- user: uri.user,
509
- password: uri.password
510
- }
521
+ f.proxy = { uri: "#{uri.scheme}://#{uri.hostname}:#{uri.port}", user: uri.user, password: uri.password }
511
522
  end
512
523
  end
513
- elapsed(@log, good: "Talked to #{url.host}:#{url.port}") do
514
- yield JSONRPC::Client.new(url.to_s, opts)
524
+ endpoints = [url.to_s] + @fallbacks
525
+ attempt = 0
526
+ begin
527
+ attempt += 1
528
+ u = URI.parse(endpoints[(attempt - 1) % endpoints.size])
529
+ elapsed(@log, good: "Talked to #{u.host}:#{u.port}") do
530
+ yield(JSONRPC::Client.new(u.to_s, opts))
531
+ end
532
+ rescue StandardError => e
533
+ raise if attempt >= @attempts
534
+ pause = 2**(attempt - 1)
535
+ log_it(:debug, "Attempt #{attempt}/#{@attempts} to #{u.host} failed (#{e.class}), retrying in #{pause}s")
536
+ sleep(pause)
537
+ retry
515
538
  end
516
539
  end
517
540
 
518
541
  def to_pay_data(address, amount)
519
- func = 'a9059cbb' # transfer(address,uint256)
520
542
  to_clean = address.downcase.sub(/^0x/, '')
521
- to_padded = ('0' * (64 - to_clean.size)) + to_clean
522
543
  amt_hex = amount.to_s(16)
523
- amt_padded = ('0' * (64 - amt_hex.size)) + amt_hex
524
- "0x#{func}#{to_padded}#{amt_padded}"
544
+ "0xa9059cbb#{('0' * (64 - to_clean.size)) + to_clean}#{('0' * (64 - amt_hex.size)) + amt_hex}"
525
545
  end
526
546
 
527
547
  def log_it(method, msg)
data/lib/erc20.rb CHANGED
@@ -4,5 +4,5 @@
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
6
  require_relative 'erc20/erc20'
7
- require_relative 'erc20/wallet'
8
7
  require_relative 'erc20/fake_wallet'
8
+ require_relative 'erc20/wallet'