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.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require 'eth'
7
+ require_relative 'erc20'
8
+
9
+ # The checks that every wallet makes before it touches the network.
10
+ #
11
+ # A wallet deals with private keys, public addresses, and amounts of money. A
12
+ # broken one of them either costs money or silently loses it, thus it is
13
+ # rejected the moment it arrives. Both +ERC20::Wallet+ and +ERC20::FakeWallet+
14
+ # make these very checks, so that a test that passes against the fake wallet
15
+ # doesn't fail against a real provider.
16
+ #
17
+ # Author:: Yegor Bugayenko (yegor256@gmail.com)
18
+ # Copyright:: Copyright (c) 2025 Yegor Bugayenko
19
+ # License:: MIT
20
+ module ERC20::Checks
21
+ HEX = /^0x[0-9a-fA-F]{40}$/
22
+ private_constant :HEX
23
+
24
+ MAX_AMOUNT = (2**256) - 1
25
+ private_constant :MAX_AMOUNT
26
+
27
+ private
28
+
29
+ def to_priv(priv)
30
+ raise(ArgumentError, 'Private key can\'t be nil') unless priv
31
+ raise(ArgumentError, 'Private key must be a String') unless priv.is_a?(String)
32
+ raise(ArgumentError, 'Invalid format of private key') unless /^[0-9a-fA-F]{64}$/.match?(priv)
33
+ priv
34
+ end
35
+
36
+ def to_address(address)
37
+ raise(ArgumentError, 'Address can\'t be nil') unless address
38
+ raise(ArgumentError, 'Address must be a String') unless address.is_a?(String)
39
+ raise(ArgumentError, 'Invalid format of the address') unless HEX.match?(address)
40
+ address
41
+ end
42
+
43
+ def to_addresses(addresses)
44
+ raise(ArgumentError, 'Addresses can\'t be nil') unless addresses
45
+ raise(ArgumentError, 'Addresses must respond to .to_a()') unless addresses.respond_to?(:to_a)
46
+ addresses.to_a.each do |a|
47
+ raise(ArgumentError, 'Each address must be a String') unless a.is_a?(String)
48
+ raise(ArgumentError, "Invalid format of the address (#{a})") unless HEX.match?(a)
49
+ end
50
+ end
51
+
52
+ def to_active(active)
53
+ raise(ArgumentError, 'Active can\'t be nil') unless active
54
+ raise(ArgumentError, 'Active must respond to .to_a()') unless active.respond_to?(:to_a)
55
+ raise(ArgumentError, 'Active must respond to .append()') unless active.respond_to?(:append)
56
+ raise(ArgumentError, 'Active must respond to .clear()') unless active.respond_to?(:clear)
57
+ active
58
+ end
59
+
60
+ def to_txn(txn)
61
+ raise(ArgumentError, 'Transaction hash can\'t be nil') unless txn
62
+ raise(ArgumentError, 'Transaction hash must be a String') unless txn.is_a?(String)
63
+ raise(ArgumentError, 'Invalid format of the transaction hash') unless /^0x[0-9a-fA-F]{64}$/.match?(txn)
64
+ txn
65
+ end
66
+
67
+ def to_amount(amount)
68
+ raise(ArgumentError, 'Amount can\'t be nil') unless amount
69
+ raise(ArgumentError, "Amount (#{amount}) must be an Integer") unless amount.is_a?(Integer)
70
+ raise(ArgumentError, "Amount (#{amount}) must be a positive Integer") unless amount.positive?
71
+ raise(ArgumentError, "Amount (#{amount}) must fit into uint256") if amount > MAX_AMOUNT
72
+ amount
73
+ end
74
+
75
+ def to_limit(limit)
76
+ raise(ArgumentError, 'Gas limit must be an Integer') unless limit.is_a?(Integer)
77
+ least = Eth::Tx::DEFAULT_GAS_LIMIT
78
+ raise(ArgumentError, "Gas limit #{limit} is below #{least}") if limit < least
79
+ most = Eth::Tx::BLOCK_GAS_LIMIT
80
+ raise(ArgumentError, "Gas limit #{limit} is above #{most}") if limit > most
81
+ limit
82
+ end
83
+
84
+ def to_price(price)
85
+ raise(ArgumentError, 'Gas price can\'t be nil') unless price
86
+ raise(ArgumentError, 'Gas price must be an Integer') unless price.is_a?(Integer)
87
+ raise(ArgumentError, 'Gas price must be a positive Integer') unless price.positive?
88
+ price
89
+ end
90
+
91
+ def to_delay(delay)
92
+ raise(ArgumentError, 'Delay must be a number') unless delay.is_a?(Numeric)
93
+ raise(ArgumentError, 'Delay must be a positive number') unless delay.positive?
94
+ delay
95
+ end
96
+
97
+ def to_subscription(id)
98
+ raise(ArgumentError, 'Subscription ID must be an Integer') unless id.is_a?(Integer)
99
+ raise(ArgumentError, 'Subscription ID must be a positive Integer') unless id.positive?
100
+ id
101
+ end
102
+ end
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.9' unless defined?(VERSION)
27
+ VERSION = '0.4.0' unless defined?(VERSION)
29
28
  end
