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 +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +463 -0
- data/lib/block_given/abi/coder.rb +134 -0
- data/lib/block_given/abi/custom_error.rb +29 -0
- data/lib/block_given/abi/event.rb +87 -0
- data/lib/block_given/abi/function.rb +72 -0
- data/lib/block_given/abi/interface.rb +109 -0
- data/lib/block_given/abi/parameter.rb +57 -0
- data/lib/block_given/chain.rb +112 -0
- data/lib/block_given/client.rb +234 -0
- data/lib/block_given/configuration.rb +48 -0
- data/lib/block_given/connectors/alchemy.rb +36 -0
- data/lib/block_given/connectors/base.rb +26 -0
- data/lib/block_given/connectors/http.rb +150 -0
- data/lib/block_given/connectors/stub.rb +66 -0
- data/lib/block_given/contract.rb +287 -0
- data/lib/block_given/errors.rb +157 -0
- data/lib/block_given/event.rb +36 -0
- data/lib/block_given/normalizer.rb +37 -0
- data/lib/block_given/poller.rb +226 -0
- data/lib/block_given/railtie.rb +17 -0
- data/lib/block_given/receipt.rb +44 -0
- data/lib/block_given/signed_transaction.rb +145 -0
- data/lib/block_given/transaction.rb +85 -0
- data/lib/block_given/utils.rb +134 -0
- data/lib/block_given/version.rb +5 -0
- data/lib/block_given/wallet.rb +137 -0
- data/lib/block_given.rb +68 -0
- metadata +130 -0
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
|
+
[](https://github.com/Bolero-Music/block_given/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/block_given)
|
|
5
|
+
[](LICENSE.txt)
|
|
6
|
+

|
|
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
|