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/checks.rb
ADDED
|
@@ -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
|
-
|
|
28
|
-
VERSION = '0.2.9' unless defined?(VERSION)
|
|
27
|
+
VERSION = '0.4.0' unless defined?(VERSION)
|
|
29
28
|
end
|
data/lib/erc20/fake_wallet.rb
CHANGED
|
@@ -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
|
-
|
|
16
|
-
TXN_HASH = '0x172de9cda30537eae68ab4a96163ebbb8f8a85293b8737dd2e5deb4714b14623'
|
|
20
|
+
include ERC20::Checks
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
attr_reader :host, :port, :ssl, :chain, :contract, :ws_path, :http_path
|
|
22
|
+
TXN_HASH = '0x172de9cda30537eae68ab4a96163ebbb8f8a85293b8737dd2e5deb4714b14623'
|
|
20
23
|
|
|
21
|
-
|
|
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]
|
|
75
|
-
# @
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
93
|
-
#
|
|
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
|
-
|
|
96
|
-
@history << { method: :gas_price, result:
|
|
97
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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 [
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
+
)
|
|
152
186
|
end
|
|
153
187
|
end
|
|
154
188
|
end
|