@@ -3,23 +3,25 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
+ require_relative 'checks'
6
7
  require_relative 'erc20'
7
8
  require_relative 'wallet'
8
9
 
9
10
  # A fake wallet that behaves like a +ERC20::Wallet+.
10
11
  #
12
+ # It rejects exactly what the real wallet rejects, so that a broken private
13
+ # key, address, or amount fails in a test the same way it would fail with a
14
+ # real provider, where money is at stake.
15
+ #
11
16
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
12
17
  # Copyright:: Copyright (c) 2025 Yegor Bugayenko
13
18
  # License:: MIT
14
19
  class ERC20::FakeWallet
15
- # Transaction hash always returned:
16
- TXN_HASH = '0x172de9cda30537eae68ab4a96163ebbb8f8a85293b8737dd2e5deb4714b14623'
20
+ include ERC20::Checks
17
21
 
18
- # Fakes:
19
- attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path
22
+ TXN_HASH = '0x172de9cda30537eae68ab4a96163ebbb8f8a85293b8737dd2e5deb4714b14623'
20
23
 
21
- # Full history of all method calls:
22
- attr_reader :history
24
+ attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path, :history
23
25
 
24
26
  # Ctor.
25
27
  def initialize
@@ -54,6 +56,7 @@ class ERC20::FakeWallet
54
56
  # @param [String] address Public key, in hex, starting from '0x'
55
57
  # @return [Integer] Balance, in tokens
56
58
  def balance(address)
59
+ to_address(address)
57
60
  b = @balances[address] || 42_000_000
58
61
  @history << { method: :balance, address:, result: b }
59
62
  b
@@ -64,6 +67,7 @@ class ERC20::FakeWallet
64
67
  # @param [String] address Public key, in hex, starting from '0x'
65
68
  # @return [Integer] Balance, in tokens
66
69
  def eth_balance(address)
70
+ to_address(address)
67
71
  b = @eth_balances[address] || 77_000_000_000_000_000
68
72
  @history << { method: :eth_balance, address:, result: b }
69
73
  b
@@ -71,30 +75,43 @@ class ERC20::FakeWallet
71
75
 
72
76
  # Get ERC20 amount (in tokens) that was sent in the given transaction.
73
77
  #
74
- # @param [String] _txn Hex of transaction
75
- # @return [Integer] Balance, in ERC20 tokens
76
- def sum_of(_txn)
77
- 42_000_000
78
+ # @param [String] txn Hex of transaction
79
+ # @param [String] to Public key of the receiver, in hex, starting from '0x'
80
+ # @return [Integer] Amount, in ERC20 tokens
81
+ def sum_of(txn, to: nil)
82
+ to_txn(txn)
83
+ to_address(to) unless to.nil?
84
+ tokens = 42_000_000
85
+ @history << { method: :sum_of, txn:, to:, result: tokens }
86
+ tokens
78
87
  end
79
88
 
80
- # How much gas units is required in order to send ERC20 transaction.
89
+ # How many gas units are required to send an ERC20 transaction.
81
90
  #
82
91
  # @param [String] from The departing address, in hex
83
92
  # @param [String] to Arriving address, in hex
84
93
  # @param [Integer] amount How many ERC20 tokens to send
85
94
  # @return [Integer] How many gas units required
86
95
  def gas_estimate(from, to, amount)
96
+ to_address(from)
97
+ to_address(to)
98
+ to_amount(amount)
87
99
  gas = 66_000
88
100
  @history << { method: :gas_estimate, from:, to:, amount:, result: gas }
89
101
  gas
90
102
  end
