erc20 0.2.9 → 0.4.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.
- checksums.yaml +4 -4
- data/Gemfile +6 -4
- data/Gemfile.lock +83 -72
- data/README.md +15 -4
- data/Rakefile +2 -2
- data/bin/erc20 +21 -8
- data/erc20.gemspec +8 -8
- data/features/dry.feature +9 -1
- data/features/step_definitions/steps.rb +5 -5
- data/features/support/env.rb +1 -1
- data/lib/erc20/checks.rb +102 -0
- data/lib/erc20/erc20.rb +1 -2
- data/lib/erc20/fake_wallet.rb +59 -25
- data/lib/erc20/wallet.rb +321 -198
- data/lib/erc20.rb +1 -1
- metadata +2 -1
data/lib/erc20/wallet.rb
CHANGED
|
@@ -11,6 +11,7 @@ require 'json'
|
|
|
11
11
|
require 'jsonrpc/client'
|
|
12
12
|
require 'loog'
|
|
13
13
|
require 'uri'
|
|
14
|
+
require_relative 'checks'
|
|
14
15
|
require_relative 'erc20'
|
|
15
16
|
|
|
16
17
|
# A wallet with ERC20 tokens on Ethereum.
|
|
@@ -58,10 +59,11 @@ require_relative 'erc20'
|
|
|
58
59
|
# Copyright:: Copyright (c) 2025 Yegor Bugayenko
|
|
59
60
|
# License:: MIT
|
|
60
61
|
class ERC20::Wallet
|
|
61
|
-
|
|
62
|
+
include ERC20::Checks
|
|
63
|
+
|
|
62
64
|
USDT = '0xdac17f958d2ee523a2206206994597c13d831ec7'
|
|
65
|
+
TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
|
|
63
66
|
|
|
64
|
-
# These properties are read-only:
|
|
65
67
|
attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path
|
|
66
68
|
|
|
67
69
|
# Constructor.
|
|
@@ -73,36 +75,50 @@ class ERC20::Wallet
|
|
|
73
75
|
# @param [String] ws_path The path in the connection URL, for Websockets
|
|
74
76
|
# @param [Boolean] ssl Should we use SSL (for https and wss)
|
|
75
77
|
# @param [String] proxy The URL of the proxy to use
|
|
78
|
+
# @param [Integer] attempts How many times to try every HTTP RPC endpoint before giving up
|
|
79
|
+
# @param [Array<String>] fallbacks Alternative HTTP RPC endpoint URLs to try when the primary one fails
|
|
76
80
|
# @param [Object] log The destination for logs
|
|
77
|
-
def initialize(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
raise '
|
|
81
|
+
def initialize(
|
|
82
|
+
contract: USDT, chain: 1, log: $stdout,
|
|
83
|
+
host: nil, port: 443, http_path: '/', ws_path: '/',
|
|
84
|
+
ssl: true, proxy: nil, attempts: 1, fallbacks: []
|
|
85
|
+
)
|
|
86
|
+
raise(ArgumentError, 'Contract can\'t be nil') unless contract
|
|
87
|
+
raise(ArgumentError, 'Contract must be a String') unless contract.is_a?(String)
|
|
88
|
+
raise(ArgumentError, 'Invalid format of the contract') unless /^0x[0-9a-fA-F]{40}$/.match?(contract)
|
|
83
89
|
@contract = contract
|
|
84
|
-
raise 'Host can\'t be nil' unless host
|
|
85
|
-
raise 'Host must be a String' unless host.is_a?(String)
|
|
90
|
+
raise(ArgumentError, 'Host can\'t be nil') unless host
|
|
91
|
+
raise(ArgumentError, 'Host must be a String') unless host.is_a?(String)
|
|
86
92
|
@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?
|
|
93
|
+
raise(ArgumentError, 'Port can\'t be nil') unless port
|
|
94
|
+
raise(ArgumentError, 'Port must be an Integer') unless port.is_a?(Integer)
|
|
95
|
+
raise(ArgumentError, 'Port must be a positive Integer') unless port.positive?
|
|
90
96
|
@port = port
|
|
91
|
-
raise 'Ssl can\'t be nil' if ssl.nil?
|
|
97
|
+
raise(ArgumentError, 'Ssl can\'t be nil') if ssl.nil?
|
|
92
98
|
@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)
|
|
99
|
+
raise(ArgumentError, 'Http_path can\'t be nil') unless http_path
|
|
100
|
+
raise(ArgumentError, 'Http_path must be a String') unless http_path.is_a?(String)
|
|
95
101
|
@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)
|
|
102
|
+
raise(ArgumentError, 'Ws_path can\'t be nil') unless ws_path
|
|
103
|
+
raise(ArgumentError, 'Ws_path must be a String') unless ws_path.is_a?(String)
|
|
98
104
|
@ws_path = ws_path
|
|
99
|
-
raise 'Log can\'t be nil' unless log
|
|
105
|
+
raise(ArgumentError, 'Log can\'t be nil') unless log
|
|
100
106
|
@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?
|
|
107
|
+
raise(ArgumentError, 'Chain can\'t be nil') unless chain
|
|
108
|
+
raise(ArgumentError, 'Chain must be an Integer') unless chain.is_a?(Integer)
|
|
109
|
+
raise(ArgumentError, 'Chain must be a positive Integer') unless chain.positive?
|
|
104
110
|
@chain = chain
|
|
105
111
|
@proxy = proxy
|
|
112
|
+
raise(ArgumentError, 'Attempts can\'t be nil') unless attempts
|
|
113
|
+
raise(ArgumentError, 'Attempts must be an Integer') unless attempts.is_a?(Integer)
|
|
114
|
+
raise(ArgumentError, 'Attempts must be a positive Integer') unless attempts.positive?
|
|
115
|
+
@attempts = attempts
|
|
116
|
+
raise(ArgumentError, 'Fallbacks can\'t be nil') if fallbacks.nil?
|
|
117
|
+
raise(ArgumentError, 'Fallbacks must be an Array') unless fallbacks.is_a?(Array)
|
|
118
|
+
fallbacks.each do |f|
|
|
119
|
+
raise(ArgumentError, 'Each fallback must be a String') unless f.is_a?(String)
|
|
120
|
+
end
|
|
121
|
+
@fallbacks = fallbacks
|
|
106
122
|
@mutex = Mutex.new
|
|
107
123
|
end
|
|
108
124
|
|
|
@@ -112,19 +128,26 @@ class ERC20::Wallet
|
|
|
112
128
|
# balance in ETH crypto. Another balance is the one kept by the ERC20 contract
|
|
113
129
|
# in its own ledger in root storage. This balance is checked by this method.
|
|
114
130
|
#
|
|
131
|
+
# An address that has no tokens has a balance of zero, but so does an address
|
|
132
|
+
# asked at the wrong contract or in the wrong chain: there, +eth_call+ succeeds
|
|
133
|
+
# with empty return data. A +balanceOf+ always answers with a single 32-byte
|
|
134
|
+
# word, thus anything shorter is a misconfiguration and an error is raised,
|
|
135
|
+
# instead of a zero that nobody may tell from a genuinely empty balance.
|
|
136
|
+
#
|
|
115
137
|
# @param [String] address Public key, in hex, starting from '0x'
|
|
116
138
|
# @return [Integer] Balance, in tokens
|
|
117
139
|
def balance(address)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
140
|
+
to_address(address)
|
|
141
|
+
data = "0x70a08231000000000000000000000000#{address[2..].downcase}"
|
|
142
|
+
hex = with_jsonrpc { |jr| jr.eth_call({ to: @contract, data: data }, 'latest') }
|
|
143
|
+
unless /^0x[0-9a-fA-F]{64}$/.match?(hex)
|
|
144
|
+
raise(
|
|
145
|
+
StandardError,
|
|
146
|
+
"The #{@contract} contract in chain #{@chain} answered #{hex.inspect} instead of " \
|
|
147
|
+
'a 32-byte word, it may not be an ERC20 contract at all'
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
b = hex[2..].to_i(16)
|
|
128
151
|
log_it(:debug, "The balance of #{address} is #{b} ERC20 tokens")
|
|
129
152
|
b
|
|
130
153
|
end
|
|
@@ -137,41 +160,54 @@ class ERC20::Wallet
|
|
|
137
160
|
# @param [String] address Public key, in hex, starting from '0x'
|
|
138
161
|
# @return [Integer] Balance, in ETH
|
|
139
162
|
def eth_balance(address)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
end
|
|
147
|
-
b = r[2..].to_i(16)
|
|
163
|
+
to_address(address)
|
|
164
|
+
hex = with_jsonrpc { |jr| jr.eth_getBalance(address, 'latest') }
|
|
165
|
+
unless /^0x[0-9a-fA-F]+$/.match?(hex)
|
|
166
|
+
raise(StandardError, "The node answered #{hex.inspect} instead of a hex quantity, for the balance of #{address}")
|
|
167
|
+
end
|
|
168
|
+
b = hex[2..].to_i(16)
|
|
148
169
|
log_it(:debug, "The balance of #{address} is #{b} ETHs")
|
|
149
170
|
b
|
|
150
171
|
end
|
|
151
172
|
|
|
152
173
|
# Get ERC20 amount (in tokens) that was sent in the given transaction.
|
|
153
174
|
#
|
|
175
|
+
# One transaction may carry many transfers of the same token: batch payouts,
|
|
176
|
+
# multisend contracts, swaps, and fee splits all do that. When the +to+ is
|
|
177
|
+
# given, only the transfers to that address are counted and their sum is
|
|
178
|
+
# returned. When it is not given and the transaction carries more than one
|
|
179
|
+
# transfer, the amount is ambiguous and an error is raised.
|
|
180
|
+
#
|
|
154
181
|
# @param [String] txn Hex of transaction
|
|
155
|
-
# @
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
182
|
+
# @param [String] to Public key of the receiver, in hex, starting from '0x'
|
|
183
|
+
# @return [Integer] Amount, in ERC20 tokens
|
|
184
|
+
def sum_of(txn, to: nil)
|
|
185
|
+
to_txn(txn)
|
|
186
|
+
to_address(to) unless to.nil?
|
|
160
187
|
receipt =
|
|
161
188
|
with_jsonrpc do |jr|
|
|
162
189
|
jr.eth_getTransactionReceipt(txn)
|
|
163
190
|
end
|
|
164
|
-
raise "Transaction not found: #{txn}" if receipt.nil?
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
191
|
+
raise(StandardError, "Transaction not found: #{txn}") if receipt.nil?
|
|
192
|
+
raise(StandardError, "Transaction #{txn} is reverted, its status is #{receipt['status']}") \
|
|
193
|
+
unless receipt['status'] == '0x1'
|
|
194
|
+
amounts =
|
|
195
|
+
(receipt['logs'] || []).filter_map do |log|
|
|
196
|
+
next unless log['topics'] && log['topics'][0] == TRANSFER
|
|
197
|
+
next unless log['address'].downcase == @contract.downcase
|
|
198
|
+
next unless to.nil? || log['topics'][2].to_s.downcase == "0x000000000000000000000000#{to[2..].downcase}"
|
|
199
|
+
log['data'].to_i(16)
|
|
200
|
+
end
|
|
201
|
+
raise(StandardError, "No transfer event found in transaction #{txn}") if amounts.empty?
|
|
202
|
+
if to.nil? && amounts.size > 1
|
|
203
|
+
raise(
|
|
204
|
+
StandardError,
|
|
205
|
+
"Transaction #{txn} carries #{amounts.size} transfers, tell me the receiving address to pick the right ones"
|
|
206
|
+
)
|
|
173
207
|
end
|
|
174
|
-
|
|
208
|
+
sum = amounts.sum
|
|
209
|
+
log_it(:debug, "Found transfer of #{sum} tokens in transaction #{txn}")
|
|
210
|
+
sum
|
|
175
211
|
end
|
|
176
212
|
|
|
177
213
|
# How many gas units are required to send an ERC20 transaction.
|
|
@@ -181,15 +217,9 @@ class ERC20::Wallet
|
|
|
181
217
|
# @param [Integer] amount How many ERC20 tokens to send
|
|
182
218
|
# @return [Integer] Number of gas units required
|
|
183
219
|
def gas_estimate(from, to, amount)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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?
|
|
220
|
+
to_address(from)
|
|
221
|
+
to_address(to)
|
|
222
|
+
to_amount(amount)
|
|
193
223
|
gas =
|
|
194
224
|
with_jsonrpc do |jr|
|
|
195
225
|
jr.eth_estimateGas({ from:, to: @contract, data: to_pay_data(to, amount) }, 'latest').to_i(16)
|
|
@@ -198,25 +228,41 @@ class ERC20::Wallet
|
|
|
198
228
|
gas
|
|
199
229
|
end
|
|
200
230
|
|
|
201
|
-
|
|
231
|
+
GAS_PRICE_TIP = 1_000_000_000
|
|
232
|
+
|
|
233
|
+
# What is the price of gas unit in wei?
|
|
202
234
|
#
|
|
203
235
|
# In Ethereum, gas is a unit that measures the computational work required to
|
|
204
236
|
# execute operations on the network. Every transaction and smart contract
|
|
205
237
|
# interaction consumes gas. Gas price is the amount of ETH you're willing to pay
|
|
206
|
-
# for each unit of gas, denominated in
|
|
238
|
+
# for each unit of gas, denominated in wei (1 gwei = 0.000000001 ETH). Higher
|
|
207
239
|
# gas prices incentivize miners to include your transaction sooner, while lower
|
|
208
240
|
# prices may result in longer confirmation times.
|
|
209
241
|
#
|
|
210
|
-
#
|
|
242
|
+
# The returned price is not the bare EIP-1559 base fee. The base fee alone
|
|
243
|
+
# leaves a zero miner tip (+tip = gasPrice - baseFee = 0+), so proposers have
|
|
244
|
+
# no incentive to include the transaction, and it becomes unmineable the
|
|
245
|
+
# moment the base fee rises (it may grow up to 12.5% per block). To make the
|
|
246
|
+
# price mineable, we double the base fee (a buffer that absorbs several blocks
|
|
247
|
+
# of base-fee growth) and add a priority tip (+GAS_PRICE_TIP+).
|
|
248
|
+
#
|
|
249
|
+
# The price is a ceiling, not a payment: it lands in the +maxFeePerGas+ of a
|
|
250
|
+
# type-2 transaction, where the network charges only +baseFee + tip+ per gas
|
|
251
|
+
# unit and refunds the rest of the buffer.
|
|
252
|
+
#
|
|
253
|
+
# @return [Integer] Price of gas unit, in wei (1 gwei = 0.000000001 ETH)
|
|
211
254
|
def gas_price
|
|
212
255
|
block =
|
|
213
256
|
with_jsonrpc do |jr|
|
|
214
257
|
jr.eth_getBlockByNumber('latest', false)
|
|
215
258
|
end
|
|
216
|
-
raise "Can't get gas price, try again later" if block.nil?
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
259
|
+
raise(StandardError, "Can't get gas price, try again later") if block.nil?
|
|
260
|
+
fee = block['baseFeePerGas']
|
|
261
|
+
raise(StandardError, 'The latest block has no baseFeePerGas, the chain is not EIP-1559 capable') if fee.nil?
|
|
262
|
+
base = fee.to_i(16)
|
|
263
|
+
price = (base * 2) + GAS_PRICE_TIP
|
|
264
|
+
log_it(:debug, "The base fee is #{base} wei, the cost of one gas unit is #{price} wei")
|
|
265
|
+
price
|
|
220
266
|
end
|
|
221
267
|
|
|
222
268
|
# Send a single ERC20 payment from a private address to a public one.
|
|
@@ -229,52 +275,47 @@ class ERC20::Wallet
|
|
|
229
275
|
# decrease your balance and increase the recipient's balance. This requires more
|
|
230
276
|
# gas than ETH transfers since it involves executing contract code.
|
|
231
277
|
#
|
|
278
|
+
# The nonce is fetched and the transaction is signed before the broadcast,
|
|
279
|
+
# outside of the retry loop. A broadcast that fails is repeated with the very
|
|
280
|
+
# same signed transaction, which the network either mines once or rejects as
|
|
281
|
+
# already known. Thus, no number of +attempts+ may pay twice.
|
|
282
|
+
#
|
|
283
|
+
# The transaction is a type-2 one (EIP-1559): the +price+ is the most the
|
|
284
|
+
# sender agrees to pay per gas unit, while the actual payment is only
|
|
285
|
+
# +baseFee + GAS_PRICE_TIP+.
|
|
286
|
+
#
|
|
232
287
|
# @param [String] priv Private key, in hex
|
|
233
288
|
# @param [String] address Public key, in hex
|
|
234
289
|
# @param [Integer] amount The amount of ERC20 tokens to send
|
|
235
290
|
# @param [Integer] limit How much gas you're ready to spend
|
|
236
|
-
# @param [Integer] price
|
|
291
|
+
# @param [Integer] price The most you pay per computation unit
|
|
237
292
|
# @return [String] Transaction hash
|
|
238
293
|
def pay(priv, address, amount, limit: nil, price: gas_price)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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?
|
|
248
|
-
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
|
|
252
|
-
end
|
|
253
|
-
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?
|
|
256
|
-
end
|
|
294
|
+
to_priv(priv)
|
|
295
|
+
to_address(address)
|
|
296
|
+
to_amount(amount)
|
|
297
|
+
to_limit(limit) if limit
|
|
298
|
+
to_price(price)
|
|
257
299
|
key = Eth::Key.new(priv: priv)
|
|
258
300
|
from = key.address.to_s
|
|
259
301
|
tnx =
|
|
260
302
|
@mutex.synchronize do
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
303
|
+
tx = Eth::Tx.new(
|
|
304
|
+
{
|
|
305
|
+
nonce: with_jsonrpc { |jr| jr.eth_getTransactionCount(from, 'pending').to_i(16) },
|
|
306
|
+
max_gas_fee: price,
|
|
307
|
+
priority_fee: tip(price),
|
|
266
308
|
gas_limit: limit || gas_estimate(from, address, amount),
|
|
267
309
|
to: @contract,
|
|
268
310
|
value: 0,
|
|
269
311
|
data: to_pay_data(address, amount),
|
|
270
312
|
chain_id: @chain
|
|
271
313
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
end
|
|
314
|
+
)
|
|
315
|
+
tx.sign(key)
|
|
316
|
+
hex = "0x#{tx.hex}"
|
|
317
|
+
log_it(:debug, "Sending ERC20 transaction #{hex}")
|
|
318
|
+
with_jsonrpc { |jr| jr.eth_sendRawTransaction(hex) }
|
|
278
319
|
end
|
|
279
320
|
log_it(:debug, "Sent #{amount} ERC20 tokens from #{from} to #{address}: #{tnx}")
|
|
280
321
|
tnx.downcase
|
|
@@ -285,42 +326,32 @@ class ERC20::Wallet
|
|
|
285
326
|
# @param [String] priv Private key, in hex
|
|
286
327
|
# @param [String] address Public key, in hex
|
|
287
328
|
# @param [Integer] amount The amount of ETH to send
|
|
288
|
-
# @param [Integer] price
|
|
329
|
+
# @param [Integer] price The most you pay per computation unit
|
|
289
330
|
# @return [String] Transaction hash
|
|
290
331
|
def eth_pay(priv, address, amount, price: gas_price)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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?
|
|
300
|
-
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?
|
|
303
|
-
end
|
|
332
|
+
to_priv(priv)
|
|
333
|
+
to_address(address)
|
|
334
|
+
to_amount(amount)
|
|
335
|
+
to_price(price)
|
|
304
336
|
key = Eth::Key.new(priv: priv)
|
|
305
337
|
from = key.address.to_s
|
|
306
338
|
tnx =
|
|
307
339
|
@mutex.synchronize do
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
h = {
|
|
340
|
+
tx = Eth::Tx.new(
|
|
341
|
+
{
|
|
311
342
|
chain_id: @chain,
|
|
312
|
-
nonce
|
|
313
|
-
|
|
343
|
+
nonce: with_jsonrpc { |jr| jr.eth_getTransactionCount(from, 'pending').to_i(16) },
|
|
344
|
+
max_gas_fee: price,
|
|
345
|
+
priority_fee: tip(price),
|
|
314
346
|
gas_limit: 22_000,
|
|
315
347
|
to: address,
|
|
316
348
|
value: amount
|
|
317
349
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
end
|
|
350
|
+
)
|
|
351
|
+
tx.sign(key)
|
|
352
|
+
hex = "0x#{tx.hex}"
|
|
353
|
+
log_it(:debug, "Sending ETH transaction #{hex}")
|
|
354
|
+
with_jsonrpc { |jr| jr.eth_sendRawTransaction(hex) }
|
|
324
355
|
end
|
|
325
356
|
log_it(:debug, "Sent #{amount} ETHs from #{from} to #{address}: #{tnx}")
|
|
326
357
|
tnx.downcase
|
|
@@ -339,29 +370,51 @@ class ERC20::Wallet
|
|
|
339
370
|
#
|
|
340
371
|
# The +addresses+ must have +to_a()+ implemented. This method will be
|
|
341
372
|
# called every +delay+ seconds. It is expected that it returns the list
|
|
342
|
-
# of Ethereum public addresses that must be monitored.
|
|
373
|
+
# of Ethereum public addresses that must be monitored. Every address must
|
|
374
|
+
# be a hex with the +0x+ prefix, both at the start and later, when the list
|
|
375
|
+
# changes: a malformed address makes the filter of the subscription target
|
|
376
|
+
# a different address, thus payments are never seen.
|
|
343
377
|
#
|
|
344
|
-
# The +active+ must have +append()+ and +to_a()+ implemented. This
|
|
345
|
-
#
|
|
346
|
-
#
|
|
347
|
-
# an empty array.
|
|
378
|
+
# The +active+ must have +append()+, +clear()+ and +to_a()+ implemented. This
|
|
379
|
+
# array holds the addresses that the node has confirmed a subscription for,
|
|
380
|
+
# and it is rebuilt on every confirmation. This array is used mostly for
|
|
381
|
+
# testing. It is suggested to always provide an empty array.
|
|
382
|
+
#
|
|
383
|
+
# When the node answers a subscribe request with an error, the addresses stay
|
|
384
|
+
# out of +active+, the error goes to the log, and the next subscribe attempt
|
|
385
|
+
# happens +delay+ seconds later.
|
|
386
|
+
#
|
|
387
|
+
# A dropped connection leaves a gap: the blocks mined between the disconnect
|
|
388
|
+
# and the confirmation of the new subscription are not streamed by the node.
|
|
389
|
+
# After a reconnect, the logs of that gap are fetched with +eth_getLogs+ and
|
|
390
|
+
# yielded before the live ones. A payment may arrive twice this way, because
|
|
391
|
+
# the gap starts at the block of the last payment seen, and the block must be
|
|
392
|
+
# ready for it: use +txn+ and +index+ of the event as the key of the payment.
|
|
393
|
+
#
|
|
394
|
+
# An exception from the block does not stop the stream: it goes to the log
|
|
395
|
+
# and the event is gone, since the node has no way of sending it again. The
|
|
396
|
+
# block must handle its own errors, if the payment must not be lost.
|
|
397
|
+
#
|
|
398
|
+
# A reorganization of the chain may revert a payment that was already mined.
|
|
399
|
+
# The node then re-sends its log with the +removed+ flag set. Such an event
|
|
400
|
+
# is not yielded, since the payment never happened. In +raw+ mode the event
|
|
401
|
+
# is yielded as it arrives and the +removed+ flag must be checked by the
|
|
402
|
+
# consumer.
|
|
403
|
+
#
|
|
404
|
+
# Events are yielded with zero confirmations, the moment they arrive from the
|
|
405
|
+
# node. A payment must not be treated as settled until enough blocks are
|
|
406
|
+
# mined on top of it.
|
|
348
407
|
#
|
|
349
408
|
# @param [Array<String>] addresses Addresses to monitor
|
|
350
409
|
# @param [Array] active List of addresses that we are actually listening to
|
|
351
410
|
# @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
|
|
352
|
-
# @param [
|
|
411
|
+
# @param [Numeric] delay How many seconds to wait between +eth_subscribe+ calls
|
|
353
412
|
# @param [Integer] subscription_id Unique ID of the subscription
|
|
354
|
-
def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(99_999), &)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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?
|
|
413
|
+
def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(1..99_999), &)
|
|
414
|
+
to_addresses(addresses)
|
|
415
|
+
to_active(active)
|
|
416
|
+
to_delay(delay)
|
|
417
|
+
to_subscription(subscription_id)
|
|
365
418
|
EventMachine.run do
|
|
366
419
|
reaccept(addresses, active, raw:, delay:, subscription_id:, &)
|
|
367
420
|
end
|
|
@@ -372,16 +425,19 @@ class ERC20::Wallet
|
|
|
372
425
|
# @param [Array<String>] addresses Addresses to monitor
|
|
373
426
|
# @param [Array] active List of addresses that we are actually listening to
|
|
374
427
|
# @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
|
|
375
|
-
# @param [
|
|
428
|
+
# @param [Numeric] delay How many seconds to wait between +eth_subscribe+ calls
|
|
376
429
|
# @param [Integer] subscription_id Unique ID of the subscription
|
|
430
|
+
# @param [Integer] since The number of the last block we have seen a payment in
|
|
377
431
|
# @return [Websocket]
|
|
378
|
-
def reaccept(addresses, active, raw:, delay:, subscription_id:, &)
|
|
432
|
+
def reaccept(addresses, active, raw:, delay:, subscription_id:, since: nil, &)
|
|
379
433
|
u = url(http: false)
|
|
380
434
|
log_it(:debug, "Connecting ##{subscription_id} to #{u.hostname}:#{u.port}...")
|
|
381
|
-
contract = @contract
|
|
382
435
|
log_url = "ws#{'s' if @ssl}://#{u.hostname}:#{u.port}"
|
|
383
436
|
ws = Faye::WebSocket::Client.new(u.to_s, [], proxy: @proxy ? { origin: @proxy } : {}, ping: 60)
|
|
384
437
|
timer = nil
|
|
438
|
+
subscription = nil
|
|
439
|
+
wanted = nil
|
|
440
|
+
height = since
|
|
385
441
|
ws.on(:open) do
|
|
386
442
|
safe do
|
|
387
443
|
verbose do
|
|
@@ -389,28 +445,33 @@ class ERC20::Wallet
|
|
|
389
445
|
timer =
|
|
390
446
|
EventMachine.add_periodic_timer(delay) do
|
|
391
447
|
next if active.to_a.sort == addresses.to_a.sort
|
|
448
|
+
wanted = to_addresses(addresses).dup
|
|
449
|
+
# rubocop:disable Style/Send
|
|
450
|
+
if subscription
|
|
451
|
+
ws.send(
|
|
452
|
+
{
|
|
453
|
+
jsonrpc: '2.0',
|
|
454
|
+
id: subscription_id + 1,
|
|
455
|
+
method: 'eth_unsubscribe',
|
|
456
|
+
params: [subscription]
|
|
457
|
+
}.to_json
|
|
458
|
+
)
|
|
459
|
+
log_it(:debug, "Requested to unsubscribe ##{subscription_id} from #{subscription}")
|
|
460
|
+
subscription = nil
|
|
461
|
+
end
|
|
392
462
|
ws.send(
|
|
393
463
|
{
|
|
394
464
|
jsonrpc: '2.0',
|
|
395
465
|
id: subscription_id,
|
|
396
466
|
method: 'eth_subscribe',
|
|
397
|
-
params: [
|
|
398
|
-
'logs',
|
|
399
|
-
{
|
|
400
|
-
address: contract,
|
|
401
|
-
topics: [
|
|
402
|
-
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
|
|
403
|
-
nil,
|
|
404
|
-
addresses.to_a.map { |a| "0x000000000000000000000000#{a[2..]}" }
|
|
405
|
-
]
|
|
406
|
-
}
|
|
407
|
-
]
|
|
467
|
+
params: ['logs', to_filter(wanted)]
|
|
408
468
|
}.to_json
|
|
409
469
|
)
|
|
470
|
+
# rubocop:enable Style/Send
|
|
410
471
|
log_it(
|
|
411
472
|
:debug,
|
|
412
|
-
"Requested to subscribe ##{subscription_id} to #{
|
|
413
|
-
"#{
|
|
473
|
+
"Requested to subscribe ##{subscription_id} to #{wanted.size} addresses: " \
|
|
474
|
+
"#{wanted.map { |a| a[0..6] }.join(', ')}"
|
|
414
475
|
)
|
|
415
476
|
end
|
|
416
477
|
end
|
|
@@ -420,35 +481,41 @@ class ERC20::Wallet
|
|
|
420
481
|
safe do
|
|
421
482
|
verbose do
|
|
422
483
|
data = to_json(msg)
|
|
423
|
-
if data['
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
484
|
+
if data['error']
|
|
485
|
+
active.clear if data['id'] == subscription_id
|
|
486
|
+
log_it(:error, "Request ##{data['id']} was rejected by #{log_url}: #{data['error']}")
|
|
487
|
+
elsif data['id'] == subscription_id
|
|
488
|
+
subscription = data['result']
|
|
489
|
+
active.clear
|
|
490
|
+
wanted&.each { |a| active.append(a) }
|
|
429
491
|
log_it(
|
|
430
492
|
:debug,
|
|
431
493
|
"Subscribed ##{subscription_id} to #{active.to_a.size} addresses at #{log_url}: " \
|
|
432
494
|
"#{active.to_a.map { |a| a[0..6] }.join(', ')}"
|
|
433
495
|
)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
txn: event['transactionHash'].downcase
|
|
444
|
-
}
|
|
445
|
-
log_it(
|
|
446
|
-
:debug,
|
|
447
|
-
"Payment of #{event[:amount]} tokens arrived at ##{subscription_id} " \
|
|
448
|
-
"from #{event[:from]} to #{event[:to]} in #{event[:txn]}"
|
|
496
|
+
if since
|
|
497
|
+
# rubocop:disable Style/Send
|
|
498
|
+
ws.send(
|
|
499
|
+
{
|
|
500
|
+
jsonrpc: '2.0',
|
|
501
|
+
id: subscription_id + 2,
|
|
502
|
+
method: 'eth_getLogs',
|
|
503
|
+
params: [to_filter(wanted).merge(fromBlock: format('0x%x', since + 1), toBlock: 'latest')]
|
|
504
|
+
}.to_json
|
|
449
505
|
)
|
|
506
|
+
# rubocop:enable Style/Send
|
|
507
|
+
log_it(:debug, "Requested ##{subscription_id} the logs of the blocks after ##{since}")
|
|
508
|
+
since = nil
|
|
450
509
|
end
|
|
451
|
-
|
|
510
|
+
elsif data['id'] == subscription_id + 2
|
|
511
|
+
log_it(:debug, "Received #{data['result'].size} logs mined while ##{subscription_id} was offline")
|
|
512
|
+
data['result'].each do |log|
|
|
513
|
+
height = [height, log['blockNumber'].to_s.to_i(16)].compact.max
|
|
514
|
+
deliver(log, raw:, id: subscription_id, &)
|
|
515
|
+
end
|
|
516
|
+
elsif data['method'] == 'eth_subscription' && data.dig('params', 'result')
|
|
517
|
+
height = [height, data['params']['result']['blockNumber'].to_s.to_i(16)].compact.max
|
|
518
|
+
deliver(data['params']['result'], raw:, id: subscription_id, &)
|
|
452
519
|
end
|
|
453
520
|
end
|
|
454
521
|
end
|
|
@@ -459,7 +526,9 @@ class ERC20::Wallet
|
|
|
459
526
|
log_it(:debug, "Disconnected ##{subscription_id} from #{log_url}")
|
|
460
527
|
active.clear
|
|
461
528
|
timer&.cancel
|
|
462
|
-
|
|
529
|
+
EventMachine.add_timer(delay) do
|
|
530
|
+
reaccept(addresses, active, raw:, delay:, subscription_id: subscription_id + 1, since: height, &)
|
|
531
|
+
end
|
|
463
532
|
end
|
|
464
533
|
end
|
|
465
534
|
end
|
|
@@ -472,9 +541,55 @@ class ERC20::Wallet
|
|
|
472
541
|
end
|
|
473
542
|
end
|
|
474
543
|
|
|
544
|
+
def to_filter(addresses)
|
|
545
|
+
{
|
|
546
|
+
address: @contract,
|
|
547
|
+
topics: [
|
|
548
|
+
TRANSFER,
|
|
549
|
+
nil,
|
|
550
|
+
addresses.to_a.map { |a| "0x000000000000000000000000#{a[2..]}" }
|
|
551
|
+
]
|
|
552
|
+
}
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def to_event(log)
|
|
556
|
+
{
|
|
557
|
+
amount: log['data'].to_i(16),
|
|
558
|
+
block: log['blockNumber'].to_s.to_i(16),
|
|
559
|
+
from: "0x#{log['topics'][1][26..].downcase}",
|
|
560
|
+
index: log['logIndex'].to_s.to_i(16),
|
|
561
|
+
to: "0x#{log['topics'][2][26..].downcase}",
|
|
562
|
+
txn: log['transactionHash'].downcase
|
|
563
|
+
}
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def deliver(log, raw:, id:, &)
|
|
567
|
+
if raw
|
|
568
|
+
log_it(:debug, "New event arrived from #{log['address']}")
|
|
569
|
+
digest(log, log['transactionHash'], &)
|
|
570
|
+
elsif log['removed']
|
|
571
|
+
log_it(:debug, "Payment in #{log['transactionHash']} is reverted by a reorganization of the chain, ignoring it")
|
|
572
|
+
else
|
|
573
|
+
event = to_event(log)
|
|
574
|
+
log_it(
|
|
575
|
+
:debug,
|
|
576
|
+
"Payment of #{event[:amount]} tokens arrived at ##{id} " \
|
|
577
|
+
"from #{event[:from]} to #{event[:to]} in #{event[:txn]}"
|
|
578
|
+
)
|
|
579
|
+
digest(event, event[:txn], &)
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def digest(event, txn)
|
|
584
|
+
yield(event)
|
|
585
|
+
rescue StandardError => e
|
|
586
|
+
log_it(:error, "The block failed to process the payment in #{txn}, the event is lost (#{e.class}): #{e.message}")
|
|
587
|
+
end
|
|
588
|
+
|
|
475
589
|
def to_json(msg)
|
|
476
590
|
JSON.parse(msg.data)
|
|
477
|
-
rescue StandardError
|
|
591
|
+
rescue StandardError => e
|
|
592
|
+
log_it(:error, "Failed to parse a frame of #{msg.data.to_s.length} bytes (#{e.message}): #{msg.data}")
|
|
478
593
|
{}
|
|
479
594
|
end
|
|
480
595
|
|
|
@@ -482,13 +597,13 @@ class ERC20::Wallet
|
|
|
482
597
|
yield
|
|
483
598
|
rescue StandardError => e
|
|
484
599
|
log_it(:error, Backtrace.new(e).to_s)
|
|
485
|
-
raise
|
|
600
|
+
raise(e)
|
|
486
601
|
end
|
|
487
602
|
|
|
488
603
|
def safe
|
|
489
604
|
yield
|
|
490
605
|
rescue StandardError
|
|
491
|
-
|
|
606
|
+
nil
|
|
492
607
|
end
|
|
493
608
|
|
|
494
609
|
def url(http: true)
|
|
@@ -503,25 +618,33 @@ class ERC20::Wallet
|
|
|
503
618
|
opts[:connection] =
|
|
504
619
|
Faraday.new do |f|
|
|
505
620
|
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
|
-
}
|
|
621
|
+
f.proxy = { uri: "#{uri.scheme}://#{uri.hostname}:#{uri.port}", user: uri.user, password: uri.password }
|
|
511
622
|
end
|
|
512
623
|
end
|
|
513
|
-
|
|
514
|
-
|
|
624
|
+
endpoints = [url.to_s] + @fallbacks
|
|
625
|
+
budget = @attempts * endpoints.size
|
|
626
|
+
tried = 0
|
|
627
|
+
begin
|
|
628
|
+
u = URI.parse(endpoints[tried % endpoints.size])
|
|
629
|
+
tried += 1
|
|
630
|
+
elapsed(@log, good: "Talked to #{u.host}:#{u.port}") do
|
|
631
|
+
yield(JSONRPC::Client.new(u.to_s, opts))
|
|
632
|
+
end
|
|
633
|
+
rescue StandardError => e
|
|
634
|
+
raise if tried >= budget
|
|
635
|
+
pause = (tried % endpoints.size).zero? ? 2**((tried / endpoints.size) - 1) : 0
|
|
636
|
+
log_it(:debug, "Attempt #{tried}/#{budget} to #{u.host} failed (#{e.class}), retrying in #{pause}s")
|
|
637
|
+
sleep(pause)
|
|
638
|
+
retry
|
|
515
639
|
end
|
|
516
640
|
end
|
|
517
641
|
|
|
642
|
+
def tip(price)
|
|
643
|
+
[GAS_PRICE_TIP, price].min
|
|
644
|
+
end
|
|
645
|
+
|
|
518
646
|
def to_pay_data(address, amount)
|
|
519
|
-
|
|
520
|
-
to_clean = address.downcase.sub(/^0x/, '')
|
|
521
|
-
to_padded = ('0' * (64 - to_clean.size)) + to_clean
|
|
522
|
-
amt_hex = amount.to_s(16)
|
|
523
|
-
amt_padded = ('0' * (64 - amt_hex.size)) + amt_hex
|
|
524
|
-
"0x#{func}#{to_padded}#{amt_padded}"
|
|
647
|
+
"0xa9059cbb#{format('%064x', address.to_i(16))}#{format('%064x', amount)}"
|
|
525
648
|
end
|
|
526
649
|
|
|
527
650
|
def log_it(method, msg)
|