Class: ERC20::Wallet

Inherits:
Object
  • Object
show all
Includes:
Checks
Defined in:
lib/erc20/wallet.rb

Overview

A wallet with ERC20 tokens on Ethereum.

Objects of this class are thread-safe.

In order to check the balance of ERC20 address:

require 'erc20' w = ERC20::Wallet.new( contract: ERC20::Wallet.USDT, # hex of it host: 'mainnet.infura.io', http_path: '/v3/', ws_path: '/ws/v3/', log: $stdout ) usdt = w.balance(address)

In order to send a payment:

hex = w.pay(private_key, to_address, amount)

In order to catch incoming payments to a set of addresses:

addresses = ['0x...', '0x...'] w.accept(addresses) do |event| puts event # hash of transaction puts event # how much, in tokens (1000000 = $1 USDT) puts event # who sent the payment puts event # who was the receiver end

To connect to the server via HTTP proxy with basic authentication:

w = ERC20::Wallet.new( host: 'go.getblock.io', http_path: '/', ws_path: '/', proxy: 'http://jeffrey:swordfish@example.com:3128' # here! )

More information in our README.

Author

Yegor Bugayenko (yegor256@gmail.com)

Copyright

Copyright (c) 2025 Yegor Bugayenko

License

MIT

Constant Summary collapse

USDT =
'0xdac17f958d2ee523a2206206994597c13d831ec7'
TRANSFER =
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
GAS_PRICE_TIP =
1_000_000_000

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(contract: USDT, chain: 1, log: $stdout, host: nil, port: 443, http_path: '/', ws_path: '/', ssl: true, proxy: nil, attempts: 1, fallbacks: []) ⇒ Wallet

Constructor.

Parameters:

  • contract (String) (defaults to: USDT)

    Hex of the contract in Ethereum

  • chain (Integer) (defaults to: 1)

    The ID of the chain (1 for mainnet)

  • host (String) (defaults to: nil)

    The host to connect to

  • port (Integer) (defaults to: 443)

    TCP port to use

  • http_path (String) (defaults to: '/')

    The path in the connection URL, for HTTP RPC

  • ws_path (String) (defaults to: '/')

    The path in the connection URL, for Websockets

  • ssl (Boolean) (defaults to: true)

    Should we use SSL (for https and wss)

  • proxy (String) (defaults to: nil)

    The URL of the proxy to use

  • attempts (Integer) (defaults to: 1)

    How many times to try every HTTP RPC endpoint before giving up

  • fallbacks (Array<String>) (defaults to: [])

    Alternative HTTP RPC endpoint URLs to try when the primary one fails

  • log (Object) (defaults to: $stdout)

    The destination for logs

Raises:

  • (ArgumentError)


81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/erc20/wallet.rb', line 81

def initialize(
  contract: USDT, chain: 1, log: $stdout,
  host: nil, port: 443, http_path: '/', ws_path: '/',
  ssl: true, proxy: nil, attempts: 1, fallbacks: []
)
  raise(ArgumentError, 'Contract can\'t be nil') unless contract
  raise(ArgumentError, 'Contract must be a String') unless contract.is_a?(String)
  raise(ArgumentError, 'Invalid format of the contract') unless /^0x[0-9a-fA-F]{40}$/.match?(contract)
  @contract = contract
  raise(ArgumentError, 'Host can\'t be nil') unless host
  raise(ArgumentError, 'Host must be a String') unless host.is_a?(String)
  @host = host
  raise(ArgumentError, 'Port can\'t be nil') unless port
  raise(ArgumentError, 'Port must be an Integer') unless port.is_a?(Integer)
  raise(ArgumentError, 'Port must be a positive Integer') unless port.positive?
  @port = port
  raise(ArgumentError, 'Ssl can\'t be nil') if ssl.nil?
  @ssl = ssl
  raise(ArgumentError, 'Http_path can\'t be nil') unless http_path
  raise(ArgumentError, 'Http_path must be a String') unless http_path.is_a?(String)
  @http_path = http_path
  raise(ArgumentError, 'Ws_path can\'t be nil') unless ws_path
  raise(ArgumentError, 'Ws_path must be a String') unless ws_path.is_a?(String)
  @ws_path = ws_path
  raise(ArgumentError, 'Log can\'t be nil') unless log
  @log = log
  raise(ArgumentError, 'Chain can\'t be nil') unless chain
  raise(ArgumentError, 'Chain must be an Integer') unless chain.is_a?(Integer)
  raise(ArgumentError, 'Chain must be a positive Integer') unless chain.positive?
  @chain = chain
  @proxy = proxy
  raise(ArgumentError, 'Attempts can\'t be nil') unless attempts
  raise(ArgumentError, 'Attempts must be an Integer') unless attempts.is_a?(Integer)
  raise(ArgumentError, 'Attempts must be a positive Integer') unless attempts.positive?
  @attempts = attempts
  raise(ArgumentError, 'Fallbacks can\'t be nil') if fallbacks.nil?
  raise(ArgumentError, 'Fallbacks must be an Array') unless fallbacks.is_a?(Array)
  fallbacks.each do |f|
    raise(ArgumentError, 'Each fallback must be a String') unless f.is_a?(String)
  end
  @fallbacks = fallbacks
  @mutex = Mutex.new
end

Instance Attribute Details

#chainObject (readonly)

Returns the value of attribute chain.



67
68
69
# File 'lib/erc20/wallet.rb', line 67

def chain
  @chain
end

#contractObject (readonly)

Returns the value of attribute contract.



67
68
69
# File 'lib/erc20/wallet.rb', line 67

def contract
  @contract
end

#hostObject (readonly)

Returns the value of attribute host.



67
68
69
# File 'lib/erc20/wallet.rb', line 67

def host
  @host
end

#http_pathObject (readonly)

Returns the value of attribute http_path.



67
68
69
# File 'lib/erc20/wallet.rb', line 67

def http_path
  @http_path
end

#portObject (readonly)

Returns the value of attribute port.



67
68
69
# File 'lib/erc20/wallet.rb', line 67

def port
  @port
end

#sslObject (readonly)

Returns the value of attribute ssl.



67
68
69
# File 'lib/erc20/wallet.rb', line 67

def ssl
  @ssl
end

#ws_pathObject (readonly)

Returns the value of attribute ws_path.



67
68
69
# File 'lib/erc20/wallet.rb', line 67

def ws_path
  @ws_path
end

Instance Method Details

#accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(1..99_999)) ⇒ Object

Wait for incoming transactions and let the block know when they arrive. It's a blocking call, it's better to run it in a separate thread. It will never finish. In order to stop it, you should do Thread.kill.

The array with the list of addresses (+addresses+) may change its content on-the-fly. The accept() method will eth_subscribe to the addresses that are added and will eth_unsubscribe from those that are removed. Once we actually start listening, the active array will be updated with the list of addresses.

The addresses must have to_a() implemented. This method will be called every delay seconds. It is expected that it returns the list of Ethereum public addresses that must be monitored. Every address must be a hex with the 0x prefix, both at the start and later, when the list changes: a malformed address makes the filter of the subscription target a different address, thus payments are never seen.

The active must have append(), clear() and to_a() implemented. This array holds the addresses that the node has confirmed a subscription for, and it is rebuilt on every confirmation. This array is used mostly for testing. It is suggested to always provide an empty array.

When the node answers a subscribe request with an error, the addresses stay out of active, the error goes to the log, and the next subscribe attempt happens delay seconds later.

A dropped connection leaves a gap: the blocks mined between the disconnect and the confirmation of the new subscription are not streamed by the node. After a reconnect, the logs of that gap are fetched with eth_getLogs and yielded before the live ones. A payment may arrive twice this way, because the gap starts at the block of the last payment seen, and the block must be ready for it: use txn and index of the event as the key of the payment.

An exception from the block does not stop the stream: it goes to the log and the event is gone, since the node has no way of sending it again. The block must handle its own errors, if the payment must not be lost.

A reorganization of the chain may revert a payment that was already mined. The node then re-sends its log with the removed flag set. Such an event is not yielded, since the payment never happened. In raw mode the event is yielded as it arrives and the removed flag must be checked by the consumer.

Events are yielded with zero confirmations, the moment they arrive from the node. A payment must not be treated as settled until enough blocks are mined on top of it.

Parameters:

  • addresses (Array<String>)

    Addresses to monitor

  • active (Array) (defaults to: [])

    List of addresses that we are actually listening to

  • raw (Boolean) (defaults to: false)

    TRUE if you need to get JSON events as they arrive from Websockets

  • delay (Numeric) (defaults to: 1)

    How many seconds to wait between eth_subscribe calls

  • subscription_id (Integer) (defaults to: rand(1..99_999))

    Unique ID of the subscription



413
414
415
416
417
418
419
420
421
# File 'lib/erc20/wallet.rb', line 413

def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(1..99_999), &)
  to_addresses(addresses)
  to_active(active)
  to_delay(delay)
  to_subscription(subscription_id)
  EventMachine.run do
    reaccept(addresses, active, raw:, delay:, subscription_id:, &)
  end
end

#balance(address) ⇒ Integer

Get ERC20 balance of a public address (it's not the same as ETH balance!).

An address in Ethereum may have many balances. One of them is the main balance in ETH crypto. Another balance is the one kept by the ERC20 contract in its own ledger in root storage. This balance is checked by this method.

An address that has no tokens has a balance of zero, but so does an address asked at the wrong contract or in the wrong chain: there, eth_call succeeds with empty return data. A balanceOf always answers with a single 32-byte word, thus anything shorter is a misconfiguration and an error is raised, instead of a zero that nobody may tell from a genuinely empty balance.

Parameters:

  • address (String)

    Public key, in hex, starting from '0x'

Returns:

  • (Integer)

    Balance, in tokens



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/erc20/wallet.rb', line 139

def balance(address)
  to_address(address)
  data = "0x70a08231000000000000000000000000#{address[2..].downcase}"
  hex = with_jsonrpc { |jr| jr.eth_call({ to: @contract, data: data }, 'latest') }
  unless /^0x[0-9a-fA-F]{64}$/.match?(hex)
    raise(
      StandardError,
      "The #{@contract} contract in chain #{@chain} answered #{hex.inspect} instead of " \
      'a 32-byte word, it may not be an ERC20 contract at all'
    )
  end
  b = hex[2..].to_i(16)
  log_it(:debug, "The balance of #{address} is #{b} ERC20 tokens")
  b
end

#eth_balance(address) ⇒ Integer

Get ETH balance of a public address.

An address in Ethereum may have many balances. One of them is the main balance in ETH crypto. This balance is checked by this method.

Parameters:

  • address (String)

    Public key, in hex, starting from '0x'

Returns:

  • (Integer)

    Balance, in ETH



162
163
164
165
166
167
168
169
170
171
# File 'lib/erc20/wallet.rb', line 162

def eth_balance(address)
  to_address(address)
  hex = with_jsonrpc { |jr| jr.eth_getBalance(address, 'latest') }
  unless /^0x[0-9a-fA-F]+$/.match?(hex)
    raise(StandardError, "The node answered #{hex.inspect} instead of a hex quantity, for the balance of #{address}")
  end
  b = hex[2..].to_i(16)
  log_it(:debug, "The balance of #{address} is #{b} ETHs")
  b
end

#eth_pay(priv, address, amount, price: gas_price) ⇒ String

Send a single ETH payment from a private address to a public one.

Parameters:

  • priv (String)

    Private key, in hex

  • address (String)

    Public key, in hex

  • amount (Integer)

    The amount of ETH to send

  • price (Integer) (defaults to: gas_price)

    The most you pay per computation unit

Returns:

  • (String)

    Transaction hash



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/erc20/wallet.rb', line 331

def eth_pay(priv, address, amount, price: gas_price)
  to_priv(priv)
  to_address(address)
  to_amount(amount)
  to_price(price)
  key = Eth::Key.new(priv: priv)
  from = key.address.to_s
  tnx =
    @mutex.synchronize do
      tx = Eth::Tx.new(
        {
          chain_id: @chain,
          nonce: with_jsonrpc { |jr| jr.eth_getTransactionCount(from, 'pending').to_i(16) },
          max_gas_fee: price,
          priority_fee: tip(price),
          gas_limit: 22_000,
          to: address,
          value: amount
        }
      )
      tx.sign(key)
      hex = "0x#{tx.hex}"
      log_it(:debug, "Sending ETH transaction #{hex}")
      with_jsonrpc { |jr| jr.eth_sendRawTransaction(hex) }
    end
  log_it(:debug, "Sent #{amount} ETHs from #{from} to #{address}: #{tnx}")
  tnx.downcase
end

#gas_estimate(from, to, amount) ⇒ Integer

How many gas units are required to send an ERC20 transaction.

Parameters:

  • from (String)

    The sending address, in hex

  • to (String)

    The receiving address, in hex

  • amount (Integer)

    How many ERC20 tokens to send

Returns:

  • (Integer)

    Number of gas units required



219
220
221
222
223
224
225
226
227
228
229
# File 'lib/erc20/wallet.rb', line 219

def gas_estimate(from, to, amount)
  to_address(from)
  to_address(to)
  to_amount(amount)
  gas =
    with_jsonrpc do |jr|
      jr.eth_estimateGas({ from:, to: @contract, data: to_pay_data(to, amount) }, 'latest').to_i(16)
    end
  log_it(:debug, "It would take #{gas} gas units to send #{amount} tokens from #{from} to #{to}")
  gas
end

#gas_priceInteger

What is the price of gas unit in wei?

In Ethereum, gas is a unit that measures the computational work required to execute operations on the network. Every transaction and smart contract interaction consumes gas. Gas price is the amount of ETH you're willing to pay for each unit of gas, denominated in wei (1 gwei = 0.000000001 ETH). Higher gas prices incentivize miners to include your transaction sooner, while lower prices may result in longer confirmation times.

The returned price is not the bare EIP-1559 base fee. The base fee alone leaves a zero miner tip (+tip = gasPrice - baseFee = 0+), so proposers have no incentive to include the transaction, and it becomes unmineable the moment the base fee rises (it may grow up to 12.5% per block). To make the price mineable, we double the base fee (a buffer that absorbs several blocks of base-fee growth) and add a priority tip (+GAS_PRICE_TIP+).

The price is a ceiling, not a payment: it lands in the maxFeePerGas of a type-2 transaction, where the network charges only +baseFee + tip+ per gas unit and refunds the rest of the buffer.

Returns:

  • (Integer)

    Price of gas unit, in wei (1 gwei = 0.000000001 ETH)

Raises:

  • (StandardError)


254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/erc20/wallet.rb', line 254

def gas_price
  block =
    with_jsonrpc do |jr|
      jr.eth_getBlockByNumber('latest', false)
    end
  raise(StandardError, "Can't get gas price, try again later") if block.nil?
  fee = block['baseFeePerGas']
  raise(StandardError, 'The latest block has no baseFeePerGas, the chain is not EIP-1559 capable') if fee.nil?
  base = fee.to_i(16)
  price = (base * 2) + GAS_PRICE_TIP
  log_it(:debug, "The base fee is #{base} wei, the cost of one gas unit is #{price} wei")
  price
end

#pay(priv, address, amount, limit: nil, price: gas_price) ⇒ String

Send a single ERC20 payment from a private address to a public one.

ERC20 payments differ fundamentally from native ETH transfers. While ETH transfers simply move the cryptocurrency directly between addresses, ERC20 token transfers are actually interactions with a smart contract. When you transfer ERC20 tokens, you're not sending anything directly to another user - instead, you're calling the token contract's transfer function, which updates its internal ledger to decrease your balance and increase the recipient's balance. This requires more gas than ETH transfers since it involves executing contract code.

The nonce is fetched and the transaction is signed before the broadcast, outside of the retry loop. A broadcast that fails is repeated with the very same signed transaction, which the network either mines once or rejects as already known. Thus, no number of attempts may pay twice.

The transaction is a type-2 one (EIP-1559): the price is the most the sender agrees to pay per gas unit, while the actual payment is only +baseFee + GAS_PRICE_TIP+.

Parameters:

  • priv (String)

    Private key, in hex

  • address (String)

    Public key, in hex

  • amount (Integer)

    The amount of ERC20 tokens to send

  • limit (Integer) (defaults to: nil)

    How much gas you're ready to spend

  • price (Integer) (defaults to: gas_price)

    The most you pay per computation unit

Returns:

  • (String)

    Transaction hash



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/erc20/wallet.rb', line 293

def pay(priv, address, amount, limit: nil, price: gas_price)
  to_priv(priv)
  to_address(address)
  to_amount(amount)
  to_limit(limit) if limit
  to_price(price)
  key = Eth::Key.new(priv: priv)
  from = key.address.to_s
  tnx =
    @mutex.synchronize do
      tx = Eth::Tx.new(
        {
          nonce: with_jsonrpc { |jr| jr.eth_getTransactionCount(from, 'pending').to_i(16) },
          max_gas_fee: price,
          priority_fee: tip(price),
          gas_limit: limit || gas_estimate(from, address, amount),
          to: @contract,
          value: 0,
          data: to_pay_data(address, amount),
          chain_id: @chain
        }
      )
      tx.sign(key)
      hex = "0x#{tx.hex}"
      log_it(:debug, "Sending ERC20 transaction #{hex}")
      with_jsonrpc { |jr| jr.eth_sendRawTransaction(hex) }
    end
  log_it(:debug, "Sent #{amount} ERC20 tokens from #{from} to #{address}: #{tnx}")
  tnx.downcase
end

#sum_of(txn, to: nil) ⇒ Integer

Get ERC20 amount (in tokens) that was sent in the given transaction.

One transaction may carry many transfers of the same token: batch payouts, multisend contracts, swaps, and fee splits all do that. When the to is given, only the transfers to that address are counted and their sum is returned. When it is not given and the transaction carries more than one transfer, the amount is ambiguous and an error is raised.

Parameters:

  • txn (String)

    Hex of transaction

  • to (String) (defaults to: nil)

    Public key of the receiver, in hex, starting from '0x'

Returns:

  • (Integer)

    Amount, in ERC20 tokens

Raises:

  • (StandardError)


184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/erc20/wallet.rb', line 184

def sum_of(txn, to: nil)
  to_txn(txn)
  to_address(to) unless to.nil?
  receipt =
    with_jsonrpc do |jr|
      jr.eth_getTransactionReceipt(txn)
    end
  raise(StandardError, "Transaction not found: #{txn}") if receipt.nil?
  raise(StandardError, "Transaction #{txn} is reverted, its status is #{receipt['status']}") \
    unless receipt['status'] == '0x1'
  amounts =
    (receipt['logs'] || []).filter_map do |log|
      next unless log['topics'] && log['topics'][0] == TRANSFER
      next unless log['address'].downcase == @contract.downcase
      next unless to.nil? || log['topics'][2].to_s.downcase == "0x000000000000000000000000#{to[2..].downcase}"
      log['data'].to_i(16)
    end
  raise(StandardError, "No transfer event found in transaction #{txn}") if amounts.empty?
  if to.nil? && amounts.size > 1
    raise(
      StandardError,
      "Transaction #{txn} carries #{amounts.size} transfers, tell me the receiving address to pick the right ones"
    )
  end
  sum = amounts.sum
  log_it(:debug, "Found transfer of #{sum} tokens in transaction #{txn}")
  sum
end