eth-rb 0.1.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 +7 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +15 -0
- data/exe/eth.rb +6 -0
- data/lib/eth.rb +338 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1940c7b2ccb9dd67f344ae0ff7d29bae166726ecbac8bfb10313d461f70304aa
|
|
4
|
+
data.tar.gz: f271a44d507892b99ca64f69f218be1986972f57d9744427f8b5c130aea29a50
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 759cc650c335a35988eb10bccb3aab381f50525d4652fea2758b04c587c4e5d4f5160103ef144f3406a986d84134b1abb82605794539c2938a8b8461f7168000
|
|
7
|
+
data.tar.gz: f492da002def87a00cb13b5551fc2bdc71e073e25e5cd2fefc1e974a4a23d40bcc0af3a77e4d4bb86db0f0d9f7b6b4d483ee87da2dba53bc70481152ef224b02
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
data/exe/eth.rb
ADDED
data/lib/eth.rb
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# eth.rb — Ethereum blockchain queries via Alchemy JSON-RPC
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# ./eth.rb — show latest block number
|
|
7
|
+
# ./eth.rb <address> — show ETH balance of <address>
|
|
8
|
+
# ./eth.rb --price — show current ETH/USD price
|
|
9
|
+
# ./eth.rb send <key> <to> <amount> — send ETH (amount in ETH)
|
|
10
|
+
# ./eth.rb send <key> <to> $<amount> — send ETH (amount in USD, converted at current price)
|
|
11
|
+
# ./eth.rb --help — show this usage info
|
|
12
|
+
|
|
13
|
+
require "bundler/setup"
|
|
14
|
+
require "net/http"
|
|
15
|
+
require "json"
|
|
16
|
+
require "uri"
|
|
17
|
+
require "securerandom"
|
|
18
|
+
require "ecdsa"
|
|
19
|
+
require "digest/keccak"
|
|
20
|
+
|
|
21
|
+
ALCHEMY_URL = "https://eth-mainnet.g.alchemy.com/v2/alch_PhpVkmsabZhYV69otj1rF"
|
|
22
|
+
COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd"
|
|
23
|
+
CHAIN_ID = 1 # Ethereum mainnet
|
|
24
|
+
|
|
25
|
+
# ── JSON-RPC ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def rpc_call(method, params = [])
|
|
28
|
+
uri = URI(ALCHEMY_URL)
|
|
29
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
30
|
+
http.use_ssl = true
|
|
31
|
+
|
|
32
|
+
payload = {
|
|
33
|
+
jsonrpc: "2.0",
|
|
34
|
+
id: 1,
|
|
35
|
+
method: method,
|
|
36
|
+
params: params
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
40
|
+
request.body = JSON.generate(payload)
|
|
41
|
+
request["Content-Type"] = "application/json"
|
|
42
|
+
|
|
43
|
+
response = http.request(request)
|
|
44
|
+
|
|
45
|
+
unless response.code.to_i == 200
|
|
46
|
+
puts "HTTP Error: #{response.code} — #{response.message}"
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result = JSON.parse(response.body)
|
|
51
|
+
|
|
52
|
+
if (error = result["error"])
|
|
53
|
+
puts "JSON-RPC Error (#{error['code']}): #{error['message']}"
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
result["result"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ── RLP Encoding ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def rlp_encode(item)
|
|
63
|
+
case item
|
|
64
|
+
when Integer
|
|
65
|
+
rlp_encode_integer(item)
|
|
66
|
+
when String
|
|
67
|
+
rlp_encode_bytes(item)
|
|
68
|
+
when Array
|
|
69
|
+
rlp_encode_list(item)
|
|
70
|
+
else
|
|
71
|
+
raise "Unsupported RLP type: #{item.class}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def rlp_encode_integer(int)
|
|
76
|
+
if int.zero?
|
|
77
|
+
rlp_encode_bytes("")
|
|
78
|
+
else
|
|
79
|
+
rlp_encode_bytes(to_big_endian(int))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def rlp_encode_bytes(bytes)
|
|
84
|
+
len = bytes.bytesize
|
|
85
|
+
if len == 1 && bytes.ord < 0x80
|
|
86
|
+
bytes
|
|
87
|
+
elsif len <= 55
|
|
88
|
+
(0x80 + len).chr + bytes
|
|
89
|
+
else
|
|
90
|
+
len_bytes = to_big_endian(len)
|
|
91
|
+
(0xb7 + len_bytes.bytesize).chr + len_bytes + bytes
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def rlp_encode_list(items)
|
|
96
|
+
encoded = items.map { |i| rlp_encode(i) }.join
|
|
97
|
+
len = encoded.bytesize
|
|
98
|
+
if len <= 55
|
|
99
|
+
(0xc0 + len).chr + encoded
|
|
100
|
+
else
|
|
101
|
+
len_bytes = to_big_endian(len)
|
|
102
|
+
(0xf7 + len_bytes.bytesize).chr + len_bytes + encoded
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def to_big_endian(int)
|
|
107
|
+
return "" if int.zero?
|
|
108
|
+
hex = int.to_s(16)
|
|
109
|
+
hex = "0#{hex}" if hex.bytesize.odd?
|
|
110
|
+
[hex].pack("H*")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# ── Crypto helpers ──────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def keccak256(data)
|
|
116
|
+
Digest::Keccak.digest(data, 256)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
GROUP = ECDSA::Group::Secp256k1
|
|
120
|
+
|
|
121
|
+
# Derive address (0x-prefixed hex) from a raw 20-byte address string
|
|
122
|
+
def hex_address(raw)
|
|
123
|
+
"0x#{raw.unpack1('H*')}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Derive the 20-byte address from a private key hex string (with or without 0x)
|
|
127
|
+
def private_key_to_address(priv_hex)
|
|
128
|
+
priv_int = normalize_hex(priv_hex).to_i(16)
|
|
129
|
+
pub_point = GROUP.generator.multiply_by_scalar(priv_int)
|
|
130
|
+
|
|
131
|
+
# Uncompressed public key: 04 || x_bytes || y_bytes
|
|
132
|
+
x_bytes = to_big_endian(pub_point.x)
|
|
133
|
+
y_bytes = to_big_endian(pub_point.y)
|
|
134
|
+
|
|
135
|
+
# Pad x and y to 32 bytes each
|
|
136
|
+
pad32 = ->(b) { b.bytesize >= 32 ? b : "\x00" * (32 - b.bytesize) + b }
|
|
137
|
+
|
|
138
|
+
pub_raw = pad32.call(x_bytes) + pad32.call(y_bytes)
|
|
139
|
+
|
|
140
|
+
# Address = last 20 bytes of keccak256(pubkey)
|
|
141
|
+
hash = keccak256(pub_raw)
|
|
142
|
+
hash.byteslice(12, 20)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def normalize_hex(str)
|
|
146
|
+
str.start_with?("0x") || str.start_with?("0X") ? str[2..] : str
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── Transaction signing ─────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def build_and_sign_tx(private_key_hex, to_address, amount_wei)
|
|
152
|
+
priv_int = normalize_hex(private_key_hex).to_i(16)
|
|
153
|
+
sender_pub_point = GROUP.generator.multiply_by_scalar(priv_int)
|
|
154
|
+
|
|
155
|
+
# Derive sender address to match back later for recovery ID
|
|
156
|
+
sender_raw = private_key_to_address(private_key_hex)
|
|
157
|
+
|
|
158
|
+
# Get nonce
|
|
159
|
+
sender_hex = "0x#{sender_raw.unpack1('H*')}"
|
|
160
|
+
nonce_hex = rpc_call("eth_getTransactionCount", [sender_hex, "pending"])
|
|
161
|
+
nonce = nonce_hex.to_i(16)
|
|
162
|
+
|
|
163
|
+
# Get gas price
|
|
164
|
+
gas_price_hex = rpc_call("eth_gasPrice")
|
|
165
|
+
gas_price = gas_price_hex.to_i(16)
|
|
166
|
+
|
|
167
|
+
gas_limit = 21_000 # standard for a simple ETH transfer
|
|
168
|
+
value = amount_wei
|
|
169
|
+
to_bytes = [normalize_hex(to_address)].pack("H*")
|
|
170
|
+
data = ""
|
|
171
|
+
|
|
172
|
+
amount_eth = value / 1_000_000_000_000_000_000.0
|
|
173
|
+
puts "Sender: #{sender_hex}"
|
|
174
|
+
puts "To: #{to_address}"
|
|
175
|
+
puts "Amount: #{format('%.18f', amount_eth)} ETH (#{value} wei)"
|
|
176
|
+
puts "Nonce: #{nonce}"
|
|
177
|
+
puts "Gas: #{gas_limit}"
|
|
178
|
+
puts "GasPrice: #{gas_price} wei"
|
|
179
|
+
puts ""
|
|
180
|
+
|
|
181
|
+
# EIP-155 signing payload: rlp([nonce, gp, gl, to, value, data, chain_id, 0, 0])
|
|
182
|
+
signing_list = [nonce, gas_price, gas_limit, to_bytes, value, data, CHAIN_ID, 0, 0]
|
|
183
|
+
signing_encoded = rlp_encode(signing_list)
|
|
184
|
+
digest = keccak256(signing_encoded)
|
|
185
|
+
|
|
186
|
+
# Generate random k (temporary key)
|
|
187
|
+
temp_key = SecureRandom.random_number(GROUP.order - 1) + 1
|
|
188
|
+
|
|
189
|
+
sig = ECDSA.sign(GROUP, priv_int, digest, temp_key)
|
|
190
|
+
raise "Signing produced s=0, try again" if sig.nil?
|
|
191
|
+
|
|
192
|
+
# Normalize s to low-s (required by Ethereum)
|
|
193
|
+
s = sig.s
|
|
194
|
+
if s > GROUP.order / 2
|
|
195
|
+
s = GROUP.order - s
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
low_sig = ECDSA::Signature.new(sig.r, s)
|
|
199
|
+
|
|
200
|
+
# Determine recovery ID by trying each possible recovered public key
|
|
201
|
+
rec_id = nil
|
|
202
|
+
ECDSA.recover_public_key(GROUP, digest, low_sig).each_with_index do |point, i|
|
|
203
|
+
# Reconstruct raw public key from point
|
|
204
|
+
xb = to_big_endian(point.x)
|
|
205
|
+
yb = to_big_endian(point.y)
|
|
206
|
+
pad32 = ->(b) { b.bytesize >= 32 ? b : "\x00" * (32 - b.bytesize) + b }
|
|
207
|
+
raw_pub = pad32.call(xb) + pad32.call(yb)
|
|
208
|
+
addr = keccak256(raw_pub).byteslice(12, 20)
|
|
209
|
+
if addr == sender_raw
|
|
210
|
+
rec_id = i
|
|
211
|
+
break
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
raise "Could not determine recovery ID" if rec_id.nil?
|
|
216
|
+
|
|
217
|
+
v = 35 + CHAIN_ID * 2 + rec_id
|
|
218
|
+
|
|
219
|
+
# RLP-encode r, s as 32-byte big-endian
|
|
220
|
+
pad32 = ->(b) { b.bytesize >= 32 ? b : "\x00" * (32 - b.bytesize) + b }
|
|
221
|
+
r_bytes = pad32.call(to_big_endian(low_sig.r))
|
|
222
|
+
s_bytes = pad32.call(to_big_endian(s))
|
|
223
|
+
|
|
224
|
+
# Signed transaction: rlp([nonce, gp, gl, to, value, data, v, r, s])
|
|
225
|
+
tx_list = [nonce, gas_price, gas_limit, to_bytes, value, data, v, r_bytes, s_bytes]
|
|
226
|
+
rlp_encode(tx_list)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ── Send command ────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def cmd_send(args)
|
|
232
|
+
if args.length < 3
|
|
233
|
+
puts "Usage: ./eth.rb send <private_key_hex> <to_address> <amount>"
|
|
234
|
+
puts " amount: number (ETH) or $number (USD, converted to ETH at current price)"
|
|
235
|
+
exit 1
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private_key = args[0]
|
|
239
|
+
to_address = args[1]
|
|
240
|
+
raw_amount = args[2]
|
|
241
|
+
|
|
242
|
+
# Parse amount: $X → USD, otherwise treat as ETH
|
|
243
|
+
if raw_amount.start_with?("$")
|
|
244
|
+
usd_amount = raw_amount[1..].to_f
|
|
245
|
+
price = fetch_eth_price
|
|
246
|
+
eth_amount = usd_amount / price
|
|
247
|
+
puts "Converting $#{format('%.2f', usd_amount)} → #{format('%.8f', eth_amount)} ETH (price: $#{format('%.2f', price)})"
|
|
248
|
+
else
|
|
249
|
+
eth_amount = raw_amount.to_f
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
amount_wei = (eth_amount * 1_000_000_000_000_000_000).to_i
|
|
253
|
+
|
|
254
|
+
puts "Building and signing transaction..."
|
|
255
|
+
signed_tx = build_and_sign_tx(private_key, to_address, amount_wei)
|
|
256
|
+
tx_hex = "0x#{signed_tx.unpack1('H*')}"
|
|
257
|
+
|
|
258
|
+
puts "Broadcasting..."
|
|
259
|
+
tx_hash = rpc_call("eth_sendRawTransaction", [tx_hex])
|
|
260
|
+
puts "Transaction sent! TxHash: #{tx_hash}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# ── Display commands ────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
def show_block_number
|
|
266
|
+
hex_block = rpc_call("eth_blockNumber")
|
|
267
|
+
block_num = hex_block.to_i(16)
|
|
268
|
+
puts "Latest Ethereum block: ##{block_num}"
|
|
269
|
+
puts "Hex: #{hex_block}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def show_balance(address)
|
|
273
|
+
hex_wei = rpc_call("eth_getBalance", [address, "latest"])
|
|
274
|
+
wei = hex_wei.to_i(16)
|
|
275
|
+
eth = wei / 1_000_000_000_000_000_000.0 # wei → ETH (18 decimals)
|
|
276
|
+
eth_fmt = format("%.18f", eth)
|
|
277
|
+
|
|
278
|
+
# Fetch ETH/USD price and compute USD value
|
|
279
|
+
price = fetch_eth_price
|
|
280
|
+
usd = eth * price
|
|
281
|
+
usd_fmt = format("%.2f", usd)
|
|
282
|
+
|
|
283
|
+
puts "Address: #{address}"
|
|
284
|
+
puts "Balance: #{eth_fmt} ETH (#{wei} wei)"
|
|
285
|
+
puts "Value: $#{usd_fmt} USD @ $#{format("%.2f", price)}/ETH"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def fetch_eth_price
|
|
289
|
+
uri = URI(COINGECKO_URL)
|
|
290
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
291
|
+
http.use_ssl = true
|
|
292
|
+
|
|
293
|
+
request = Net::HTTP::Get.new(uri)
|
|
294
|
+
request["Accept"] = "application/json"
|
|
295
|
+
|
|
296
|
+
response = http.request(request)
|
|
297
|
+
|
|
298
|
+
unless response.code.to_i == 200
|
|
299
|
+
puts "HTTP Error: #{response.code} — #{response.message}"
|
|
300
|
+
exit 1
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
data = JSON.parse(response.body)
|
|
304
|
+
|
|
305
|
+
unless (price = data.dig("ethereum", "usd"))
|
|
306
|
+
puts "Could not fetch ETH price"
|
|
307
|
+
exit 1
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
price
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def show_price
|
|
314
|
+
price = fetch_eth_price
|
|
315
|
+
puts "ETH/USD: $#{format("%.2f", price)}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def print_usage
|
|
319
|
+
puts File.read(__FILE__)[/^#\sUsage:.*?(?=\n\n)/m]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# ── CLI dispatch ────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
def run_cli(args)
|
|
325
|
+
if args.empty?
|
|
326
|
+
show_block_number
|
|
327
|
+
elsif args[0] == "--price"
|
|
328
|
+
show_price
|
|
329
|
+
elsif args[0] == "--help" || args[0] == "-h"
|
|
330
|
+
print_usage
|
|
331
|
+
elsif args[0] == "send"
|
|
332
|
+
cmd_send(args[1..])
|
|
333
|
+
else
|
|
334
|
+
show_balance(args[0])
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
run_cli(ARGV) if __FILE__ == $0
|
metadata
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: eth-rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Khalid Shaikh
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: ecdsa
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: keccak
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.3'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.3'
|
|
41
|
+
description: A lightweight CLI tool for querying Ethereum balances, block numbers,
|
|
42
|
+
ETH/USD prices, and sending transactions via Alchemy JSON-RPC.
|
|
43
|
+
email:
|
|
44
|
+
- k@iai.lol
|
|
45
|
+
executables:
|
|
46
|
+
- eth.rb
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- Gemfile
|
|
51
|
+
- Gemfile.lock
|
|
52
|
+
- exe/eth.rb
|
|
53
|
+
- lib/eth.rb
|
|
54
|
+
homepage: https://github.com/kshaikh/eth.rb
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata: {}
|
|
58
|
+
post_install_message:
|
|
59
|
+
rdoc_options: []
|
|
60
|
+
require_paths:
|
|
61
|
+
- lib
|
|
62
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: 2.5.0
|
|
67
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '0'
|
|
72
|
+
requirements: []
|
|
73
|
+
rubygems_version: 3.0.3.1
|
|
74
|
+
signing_key:
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: Ethereum blockchain queries via Alchemy JSON-RPC
|
|
77
|
+
test_files: []
|