block_given 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c4b499f5165a70b3cc18adbaf4a252debefa3fe690fec649e6e4e763852e888f
4
+ data.tar.gz: 522255498e152e9c5b3a410550e6774cf6710419d340cddeb79a4b384895be7f
5
+ SHA512:
6
+ metadata.gz: 357a9aaa58ac26362a81ca80aa60e6dabe07840996ed76542389d8b0c5ae0d386f69b37c028932a58c2967952faba99813555a057adb600b3f15cadb57cbb792
7
+ data.tar.gz: 25da492c348c47987e1675026e63793d7908a99f2a1fbc8f66358bdf026053ddb37ac1ab96b1134ea6e2818e8cc5f1492d5346c4cd0c081156ed859a612a72de
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to
5
+ [Semantic Versioning](https://semver.org).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-09-11
10
+
11
+ Initial release.
12
+
13
+ - `BlockGiven::Contract`: typed contract classes from an ABI file (`abi_file`, `BlockGiven.config.abi_path`), snake_case
14
+ methods for every function, positional or keyword arguments, `tx:` overrides, `read` / `write` / `simulate` /
15
+ `estimate_gas`, overload resolution by arity, keyword names or full signature.
16
+ - `BlockGiven::Wallet`: EIP-1559 and legacy signing, EIP-191 / EIP-712, automatic nonce, gas and fee resolution.
17
+ - `BlockGiven::Client`: JSON-RPC client (blocks, balances, calls, receipts, logs), EIP-1559 fee estimation,
18
+ `wait_for_transaction_receipt`, `get_logs_in_chunks`.
19
+ - `BlockGiven::SignedTransaction` (`Contract#prepare_write`, `Wallet#signed_transaction`): sign without
20
+ broadcasting, hash and nonce known before any network call, `broadcast`, `replacement(fee_multiplier:)` for
21
+ same-nonce fee bumps, `.from_raw` to rebuild one from persisted bytes. `Transaction#hash` is computed locally
22
+ from the signed bytes (a differing node answer is logged). `Transaction#status` (`:success` / `:reverted` /
23
+ `:pending` / `:unknown`), `#confirmations`, `#confirmed?`, `#reload` for non-blocking outbox workers.
24
+ - Connectors: Alchemy (endpoint derived from the chain), generic HTTP with retries and backoff, in-memory Stub
25
+ for tests. Secrets are masked in `inspect`, logs and error messages.
26
+ - Events: `get_events` with indexed filters, `watch_event` polling with chunked catch-up (`from_block`,
27
+ `max_block_range`), reorg margin (`confirmations`), `on_progress` cursor callback.
28
+ - Watcher registry: unique ids, `BlockGiven.watchers`, `BlockGiven::Watcher.find / stop / kill / stop_all`, named threads,
29
+ diagnostics (`to_h`).
30
+ - Revert decoding: `Error(string)`, `Panic(uint256)` and custom errors from the contract ABI.
31
+ - Chains: Ethereum, Sepolia, Base, Base Sepolia, Polygon, Amoy, Arbitrum, Optimism (+ Sepolias), Localhost.
32
+ - Optional Rails railtie (Rails 7.0 to 8.0) routing logs to `Rails.logger`.
33
+ - Supported Ruby 3.1 to 3.4.
34
+
35
+ [Unreleased]: https://github.com/Bolero-Music/block_given/compare/v0.1.0...HEAD
36
+ [0.1.0]: https://github.com/Bolero-Music/block_given/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bolero Music
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,463 @@
1
+ # BlockGiven
2
+
3
+ [![CI](https://github.com/Bolero-Music/block_given/actions/workflows/ci.yml/badge.svg)](https://github.com/Bolero-Music/block_given/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/block_given.svg)](https://rubygems.org/gems/block_given)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+ ![Ruby 3.1+](https://img.shields.io/badge/ruby-%3E%3D%203.1-cc342d)
7
+
8
+ **Ruby client for EVM smart contracts, inspired by [viem](https://viem.sh).**
9
+ Declare a contract class from its ABI and every function becomes a Ruby method. Wallets sign EIP-1559
10
+ transactions, connectors (Alchemy first) speak JSON-RPC, and polling helpers wait for receipts, blocks and
11
+ events with resumable cursors.
12
+
13
+ ```ruby
14
+ BlockGiven.configure do |c|
15
+ c.connector = BlockGiven::Connectors::Alchemy.new(api_key: ENV["ALCHEMY_API_KEY"])
16
+ c.chain = :base
17
+ end
18
+
19
+ class Usdc < BlockGiven::Contract
20
+ abi_file "abis/erc20.json" # ABIs live in your repo, not in the gem
21
+ address "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
22
+ end
23
+
24
+ wallet = BlockGiven::Wallet.new(private_key: ENV["PRIVATE_KEY"])
25
+ usdc = Usdc.new(wallet: wallet)
26
+
27
+ usdc.balance_of(wallet.address) # => 12_500_000 (eth_call, decoded)
28
+ tx = usdc.transfer(to: "0x7099...79C8", amount: 1e6) # signs + broadcasts, returns BlockGiven::Transaction
29
+ receipt = tx.wait! # polls until mined, raises if reverted
30
+ usdc.events_from(receipt) # => [#<BlockGiven::Event Transfer {from:, to:, value: 1000000}>]
31
+ ```
32
+
33
+ ## Table of contents
34
+
35
+ - [Installation](#installation)
36
+ - [Configuration](#configuration)
37
+ - [Connectors](#connectors)
38
+ - [Chains](#chains)
39
+ - [Contracts](#contracts)
40
+ - [Calling functions](#calling-functions)
41
+ - [Transactions & receipts](#transactions--receipts)
42
+ - [Reliable writes: sign first, broadcast later](#reliable-writes-sign-first-broadcast-later)
43
+ - [Reverts](#reverts)
44
+ - [Events](#events)
45
+ - [Polling](#polling)
46
+ - [Listing, stopping and killing watchers](#listing-stopping-and-killing-watchers)
47
+ - [How watchers behave](#how-watchers-behave)
48
+ - [Wallet](#wallet)
49
+ - [Client (low level)](#client-low-level)
50
+ - [Utils](#utils)
51
+ - [Testing your code](#testing-your-code)
52
+ - [Compatibility](#compatibility)
53
+ - [Rails integration](#rails-integration)
54
+ - [Development](#development)
55
+ - [Versioning & releases](#versioning--releases)
56
+ - [Security](#security)
57
+ - [Contributing](#contributing)
58
+ - [Roadmap](#roadmap)
59
+ - [License](#license)
60
+
61
+ ## Installation
62
+
63
+ ```ruby
64
+ # Gemfile
65
+ gem "block_given"
66
+ ```
67
+
68
+ BlockGiven depends on the [`eth`](https://github.com/q9f/eth.rb) gem for secp256k1, keccak and ABI primitives.
69
+ Its native extension needs libsecp256k1; on macOS `brew install secp256k1` then
70
+ `gem install rbsecp256k1 -- --with-system-library` if the bundled build fails.
71
+
72
+ Requires Ruby >= 3.1.
73
+
74
+ ## Configuration
75
+
76
+ ```ruby
77
+ BlockGiven.configure do |c|
78
+ c.connector = BlockGiven::Connectors::Alchemy.new(api_key: ENV["ALCHEMY_API_KEY"])
79
+ c.chain = :base # BlockGiven::Chains::BASE, "base-sepolia", 8453 ... all work
80
+ c.polling_interval = 2.0 # seconds between polls (receipts, blocks, events)
81
+ c.timeout = 180 # seconds before Transaction#wait gives up
82
+ c.confirmations = 1 # blocks to wait for in Transaction#wait
83
+ c.gas_multiplier = 1.2 # margin applied to eth_estimateGas
84
+ c.base_fee_multiplier = 1.2 # maxFeePerGas = baseFee * 1.2 + priorityFee (viem default)
85
+ c.abi_path = "abis" # optional: directory Contract.abi_file resolves relative paths against
86
+ c.logger = Logger.new($stdout, level: Logger::DEBUG) # logs every JSON-RPC call at DEBUG
87
+ end
88
+
89
+ BlockGiven.client # default BlockGiven::Client built from the config
90
+ ```
91
+
92
+ ### Connectors
93
+
94
+ | Connector | Usage |
95
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------- |
96
+ | `BlockGiven::Connectors::Alchemy.new(api_key:)` | Endpoint derived from the chain (`base-mainnet.g.alchemy.com`, ...). One instance serves every network. |
97
+ | `BlockGiven::Connectors::Http.new(url:)` | Any JSON-RPC endpoint (Hardhat, Anvil, Infura...). Without `url:` it uses the chain's public RPC. |
98
+ | `BlockGiven::Connectors::Stub.new(...)` | In-memory responses for tests (see below). |
99
+
100
+ All HTTP connectors retry on 429/5xx/timeouts with exponential backoff (`retries:`, `retry_delay:`),
101
+ support `batch`, and never print API keys in `inspect`.
102
+
103
+ ### Chains
104
+
105
+ Built in: `MAINNET`, `SEPOLIA`, `BASE`, `BASE_SEPOLIA`, `POLYGON`, `POLYGON_AMOY`, `ARBITRUM`,
106
+ `ARBITRUM_SEPOLIA`, `OPTIMISM`, `OPTIMISM_SEPOLIA`, `LOCALHOST` (31337). Custom:
107
+
108
+ ```ruby
109
+ fork = BlockGiven::Chain.new(id: 31_337, name: "Base fork", rpc_urls: ["http://127.0.0.1:8545"])
110
+ client = BlockGiven::Client.new(chain: fork, connector: BlockGiven::Connectors::Http.new)
111
+ ```
112
+
113
+ ## Contracts
114
+
115
+ The gem ships no ABI: keep them in your repository (`abis/*.json`, or the Hardhat/Foundry artifacts) and
116
+ point each contract class at its file. With `BlockGiven.config.abi_path = Rails.root.join("abis")` relative
117
+ names resolve from that directory.
118
+
119
+ ```ruby
120
+ class CatalogShares < BlockGiven::Contract
121
+ abi_file "CatalogShares.json" # ABI array, Hardhat/Foundry artifact ({ "abi": [...] }), or JSON string via `abi`
122
+ address "0x..." # optional default address
123
+ chain :base # optional: pins the chain regardless of the global config
124
+ end
125
+
126
+ shares = CatalogShares.new(wallet: wallet) # default address
127
+ shares = CatalogShares.at("0x...", wallet: wallet) # explicit address
128
+ shares = CatalogShares.at("0x...") # read-only (no wallet)
129
+ ```
130
+
131
+ ### Calling functions
132
+
133
+ Every ABI function is available in snake_case. `view`/`pure` functions run `eth_call` and return decoded
134
+ values; the others sign and broadcast a transaction and return a `BlockGiven::Transaction`.
135
+
136
+ ```ruby
137
+ shares.balance_of("0x...") # positional
138
+ shares.balance_of(account: "0x...") # keyword (ABI input names, leading _ stripped, snake_cased)
139
+ shares.transfer(to: "0x...", amount: 1e6) # floats are accepted when they are whole numbers
140
+ shares.transfer(wallet2, 1_000_000) # anything responding to #address works as an address
141
+ ```
142
+
143
+ Argument coercion: integers accept `Integer`, whole `Float`/`BigDecimal`, decimal or hex strings; tuples accept
144
+ `Hash` (component names) or `Array`; `bytes` accept hex or binary strings. Decoded outputs give checksummed
145
+ addresses, `0x` hex for bytes, and named tuples as `Hash`. Multiple outputs come back as an `Array`.
146
+
147
+ Transaction and call overrides live in the reserved `tx:` keyword so they never clash with ABI input names:
148
+
149
+ ```ruby
150
+ vault.deposit(amount, tx: { value: BlockGiven::Utils.parse_ether("0.1"), gas: 200_000, nonce: 12 })
151
+ shares.balance_of(addr, tx: { block: 20_000_000 }) # historical read
152
+ shares.owner_of(1, tx: { from: "0x..." }) # msg.sender for eth_call
153
+ # allowed keys: value gas nonce max_fee_per_gas max_priority_fee_per_gas gas_price from block
154
+ ```
155
+
156
+ Explicit API (handles names clashing with Ruby methods, overloads by signature, etc.):
157
+
158
+ ```ruby
159
+ shares.read(:balance_of, addr)
160
+ shares.write("safeMint(address,bytes)", addr, "0x")
161
+ shares.simulate(:buy, 42, tx: { value: price }) # eth_call from the wallet: raises the decoded revert without paying gas
162
+ shares.estimate_gas(:buy, 42, tx: { value: price })
163
+ shares.encode_function_data(:transfer, to: addr, amount: 1)
164
+ shares.decode_function_result(:balance_of, "0x...")
165
+ ```
166
+
167
+ ### Transactions & receipts
168
+
169
+ ```ruby
170
+ tx = shares.transfer(to: addr, amount: 1)
171
+ tx.hash # "0x..."
172
+ tx.explorer_url # https://basescan.org/tx/0x...
173
+ tx.mined? # non-blocking
174
+ receipt = tx.wait(confirmations: 2, timeout: 300, polling_interval: 1)
175
+ receipt.status # :success / :reverted
176
+ receipt.gas_used, receipt.fee, receipt.block_number, receipt.logs
177
+ tx.wait! # raises BlockGiven::TransactionRevertedError when status is :reverted
178
+ tx.status # :success / :reverted (mined), :pending (in the mempool), :unknown (never seen or dropped)
179
+ tx.confirmations # blocks since inclusion (non-blocking), tx.confirmed?(5)
180
+ client.transaction("0x...") # the same handle for a hash you persisted earlier
181
+ ```
182
+
183
+ ### Reliable writes: sign first, broadcast later
184
+
185
+ A transaction hash is the keccak of the signed bytes, so it is known **before** anything is sent.
186
+ `prepare_write` (or `Wallet#signed_transaction`) signs without broadcasting; persist the hash and
187
+ nonce, then broadcast. If the RPC call times out you still know exactly which transaction to look for,
188
+ and a same-nonce replacement can never be mined twice.
189
+
190
+ ```ruby
191
+ signed = registry.prepare_write(:record, movement_id, tx: { nonce: call.nonce })
192
+ signed.hash, signed.nonce, signed.raw # known now; signed.to_h for persistence
193
+ call.update!(tx_hash: signed.hash, status: :submitted)
194
+ signed.broadcast # eth_sendRawTransaction, returns the Transaction
195
+
196
+ # later, one tick of your outbox worker (possibly another process: rebuild from the persisted bytes)
197
+ signed = BlockGiven::SignedTransaction.from_raw(call.raw_tx, wallet: wallet, interface: registry.interface)
198
+ tx = signed.transaction # same as client.transaction(call.tx_hash)
199
+ case tx.status
200
+ when :success then call.confirmed! if tx.confirmed?(5) # registry.events_from(tx.receipt) has the logs
201
+ when :reverted then call.failed!
202
+ when :unknown then signed.broadcast # dropped by the node: resend the same bytes
203
+ when :pending then signed.replacement.broadcast if call.submitted_at < 5.minutes.ago
204
+ end
205
+ ```
206
+
207
+ `replacement(fee_multiplier: 1.125)` re-signs the same payload and nonce with fees raised by the
208
+ multiplier (at least 10%, otherwise nodes reject it as underpriced) and never below a fresh estimate. If
209
+ the original gets mined first, the replacement is rejected for its nonce and `tx.status` tells you so.
210
+
211
+ ### Reverts
212
+
213
+ `eth_call`, `eth_estimateGas` and sends that revert raise `BlockGiven::ContractRevertError`.
214
+ `Error(string)` and `Panic(uint256)` reasons are decoded; custom errors are decoded with the contract ABI:
215
+
216
+ ```ruby
217
+ begin
218
+ usdc.transfer(to: addr, amount: 10**12)
219
+ rescue BlockGiven::ContractRevertError => e
220
+ e.message # => 'ERC20InsufficientBalance("0xf39F...", 5, 1000000000000)'
221
+ e.error_name # => "ERC20InsufficientBalance"
222
+ e.args # => { sender: "0xf39F...", balance: 5, needed: 1000000000000 }
223
+ e.reason # => "insufficient balance" for Error(string) reverts
224
+ end
225
+ ```
226
+
227
+ ## Events
228
+
229
+ ```ruby
230
+ # past events, filtered on indexed args (single value or Array for OR)
231
+ usdc.get_events(:Transfer, from_block: 20_000_000, to_block: :latest, args: { to: wallet.address })
232
+ usdc.get_events(from_block: 20_000_000) # every event the ABI knows
233
+
234
+ # polling in a background thread
235
+ watcher = usdc.watch_event(:Transfer, args: { to: wallet.address }) do |event|
236
+ puts "#{event[:from]} sent #{event[:value]} in tx #{event.transaction_hash}"
237
+ end
238
+ watcher.on_error { |error, _| warn error.message } # default: logged, polling continues
239
+ watcher.stop # alias: unwatch
240
+
241
+ # decode logs yourself
242
+ usdc.decode_logs(receipt.logs)
243
+ usdc.events_from(receipt) # only this contract's logs
244
+ ```
245
+
246
+ `BlockGiven::Event` exposes `name`, `args` (snake_case symbols, declaration order), `[]`, `address`, `block_number`,
247
+ `transaction_hash`, `log_index`. Indexed `string`/`bytes`/arrays only carry their keccak hash, as on-chain.
248
+
249
+ ## Polling
250
+
251
+ ```ruby
252
+ client = BlockGiven.client
253
+
254
+ client.watch_block_number(emit_missed: true) { |n| ... } # BlockGiven::Watcher
255
+ client.watch_blocks { |block| ... }
256
+ client.watch_logs(address: addr, topics: [...]) { |logs| ... }
257
+ client.wait_for_transaction_receipt(hash, confirmations: 3)
258
+ client.get_logs_in_chunks(address: addr, from_block: 1, to_block: :latest) # splits by max_block_range
259
+
260
+ # generic blocking poll: returns the first truthy value or raises BlockGiven::TimeoutError
261
+ BlockGiven::Poller.poll(interval: 1, timeout: 60) { client.get_transaction_receipt(hash) }
262
+ ```
263
+
264
+ ### Listing, stopping and killing watchers
265
+
266
+ Every running watcher is registered under a unique id (auto-generated, or the `id:` you pass), and its
267
+ thread is named `block_given:<id>` so it shows up in `Thread.list` and in thread dumps.
268
+
269
+ ```ruby
270
+ usdc.watch_event(:Transfer, id: "usdc-deposits") { |e| ... }
271
+ BlockGiven.client.watch_block_number { |n| ... } # id auto-generated: "block_number-3fa9c1"
272
+
273
+ BlockGiven.watchers # => running watchers, oldest first (alias BlockGiven::Watcher.all)
274
+ BlockGiven.watchers.map(&:to_h) # id, name, status, cursor, ticks, started_at, last_tick_at, last_error...
275
+ BlockGiven::Watcher.find("usdc-deposits") # => the watcher, nil if not running
276
+ BlockGiven::Watcher.stop("usdc-deposits", join: 5) # graceful: finishes the current tick
277
+ BlockGiven::Watcher.kill("usdc-deposits") # forceful: Thread#kill, for a tick stuck in a network call
278
+ BlockGiven::Watcher.stop_all(join: 5) # e.g. in an at_exit / SIGTERM handler
279
+ ```
280
+
281
+ Starting a second watcher with an id that is already running raises, which protects against double
282
+ starts after a code reload. `BlockGiven.reset!` stops every watcher.
283
+
284
+ ### How watchers behave
285
+
286
+ - One Ruby thread per watcher, sleeping on a condition variable between ticks (`stop` wakes it
287
+ immediately). Each tick fetches the new block range, yields, and keeps only the last processed block
288
+ number: nothing accumulates inside the gem. Errors are logged (or handed to `on_error`) and the range is
289
+ retried on the next tick.
290
+ - Large gaps are processed in chunks of `max_block_range` blocks (default 2000, provider limits apply), so
291
+ resuming after hours of downtime works.
292
+ - `confirmations:` keeps the watcher N blocks behind the head, so logs from shallow reorgs are never delivered.
293
+ - Watchers live in the process that started them. With Puma in cluster mode or Sidekiq, start them in a
294
+ single dedicated process (a `bin/indexer`, a Rake task, a one-replica container), not in every web worker.
295
+ - Nothing is persisted by the gem. Own the cursor in your app:
296
+
297
+ ```ruby
298
+ # app/indexers/usdc_deposit_indexer.rb — started once at boot by the dedicated process
299
+ class UsdcDepositIndexer
300
+ def start
301
+ cursor = IndexerCursor.find_or_create_by!(name: "usdc_deposits") { |c| c.block = BlockGiven.client.block_number }
302
+ usdc = Usdc.new
303
+
304
+ @watcher = usdc.watch_event(
305
+ :Transfer, args: { to: TREASURY },
306
+ from_block: cursor.block + 1, # resume where the previous process stopped
307
+ confirmations: 2, # reorg margin
308
+ on_progress: ->(_from, to) { cursor.update!(block: to) } # runs after the range's events were handled
309
+ ) { |event| Deposit.upsert_from_event(event) } # idempotent on (transaction_hash, log_index)
310
+
311
+ @watcher.on_error { |e, _| Sentry.capture_exception(e) }
312
+ at_exit { @watcher.stop.join(5) }
313
+ end
314
+ end
315
+ ```
316
+
317
+ `on_progress` is called after the block you passed processed every event of the range, so a crash in
318
+ between simply replays that range on restart. `watcher.cursor` exposes the same value in memory.
319
+
320
+ ## Wallet
321
+
322
+ ```ruby
323
+ wallet = BlockGiven::Wallet.new(private_key: ENV["PRIVATE_KEY"]) # with or without 0x
324
+ BlockGiven::Wallet.generate
325
+ wallet.address, wallet.balance, wallet.nonce
326
+ wallet.sign_message("hello") # EIP-191
327
+ wallet.sign_typed_data(typed_data) # EIP-712
328
+ wallet.send_transaction(to: addr, value: BlockGiven::Utils.parse_ether("0.01")).wait
329
+ wallet.send_transaction(to: addr, data: "0x...", gas_price: BlockGiven::Utils.parse_gwei("2")) # legacy type-0 tx
330
+ wallet.prepare_transaction(to: addr, data: "0x...") # resolved nonce/gas/fees without signing
331
+ wallet.signed_transaction(to: addr, data: "0x...") # signed, not broadcast: #hash, #nonce, #broadcast, #replacement
332
+ ```
333
+
334
+ Missing fields are filled from the client: pending nonce, `eth_estimateGas * gas_multiplier`,
335
+ EIP-1559 fees from the latest block's base fee and `eth_maxPriorityFeePerGas`.
336
+
337
+ ## Client (low level)
338
+
339
+ `BlockGiven::Client` mirrors viem's public client: `chain_id`, `block_number`, `get_block`, `get_balance`,
340
+ `get_transaction_count`, `get_code`, `get_storage_at`, `call`, `estimate_gas`, `gas_price`,
341
+ `estimate_fees_per_gas`, `send_raw_transaction`, `get_transaction`, `get_transaction_receipt`, `get_logs`,
342
+ plus `request(method, *params)` and `batch([[method, params], ...])` for anything else. Results use
343
+ snake_case symbol keys with integer quantities.
344
+
345
+ ## Utils
346
+
347
+ ```ruby
348
+ BlockGiven::Utils.parse_units("1.5", 6) # => 1_500_000
349
+ BlockGiven::Utils.format_units(1_500_000, 6) # => "1.5"
350
+ BlockGiven::Utils.parse_ether("0.1"), BlockGiven::Utils.format_ether(wei), parse_gwei, format_gwei
351
+ BlockGiven::Utils.keccak256("transfer(address,uint256)") # => "0xa9059cbb..."
352
+ BlockGiven::Utils.checksum_address(addr), BlockGiven::Utils.address?(str), BlockGiven::Utils.to_hex(255), hex_to_int("0xff")
353
+ ```
354
+
355
+ Token helpers are one method away in your own contract class:
356
+
357
+ ```ruby
358
+ class Erc20 < BlockGiven::Contract
359
+ abi_file "erc20.json"
360
+ def decimals = @decimals ||= read(:decimals)
361
+ def parse_amount(value) = BlockGiven::Utils.parse_units(value, decimals) # "1.5" -> 1_500_000
362
+ def format_amount(value) = BlockGiven::Utils.format_units(value, decimals) # 1_500_000 -> "1.5"
363
+ end
364
+ ```
365
+
366
+ ## Testing your code
367
+
368
+ `BlockGiven::Connectors::Stub` answers JSON-RPC calls from memory:
369
+
370
+ ```ruby
371
+ stub = BlockGiven::Connectors::Stub.new(
372
+ "eth_call" => "0x" + "1".rjust(64, "0"),
373
+ "eth_blockNumber" => BlockGiven::Connectors::Stub.sequence("0x10", "0x11"), # consumed in order
374
+ "eth_getTransactionReceipt" => ->(params) { receipts[params.first] }
375
+ )
376
+ BlockGiven.configure { |c| c.connector = stub; c.chain = :base; c.polling_interval = 0 }
377
+ stub.calls # => [["eth_call", [...]], ...]
378
+ stub.calls_for("eth_sendRawTransaction")
379
+ ```
380
+
381
+ ## Compatibility
382
+
383
+ | | Supported | Verified by |
384
+ | ------ | ------------------------------------------ | ------------------------------------------------------------- |
385
+ | Ruby | >= 3.1 (3.1, 3.2, 3.3, 3.4) | CI matrix + local run on each version |
386
+ | Rails | optional, 7.0 / 7.1 / 7.2 / 8.0 | full suite run with Rails loaded (`gemfiles/rails_*.gemfile`) |
387
+ | `eth` | ~> 0.5, >= 0.5.17 (tuple ABI support) | pinned in the gemspec |
388
+ | stdlib | `bigdecimal`, `logger` declared explicitly | bundled gems in Ruby 3.4 / 3.5 |
389
+
390
+ BlockGiven has no runtime dependency on Rails or ActiveSupport: it is plain Ruby and works in scripts,
391
+ Sidekiq workers, Rails apps or Hanami alike.
392
+
393
+ ### Rails integration
394
+
395
+ ```ruby
396
+ # config/initializers/block_given.rb
397
+ BlockGiven.configure do |c|
398
+ c.connector = BlockGiven::Connectors::Alchemy.new(api_key: Rails.application.credentials.alchemy_api_key)
399
+ c.chain = Rails.env.production? ? :base : :base_sepolia
400
+ c.abi_path = Rails.root.join("abis")
401
+ end
402
+ ```
403
+
404
+ A railtie (loaded automatically when Rails is present) routes BlockGiven's logs to `Rails.logger`
405
+ unless the initializer sets `c.logger` itself. Contract classes live wherever you want
406
+ (`app/contracts/usdc.rb` works with Zeitwerk out of the box) and ABI files in `abis/`.
407
+
408
+ ## Development
409
+
410
+ ```bash
411
+ bin/setup # bundle install (+ libsecp256k1 fallback)
412
+ bundle exec rspec # unit suite (Stub connector, no network)
413
+ COVERAGE=1 bundle exec rspec # + SimpleCov report in coverage/ (minimum 90% lines)
414
+ bundle exec rubocop
415
+ ALCHEMY_API_KEY=... bin/console # IRB with BlockGiven configured for BLOCK_GIVEN_CHAIN (default base)
416
+
417
+ bundle exec rake ci # specs + rubocop + gem build
418
+
419
+ # Ruby / Rails matrix (Docker for the Rubies you do not have locally)
420
+ bin/matrix # Ruby 3.2, 3.3, 3.4
421
+ bin/matrix rails 7.2 # Rails 7.2 compat suite, local Ruby
422
+ bin/matrix rails 8.0 3.4 # Rails 8.0 under Ruby 3.4
423
+ ```
424
+
425
+ CI runs the suite on Ruby 3.1 to 3.4 and against Rails 7.0, 7.1, 7.2 and 8.0 (`.github/workflows/ci.yml`).
426
+
427
+ ### Versioning & releases
428
+
429
+ BlockGiven follows [Semantic Versioning](https://semver.org): breaking changes to the public API
430
+ (`BlockGiven::Contract`, `Wallet`, `Client`, connectors, `Utils`) bump the major version, additions the minor,
431
+ fixes the patch. Every change is listed in `CHANGELOG.md`. Dependency policy: Ruby versions are dropped
432
+ only once they reach end of life, Rails versions are tested while they receive security fixes, and the `eth`
433
+ constraint is only tightened when a feature needs it.
434
+
435
+ To release: bump `lib/block_given/version.rb`, move the `Unreleased` notes under the new version in `CHANGELOG.md`,
436
+ commit, then push a `vX.Y.Z` tag. The release workflow checks the tag against the version, runs the suite and
437
+ publishes through RubyGems trusted publishing (no API key in CI). `bundle exec rake release` does the same
438
+ from a maintainer machine with RubyGems credentials.
439
+
440
+ ## Security
441
+
442
+ - Private keys never leave `BlockGiven::Wallet`; `inspect` hides them and API keys are masked in every log and
443
+ error message (`Http#redact`).
444
+ - Never commit keys: use `ENV`, Rails credentials or Hardhat vars, and keep `.env` out of git (see `.env.example`).
445
+ - Report a vulnerability privately to remi@boleromusic.com rather than in a public issue. See [SECURITY.md](SECURITY.md).
446
+
447
+ ## Contributing
448
+
449
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/Bolero-Music/block_given). Please read
450
+ [CONTRIBUTING.md](CONTRIBUTING.md) (setup, test matrix, conventions) and the
451
+ [code of conduct](CODE_OF_CONDUCT.md).
452
+
453
+ ## Roadmap
454
+
455
+ - Contract deployment (`Contract.deploy`)
456
+ - Multi-contract indexer helper with pluggable cursor store
457
+ - Human-readable ABI (`parse_abi("function transfer(address to, uint256 amount)")`)
458
+ - WebSocket connector for push-based subscriptions
459
+ - Multicall batching of reads
460
+
461
+ ## License
462
+
463
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Abi
5
+ # Turns Ruby values into what the ABI encoder expects, and decoded values
6
+ # into idiomatic Ruby (checksummed addresses, hex bytes, named tuples as Hash).
7
+ module Coder
8
+ module_function
9
+
10
+ def encode(params, values)
11
+ if params.size != values.size
12
+ raise InvalidArgumentError,
13
+ "expected #{params.size} argument(s), got #{values.size}"
14
+ end
15
+
16
+ coerced = params.zip(values).map { |param, value| coerce(value, param) }
17
+ Utils.bin_to_hex(Eth::Abi.encode(params.map(&:type), coerced))
18
+ rescue Eth::Abi::EncodingError, Eth::Abi::ValueOutOfBounds => e
19
+ raise AbiError, "ABI encoding failed: #{e.message}"
20
+ end
21
+
22
+ def decode(params, hex)
23
+ return [] if params.empty?
24
+
25
+ data = Utils.strip_hex(hex.to_s)
26
+ raise AbiError, "cannot decode empty data (does the contract exist at this address?)" if data.empty?
27
+
28
+ values = Eth::Abi.decode(params.map(&:type), "0x#{data}")
29
+ params.zip(values).map { |param, value| format(value, param) }
30
+ rescue Eth::Abi::DecodingError => e
31
+ raise AbiError, "ABI decoding failed: #{e.message}"
32
+ end
33
+
34
+ # Ruby -> encoder input.
35
+ def coerce(value, param)
36
+ if param.array?
37
+ raise InvalidArgumentError, "#{param.name} expects an Array, got #{value.inspect}" unless value.is_a?(Array)
38
+
39
+ return value.map { |v| coerce(v, param.element) }
40
+ end
41
+ return coerce_tuple(value, param) if param.tuple?
42
+
43
+ case param.raw_type
44
+ when /\A(u?int)\d*\z/ then coerce_integer(value, param)
45
+ when "address" then coerce_address(value, param)
46
+ when "bool" then coerce_bool(value, param)
47
+ when "string" then value.to_s
48
+ when /\Abytes\d*\z/ then coerce_bytes(value, param)
49
+ else value
50
+ end
51
+ end
52
+
53
+ # Decoded value -> Ruby.
54
+ def format(value, param)
55
+ return value.map { |v| format(v, param.element) } if param.array?
56
+ return format_tuple(value, param) if param.tuple?
57
+
58
+ case param.raw_type
59
+ when "address" then Utils.checksum_address(value)
60
+ when /\Abytes\d*\z/ then value.is_a?(String) && !Utils.hex?(value) ? Utils.bin_to_hex(value) : value
61
+ when "string" then value.to_s.dup.force_encoding(Encoding::UTF_8)
62
+ else value
63
+ end
64
+ end
65
+
66
+ def coerce_integer(value, param)
67
+ case value
68
+ when Integer then value
69
+ when Float, BigDecimal, Rational
70
+ unless value.finite? && value == value.floor
71
+ raise InvalidArgumentError, "#{param.name}: #{value} is not an integer (use BlockGiven::Utils.parse_units)"
72
+ end
73
+
74
+ value.to_i
75
+ when String
76
+ Utils.hex?(value) ? Utils.hex_to_int(value) : Integer(value, 10)
77
+ else raise InvalidArgumentError, "#{param.name} expects an integer, got #{value.inspect}"
78
+ end
79
+ rescue ::ArgumentError
80
+ raise InvalidArgumentError, "#{param.name} expects an integer, got #{value.inspect}"
81
+ end
82
+
83
+ def coerce_address(value, param)
84
+ value = value.address if value.respond_to?(:address) && !value.is_a?(String)
85
+ raise InvalidAddressError, "#{param.name}: invalid address #{value.inspect}" unless Utils.address?(value)
86
+
87
+ value
88
+ end
89
+
90
+ def coerce_bool(value, param)
91
+ return value if [true, false].include?(value)
92
+
93
+ raise InvalidArgumentError, "#{param.name} expects true/false, got #{value.inspect}"
94
+ end
95
+
96
+ def coerce_bytes(value, param)
97
+ raise InvalidArgumentError, "#{param.name} expects a hex or binary String" unless value.is_a?(String)
98
+
99
+ Utils.hex?(value) ? value : Utils.bin_to_hex(value)
100
+ end
101
+
102
+ def coerce_tuple(value, param)
103
+ values =
104
+ case value
105
+ when Hash then tuple_values_from_hash(value, param)
106
+ when Array then value
107
+ else raise InvalidArgumentError, "#{param.name} expects a Hash or Array for tuple #{param.type}"
108
+ end
109
+ if values.size != param.components.size
110
+ raise InvalidArgumentError, "#{param.name}: tuple #{param.type} expects #{param.components.size} values"
111
+ end
112
+
113
+ param.components.zip(values).map { |component, v| coerce(v, component) }
114
+ end
115
+
116
+ def tuple_values_from_hash(hash, param)
117
+ lookup = hash.transform_keys { |k| Utils.snake_case(k) }
118
+ param.components.map do |component|
119
+ key = Utils.snake_case(component.name)
120
+ raise InvalidArgumentError, "#{param.name}: missing tuple field #{component.name}" unless lookup.key?(key)
121
+
122
+ lookup[key]
123
+ end
124
+ end
125
+
126
+ def format_tuple(values, param)
127
+ formatted = param.components.zip(values).map { |component, v| format(v, component) }
128
+ return formatted if param.components.any?(&:unnamed?)
129
+
130
+ param.components.map(&:ruby_name).zip(formatted).to_h
131
+ end
132
+ end
133
+ end
134
+ end