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.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +6 -0
  3. data/Gemfile.lock +15 -0
  4. data/exe/eth.rb +6 -0
  5. data/lib/eth.rb +338 -0
  6. 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
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "ecdsa", "~> 1.2"
6
+ gem "keccak", "~> 1.3"
data/Gemfile.lock ADDED
@@ -0,0 +1,15 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ ecdsa (1.2.0)
5
+ keccak (1.3.1)
6
+
7
+ PLATFORMS
8
+ ruby
9
+
10
+ DEPENDENCIES
11
+ ecdsa (~> 1.2)
12
+ keccak (~> 1.3)
13
+
14
+ BUNDLED WITH
15
+ 1.17.2
data/exe/eth.rb ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/eth"
5
+
6
+ run_cli(ARGV)
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: []