91
103
 
92
- # What is the price of gas unit in gwei?
93
- # @return [Integer] Price of gas unit, in gwei (0.000000001 ETH)
104
+ # What is the price of gas unit in wei?
105
+ #
106
+ # The unit is the same as in +ERC20::Wallet#gas_price+: a price in gwei
107
+ # would be a billion times smaller and would make every fee computed in a
108
+ # test look affordable, while in production it wouldn't be.
109
+ #
110
+ # @return [Integer] Price of gas unit, in wei (1 gwei = 0.000000001 ETH)
94
111
  def gas_price
95
- gwei = 55_555
96
- @history << { method: :gas_price, result: gwei }
97
- gwei
112
+ wei = 55_555_000_000
113
+ @history << { method: :gas_price, result: wei }
114
+ wei
98
115
  end
99
116
 
100
117
  # Send a single ERC20 payment from a private address to a public one.
@@ -103,9 +120,14 @@ class ERC20::FakeWallet
103
120
  # @param [String] address Public key, in hex
104
121
  # @param [Integer] amount The amount of ERC20 tokens to send
105
122
  # @param [Integer] limit Optional gas limit
106
- # @param [Integer] price Optional gas price in gwei
123
+ # @param [Integer] price The most you pay per computation unit, in wei
107
124
  # @return [String] Transaction hash
108
- def pay(priv, address, amount, limit: nil, price: nil)
125
+ def pay(priv, address, amount, limit: nil, price: gas_price)
126
+ to_priv(priv)
127
+ to_address(address)
128
+ to_amount(amount)
129
+ to_limit(limit) if limit
130
+ to_price(price)
109
131
  hex = TXN_HASH
110
132
  @history << { method: :pay, priv:, address:, amount:, limit:, price:, result: hex }
111
133
  hex
@@ -116,9 +138,13 @@ class ERC20::FakeWallet
116
138
  # @param [String] priv Private key, in hex
117
139
  # @param [String] address Public key, in hex
118
140
  # @param [Integer] amount The amount of ETHs to send
119
- # @param [Integer] price Optional gas price in gwei
141
+ # @param [Integer] price The most you pay per computation unit, in wei
120
142
  # @return [String] Transaction hash
121
- def eth_pay(priv, address, amount, price: nil)
143
+ def eth_pay(priv, address, amount, price: gas_price)
144
+ to_priv(priv)
145
+ to_address(address)
146
+ to_amount(amount)
147
+ to_price(price)
122
148
  hex = TXN_HASH
123
149
  @history << { method: :eth_pay, priv:, address:, amount:, price:, result: hex }
124
150
  hex
@@ -129,26 +155,34 @@ class ERC20::FakeWallet
129
155
  # @param [Array<String>] addresses Addresses to monitor
130
156
  # @param [Array] active List of addresses that we are actually listening to
131
157
  # @param [Boolean] raw TRUE if you need to get JSON events as they arrive from Websockets
132
- # @param [Integer] delay How many seconds to wait between +eth_subscribe+ calls
133
- def accept(addresses, active = [], raw: false, delay: 1)
134
- @history << { method: :accept, addresses:, active:, raw:, delay: }
158
+ # @param [Numeric] delay How many seconds to wait between +eth_subscribe+ calls
159
+ # @param [Integer] subscription_id Unique ID of the subscription
160
+ def accept(addresses, active = [], raw: false, delay: 1, subscription_id: rand(1..99_999))
161
+ to_addresses(addresses)
162
+ to_active(active)
163
+ to_delay(delay)
164
+ to_subscription(subscription_id)
165
+ @history << { method: :accept, addresses:, active:, raw:, delay:, subscription_id: }
135
166
  addresses.to_a.each { |a| active.append(a) }
136
167
  loop do
137
168
  sleep(delay)
169
+ to_addresses(addresses)
138
170
  a = addresses.to_a.sample
139
171
  next if a.nil?
140
- event =
172
+ yield(
141
173
  if raw
142
174
  {}
143
175
  else
144
176
  {
145
177
  amount: 424_242,
178
+ block: 42,
146
179
  from: '0xd5ff1bfcde7a03da61ad229d962c74f1ea2f16a5',
180
+ index: 0,
147
181
  to: a,
148
182
  txn: TXN_HASH
149
183
  }
150
184
  end
151
- yield event
185
+ )
152
186
  end
153
187
  end
154
188
  end