block_given 0.1.0 → 0.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/README.md +92 -55
  4. data/lib/block_given/abi/codec/decoder.rb +111 -0
  5. data/lib/block_given/abi/codec.rb +170 -0
  6. data/lib/block_given/abi/coder.rb +122 -11
  7. data/lib/block_given/abi/custom_error.rb +33 -1
  8. data/lib/block_given/abi/event.rb +85 -3
  9. data/lib/block_given/abi/function.rb +94 -2
  10. data/lib/block_given/abi/interface.rb +99 -4
  11. data/lib/block_given/abi/parameter.rb +60 -3
  12. data/lib/block_given/abi/standards/erc1155.rb +41 -0
  13. data/lib/block_given/abi/standards/erc20.rb +33 -0
  14. data/lib/block_given/abi/standards/erc4626.rb +41 -0
  15. data/lib/block_given/abi/standards/erc721.rb +47 -0
  16. data/lib/block_given/abi/standards.rb +124 -0
  17. data/lib/block_given/abi/type.rb +138 -0
  18. data/lib/block_given/chain.rb +114 -2
  19. data/lib/block_given/client.rb +322 -20
  20. data/lib/block_given/configuration.rb +85 -3
  21. data/lib/block_given/connectors/alchemy.rb +38 -4
  22. data/lib/block_given/connectors/base.rb +32 -5
  23. data/lib/block_given/connectors/http.rb +91 -5
  24. data/lib/block_given/connectors/stub.rb +70 -4
  25. data/lib/block_given/contract.rb +422 -26
  26. data/lib/block_given/crypto/keccak.rb +152 -0
  27. data/lib/block_given/crypto/secp256k1.rb +168 -0
  28. data/lib/block_given/crypto.rb +22 -0
  29. data/lib/block_given/eip712.rb +199 -0
  30. data/lib/block_given/errors.rb +134 -14
  31. data/lib/block_given/event.rb +51 -1
  32. data/lib/block_given/normalizer.rb +27 -2
  33. data/lib/block_given/poller.rb +177 -15
  34. data/lib/block_given/receipt.rb +63 -3
  35. data/lib/block_given/rlp.rb +146 -0
  36. data/lib/block_given/signed_transaction.rb +148 -47
  37. data/lib/block_given/transaction.rb +103 -12
  38. data/lib/block_given/transaction_envelope/fields.rb +104 -0
  39. data/lib/block_given/transaction_envelope.rb +183 -0
  40. data/lib/block_given/utils.rb +143 -9
  41. data/lib/block_given/version.rb +2 -1
  42. data/lib/block_given/wallet.rb +216 -35
  43. data/lib/block_given.rb +67 -4
  44. metadata +19 -23
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4b499f5165a70b3cc18adbaf4a252debefa3fe690fec649e6e4e763852e888f
4
- data.tar.gz: 522255498e152e9c5b3a410550e6774cf6710419d340cddeb79a4b384895be7f
3
+ metadata.gz: d9b06bd63a9b8cf930fffa7ddb4d38f0df49cba13bdf89f8cdec7dfc58d2afe4
4
+ data.tar.gz: 2089baad3a9d053cea769f9d40aac2f1344f74070522b2ed992048826e0ead42
5
5
  SHA512:
6
- metadata.gz: 357a9aaa58ac26362a81ca80aa60e6dabe07840996ed76542389d8b0c5ae0d386f69b37c028932a58c2967952faba99813555a057adb600b3f15cadb57cbb792
7
- data.tar.gz: 25da492c348c47987e1675026e63793d7908a99f2a1fbc8f66358bdf026053ddb37ac1ab96b1134ea6e2818e8cc5f1492d5346c4cd0c081156ed859a612a72de
6
+ metadata.gz: 49357c63673f56b9e417a135625cc13f682751ac6311510f741804c442f7cd93c2af91ace30c1f9b9a1d2a7800b1e2e48795f6cab59824845807ae2796992716
7
+ data.tar.gz: 7935466e7eb98c515d2f795d7a8ccf4e10a07844f208d17456b97e35e82de80483d0a24b3134209fe26d54067a385f5b8a32afcc01979ec737745da63ab11d1c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,38 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-10-09
10
+
11
+ ### Added
12
+
13
+ - `BlockGiven::Abi::Standards`: the ERC20, ERC721, ERC1155 and ERC4626 interfaces ship as frozen ABI arrays (EIP
14
+ functions and events, metadata / ERC-165 / enumerable extensions, ERC-6093 custom errors), with the input names of
15
+ OpenZeppelin 5 (`transfer(to:, value:)`). `Contract.abi` accepts their Symbol name (`abi :erc20`),
16
+ `Abi::Standards.fetch("ERC-721")` returns the array, `Abi::Standards.names` lists them.
17
+
18
+ ### Changed
19
+
20
+ - **The `eth` gem is no longer a dependency**, and with it the unmaintained `rbsecp256k1` native extension, which
21
+ failed to compile on Debian bullseye (system libsecp256k1 too old, broken bundled download). Keccak-256,
22
+ secp256k1 (RFC 6979 deterministic signatures, low-s, recovery), RLP, EIP-1559 / legacy EIP-155 transactions,
23
+ EIP-191, EIP-712 and the ABI codec are now implemented in the gem on top of Ruby's OpenSSL standard library. The
24
+ gem installs with no native extension and no system package. Keys, signatures, raw transactions, hashes and ABI
25
+ encodings are byte-identical to what `eth` 0.5.17 produced (golden vectors in `spec/fixtures/`).
26
+ - EIP-712: arrays are now hashed as the specification defines them (as viem and on-chain verifiers do); `eth`
27
+ encoded them as inline ABI arrays and rejected arrays of structs. `sign_typed_data` also accepts String keys and
28
+ a payload without an `EIP712Domain` type (derived from the domain, like viem).
29
+ - Errors that used to leak from `eth` are typed: invalid transaction fields (negative values, gas limit below the
30
+ intrinsic gas) and out-of-range private keys raise `InvalidArgumentError`; ABI failures raise the new
31
+ `AbiEncodingError` / `AbiDecodingError` (both `AbiError`, same messages); unsupported ABI types (`fixed`,
32
+ `function`) raise `AbiError`.
33
+ - `SignedTransaction.from_raw` returns a non-empty access list as `[{ address:, storage_keys: }]` (it returned raw
34
+ RLP bytes), and transactions accept access lists in that form as documented.
35
+ - ABI: a `string` argument that looks like hex (`"0x12"`) is encoded as text (it was encoded as bytes), and decoded
36
+ `bytes` values are always `0x` hex.
37
+ - YARD documentation for the whole public API (every method, option, block and return value), published on
38
+ [rubydoc.info](https://rubydoc.info/gems/block_given); `rake doc` builds it locally and `rake doc_check` (part of
39
+ `rake ci` and GitHub CI) fails under 100% coverage.
40
+
9
41
  ## [0.1.0] - 2026-09-11
10
42
 
11
43
  Initial release.
@@ -32,5 +64,6 @@ Initial release.
32
64
  - Optional Rails railtie (Rails 7.0 to 8.0) routing logs to `Rails.logger`.
33
65
  - Supported Ruby 3.1 to 3.4.
34
66
 
35
- [Unreleased]: https://github.com/Bolero-Music/block_given/compare/v0.1.0...HEAD
67
+ [Unreleased]: https://github.com/Bolero-Music/block_given/compare/v0.2.0...HEAD
68
+ [0.2.0]: https://github.com/Bolero-Music/block_given/compare/v0.1.0...v0.2.0
36
69
  [0.1.0]: https://github.com/Bolero-Music/block_given/releases/tag/v0.1.0
data/README.md CHANGED
@@ -3,6 +3,7 @@
3
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
4
  [![Gem Version](https://badge.fury.io/rb/block_given.svg)](https://rubygems.org/gems/block_given)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+ [![API docs](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/block_given)
6
7
  ![Ruby 3.1+](https://img.shields.io/badge/ruby-%3E%3D%203.1-cc342d)
7
8
 
8
9
  **Ruby client for EVM smart contracts, inspired by [viem](https://viem.sh).**
@@ -17,7 +18,7 @@ BlockGiven.configure do |c|
17
18
  end
18
19
 
19
20
  class Usdc < BlockGiven::Contract
20
- abi_file "abis/erc20.json" # ABIs live in your repo, not in the gem
21
+ abi :erc20 # shipped standard; your own contracts use abi_file "abis/catalog.json"
21
22
  address "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
22
23
  end
23
24
 
@@ -25,7 +26,7 @@ wallet = BlockGiven::Wallet.new(private_key: ENV["PRIVATE_KEY"])
25
26
  usdc = Usdc.new(wallet: wallet)
26
27
 
27
28
  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
+ tx = usdc.transfer(to: "0x7099...79C8", value: 1e6) # signs + broadcasts, returns BlockGiven::Transaction
29
30
  receipt = tx.wait! # polls until mined, raises if reverted
30
31
  usdc.events_from(receipt) # => [#<BlockGiven::Event Transfer {from:, to:, value: 1000000}>]
31
32
  ```
@@ -37,6 +38,7 @@ usdc.events_from(receipt) # => [#<BlockGiven::Event Transf
37
38
  - [Connectors](#connectors)
38
39
  - [Chains](#chains)
39
40
  - [Contracts](#contracts)
41
+ - [Standard ABIs](#standard-abis)
40
42
  - [Calling functions](#calling-functions)
41
43
  - [Transactions & receipts](#transactions--receipts)
42
44
  - [Reliable writes: sign first, broadcast later](#reliable-writes-sign-first-broadcast-later)
@@ -51,7 +53,7 @@ usdc.events_from(receipt) # => [#<BlockGiven::Event Transf
51
53
  - [Testing your code](#testing-your-code)
52
54
  - [Compatibility](#compatibility)
53
55
  - [Rails integration](#rails-integration)
54
- - [Development](#development)
56
+ - [Development](#development) (the full API reference lives on [rubydoc.info](https://rubydoc.info/gems/block_given))
55
57
  - [Versioning & releases](#versioning--releases)
56
58
  - [Security](#security)
57
59
  - [Contributing](#contributing)
@@ -65,9 +67,9 @@ usdc.events_from(receipt) # => [#<BlockGiven::Event Transf
65
67
  gem "block_given"
66
68
  ```
67
69
 
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.
70
+ BlockGiven is pure Ruby: keccak-256, secp256k1 signing (RFC 6979), RLP, transactions, EIP-712 and the ABI codec
71
+ are implemented in the gem on top of Ruby's OpenSSL standard library, so it installs with no native extension and
72
+ no system package (the OpenSSL Ruby links against must support the secp256k1 curve, as stock builds do).
71
73
 
72
74
  Requires Ruby >= 3.1.
73
75
 
@@ -112,62 +114,91 @@ client = BlockGiven::Client.new(chain: fork, connector: BlockGiven::Connectors::
112
114
 
113
115
  ## Contracts
114
116
 
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.
117
+ The ABI comes either from a standard the gem ships (`abi :erc20`, see below) or from your repository
118
+ (`abis/*.json`, or the Hardhat/Foundry artifacts) through `abi_file`. With
119
+ `BlockGiven.config.abi_path = Rails.root.join("abis")` relative names resolve from that directory.
118
120
 
119
121
  ```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
122
+ class Usdc < BlockGiven::Contract
123
+ abi :erc20 # or abi_file "catalog.json": ABI array, Hardhat/Foundry artifact, JSON string
124
+ address "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # optional default address (USDC on Base)
125
+ chain :base # optional: pins the chain regardless of the global config
126
+ end
127
+
128
+ usdc = Usdc.new(wallet: wallet) # default address
129
+ usdc = Usdc.at("0x036CbD53842c5426634e7929541eC2318f3dCF7e", wallet: wallet) # explicit address (USDC on Base Sepolia)
130
+ usdc = Usdc.at("0x036CbD53842c5426634e7929541eC2318f3dCF7e") # read-only (no wallet)
131
+ ```
132
+
133
+ ### Standard ABIs
134
+
135
+ `BlockGiven::Abi::Standards` ships the token standards as frozen ABI arrays, so the usual `erc20.json` download is not
136
+ needed and the keyword names below are guaranteed: `ERC20`, `ERC721`, `ERC1155` and `ERC4626`. Each one
137
+ carries the EIP functions and events, the usual extensions (metadata, ERC-165 `supportsInterface`, ERC-721
138
+ enumerable) and the [ERC-6093](https://eips.ethereum.org/EIPS/eip-6093) custom errors, so reverts from
139
+ OpenZeppelin-based tokens decode by name. Input names follow OpenZeppelin 5 (`transfer(to, value)`).
140
+
141
+ ```ruby
142
+ class Usdc < BlockGiven::Contract
143
+ abi :erc20 # Symbol name of a shipped ABI
124
144
  end
125
145
 
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)
146
+ class SongShares < BlockGiven::Contract
147
+ abi BlockGiven::Abi::Standards::ERC1155 # the constant works too
148
+ end
149
+
150
+ BlockGiven::Abi::Standards.fetch("ERC-721") # => the ABI Array; extend it: abi BlockGiven::Abi::Standards::ERC721 + extra_definitions
151
+ BlockGiven::Abi::Standards.names # => [:erc20, :erc721, :erc1155, :erc4626]
129
152
  ```
130
153
 
154
+ ERC-721 has two `safeTransferFrom` overloads: positional calls pick one by arity, otherwise use the full
155
+ signature (`nft.write("safeTransferFrom(address,address,uint256,bytes)", ...)`). Your own contracts keep their
156
+ ABIs in the application (`abi_file`): they change with every deployment and the gem must not pin them.
157
+
131
158
  ### Calling functions
132
159
 
133
160
  Every ABI function is available in snake_case. `view`/`pure` functions run `eth_call` and return decoded
134
161
  values; the others sign and broadcast a transaction and return a `BlockGiven::Transaction`.
135
162
 
136
163
  ```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
164
+ usdc.balance_of("0x...") # positional
165
+ usdc.balance_of(account: "0x...") # keyword (ABI input names, leading _ stripped, snake_cased)
166
+ usdc.transfer(to: "0x...", value: 1e6) # 1 USDC; floats are accepted when they are whole numbers
167
+ usdc.transfer(wallet2, 1_000_000) # anything responding to #address works as an address
168
+ usdc.approve(spender, 2**256 - 1) # uint256 takes any Integer
169
+ usdc.allowance(wallet.address, spender)
141
170
  ```
142
171
 
143
172
  Argument coercion: integers accept `Integer`, whole `Float`/`BigDecimal`, decimal or hex strings; tuples accept
144
173
  `Hash` (component names) or `Array`; `bytes` accept hex or binary strings. Decoded outputs give checksummed
145
174
  addresses, `0x` hex for bytes, and named tuples as `Hash`. Multiple outputs come back as an `Array`.
146
175
 
147
- Transaction and call overrides live in the reserved `tx:` keyword so they never clash with ABI input names:
176
+ Transaction and call overrides live in the reserved `tx:` keyword so they never clash with ABI input names
177
+ (ERC20 itself has an input called `value`):
148
178
 
149
179
  ```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
180
+ usdc.transfer(to: addr, value: 1e6, tx: { gas: 80_000, nonce: 12 })
181
+ usdc.balance_of(addr, tx: { block: 20_000_000 }) # historical read
182
+ weth.deposit(tx: { value: BlockGiven::Utils.parse_ether("0.1") }) # payable: weth = Weth.at("0x4200000000000000000000000000000000000006")
183
+ usdc.simulate(:transfer, addr, 1e6, tx: { from: treasury }) # eth_call with another msg.sender
153
184
  # allowed keys: value gas nonce max_fee_per_gas max_priority_fee_per_gas gas_price from block
154
185
  ```
155
186
 
156
187
  Explicit API (handles names clashing with Ruby methods, overloads by signature, etc.):
157
188
 
158
189
  ```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...")
190
+ usdc.read(:balance_of, addr)
191
+ usdc.write("transfer(address,uint256)", addr, 1_000_000) # full signature picks an overload
192
+ usdc.simulate(:transfer, addr, 10**12) # eth_call from the wallet: raises the decoded revert without paying gas
193
+ usdc.estimate_gas(:transfer, addr, 1_000_000)
194
+ usdc.encode_function_data(:transfer, to: addr, value: 1)
195
+ usdc.decode_function_result(:balance_of, "0x...")
165
196
  ```
166
197
 
167
198
  ### Transactions & receipts
168
199
 
169
200
  ```ruby
170
- tx = shares.transfer(to: addr, amount: 1)
201
+ tx = usdc.transfer(to: addr, value: 1e6)
171
202
  tx.hash # "0x..."
172
203
  tx.explorer_url # https://basescan.org/tx/0x...
173
204
  tx.mined? # non-blocking
@@ -185,22 +216,22 @@ client.transaction("0x...") # the same handle for a hash you persisted earlier
185
216
  A transaction hash is the keccak of the signed bytes, so it is known **before** anything is sent.
186
217
  `prepare_write` (or `Wallet#signed_transaction`) signs without broadcasting; persist the hash and
187
218
  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.
219
+ and a same-nonce replacement can never be mined twice. Example: paying out USDC from an outbox table.
189
220
 
190
221
  ```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
222
+ signed = usdc.prepare_write(:transfer, to: payout.wallet, value: 12_500_000, tx: { nonce: payout.nonce })
223
+ signed.hash, signed.nonce, signed.raw # known now; signed.to_h for persistence
224
+ payout.update!(tx_hash: signed.hash, raw_tx: signed.raw, status: :submitted)
225
+ signed.broadcast # eth_sendRawTransaction, returns the Transaction
195
226
 
196
227
  # 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)
228
+ signed = BlockGiven::SignedTransaction.from_raw(payout.raw_tx, wallet: wallet, interface: usdc.interface)
229
+ tx = signed.transaction # same as client.transaction(payout.tx_hash)
199
230
  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
231
+ when :success then payout.confirmed! if tx.confirmed?(5) # usdc.events_from(tx.receipt) => [Transfer ...]
232
+ when :reverted then payout.failed!
233
+ when :unknown then signed.broadcast # dropped by the node: resend the same bytes
234
+ when :pending then signed.replacement.broadcast if payout.submitted_at < 5.minutes.ago
204
235
  end
205
236
  ```
206
237
 
@@ -215,7 +246,7 @@ the original gets mined first, the replacement is rejected for its nonce and `tx
215
246
 
216
247
  ```ruby
217
248
  begin
218
- usdc.transfer(to: addr, amount: 10**12)
249
+ usdc.transfer(to: addr, value: 10**12)
219
250
  rescue BlockGiven::ContractRevertError => e
220
251
  e.message # => 'ERC20InsufficientBalance("0xf39F...", 5, 1000000000000)'
221
252
  e.error_name # => "ERC20InsufficientBalance"
@@ -356,7 +387,7 @@ Token helpers are one method away in your own contract class:
356
387
 
357
388
  ```ruby
358
389
  class Erc20 < BlockGiven::Contract
359
- abi_file "erc20.json"
390
+ abi :erc20
360
391
  def decimals = @decimals ||= read(:decimals)
361
392
  def parse_amount(value) = BlockGiven::Utils.parse_units(value, decimals) # "1.5" -> 1_500_000
362
393
  def format_amount(value) = BlockGiven::Utils.format_units(value, decimals) # 1_500_000 -> "1.5"
@@ -384,7 +415,6 @@ stub.calls_for("eth_sendRawTransaction")
384
415
  | ------ | ------------------------------------------ | ------------------------------------------------------------- |
385
416
  | Ruby | >= 3.1 (3.1, 3.2, 3.3, 3.4) | CI matrix + local run on each version |
386
417
  | 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
418
  | stdlib | `bigdecimal`, `logger` declared explicitly | bundled gems in Ruby 3.4 / 3.5 |
389
419
 
390
420
  BlockGiven has no runtime dependency on Rails or ActiveSupport: it is plain Ruby and works in scripts,
@@ -408,13 +438,15 @@ unless the initializer sets `c.logger` itself. Contract classes live wherever yo
408
438
  ## Development
409
439
 
410
440
  ```bash
411
- bin/setup # bundle install (+ libsecp256k1 fallback)
441
+ bin/setup # bundle install
412
442
  bundle exec rspec # unit suite (Stub connector, no network)
413
443
  COVERAGE=1 bundle exec rspec # + SimpleCov report in coverage/ (minimum 90% lines)
414
444
  bundle exec rubocop
445
+ bundle exec rake doc # YARD API docs in doc/ (also published at rubydoc.info/gems/block_given)
446
+ bundle exec rake doc_check # fails unless 100% of the public API is documented
415
447
  ALCHEMY_API_KEY=... bin/console # IRB with BlockGiven configured for BLOCK_GIVEN_CHAIN (default base)
416
448
 
417
- bundle exec rake ci # specs + rubocop + gem build
449
+ bundle exec rake ci # specs + rubocop + doc coverage + gem build
418
450
 
419
451
  # Ruby / Rails matrix (Docker for the Rubies you do not have locally)
420
452
  bin/matrix # Ruby 3.2, 3.3, 3.4
@@ -429,11 +461,12 @@ CI runs the suite on Ruby 3.1 to 3.4 and against Rails 7.0, 7.1, 7.2 and 8.0 (`.
429
461
  BlockGiven follows [Semantic Versioning](https://semver.org): breaking changes to the public API
430
462
  (`BlockGiven::Contract`, `Wallet`, `Client`, connectors, `Utils`) bump the major version, additions the minor,
431
463
  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.
464
+ only once they reach end of life, Rails versions are tested while they receive security fixes, and runtime
465
+ dependencies stay limited to Ruby's default and bundled gems.
434
466
 
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
467
+ To release: on a branch off `develop`, bump `lib/block_given/version.rb`, move the `Unreleased` notes under the
468
+ new version in `CHANGELOG.md`, merge it into `develop`, then merge the release pull request `develop` -> `main`
469
+ and push a `vX.Y.Z` tag. The release workflow checks the tag against the version, runs the suite and
437
470
  publishes through RubyGems trusted publishing (no API key in CI). `bundle exec rake release` does the same
438
471
  from a maintainer machine with RubyGems credentials.
439
472
 
@@ -446,16 +479,20 @@ from a maintainer machine with RubyGems credentials.
446
479
 
447
480
  ## Contributing
448
481
 
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).
482
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/Bolero-Music/block_given). Branch from
483
+ `develop` and target `develop`: `main` is release-only, and both branches require a pull request with green CI and
484
+ an approving review. Please read [CONTRIBUTING.md](CONTRIBUTING.md) (branches, setup, test matrix, conventions) and
485
+ the [code of conduct](CODE_OF_CONDUCT.md).
452
486
 
453
487
  ## Roadmap
454
488
 
455
489
  - Contract deployment (`Contract.deploy`)
456
490
  - Multi-contract indexer helper with pluggable cursor store
457
491
  - Human-readable ABI (`parse_abi("function transfer(address to, uint256 amount)")`)
458
- - WebSocket connector for push-based subscriptions
492
+ - WebSocket connector: `watch_*` helpers subscribe to filtered `logs` (`eth_subscribe`, like viem's `webSocket`
493
+ transport) so nothing is fetched per block; on every (re)connection a single range-based `eth_getLogs` catch-up
494
+ runs from the persisted cursor, overlapping logs are deduplicated on `(block_hash, log_index)`, `removed` logs
495
+ handle reorgs, and polling is the fallback when the socket stays down
459
496
  - Multicall batching of reads
460
497
 
461
498
  ## License
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Abi
5
+ module Codec
6
+ # The decoding half of {Codec}: reads heads, follows offsets, and checks every read against the data size so
7
+ # that malformed data raises instead of returning garbage.
8
+ #
9
+ # @api private
10
+ module Decoder
11
+ module_function
12
+
13
+ # Decodes a sequence whose head starts at `start`; offsets of dynamic values are relative to `start`.
14
+ #
15
+ # @param types [Array<Type>]
16
+ # @param data [String] the whole encoding, binary
17
+ # @param start [Integer]
18
+ # @return [Array]
19
+ # @raise [BlockGiven::AbiDecodingError] when data is missing
20
+ def decode_sequence(types, data, start)
21
+ position = start
22
+ types.map do |type|
23
+ value =
24
+ if type.dynamic?
25
+ decode_value(type, data, start + read_offset(data, position))
26
+ else
27
+ decode_value(type, data, position)
28
+ end
29
+ position += type.head_size
30
+ value
31
+ end
32
+ end
33
+
34
+ # Decodes one value encoded at a position.
35
+ #
36
+ # @param type [Type]
37
+ # @param data [String] binary
38
+ # @param position [Integer]
39
+ # @return [Object]
40
+ # @raise [BlockGiven::AbiDecodingError] when data is missing
41
+ def decode_value(type, data, position)
42
+ return decode_array(type, data, position) if type.array?
43
+
44
+ case type.base
45
+ when "tuple" then decode_sequence(type.components, data, position)
46
+ when "uint" then read_uint(data, position)
47
+ when "int"
48
+ value = read_uint(data, position)
49
+ value >= 2**255 ? value - (2**256) : value
50
+ when "address" then Utils.bin_to_hex(read(data, position + 12, 20))
51
+ when "bool" then read_uint(data, position) == 1
52
+ when "string" then decode_bytes(data, position).force_encoding(Encoding::UTF_8)
53
+ else type.size ? read(data, position, type.size) : decode_bytes(data, position)
54
+ end
55
+ end
56
+
57
+ # @param type [Type] an array type
58
+ # @param data [String] binary
59
+ # @param position [Integer]
60
+ # @return [Array]
61
+ # @raise [BlockGiven::AbiDecodingError] when the announced length exceeds the data
62
+ def decode_array(type, data, position)
63
+ return decode_sequence(Array.new(type.length, type.element), data, position) if type.length
64
+
65
+ count = read_uint(data, position)
66
+ if count * [type.element.head_size, 1].max > data.bytesize - position - 32
67
+ raise AbiDecodingError, "array length #{count} exceeds the data"
68
+ end
69
+
70
+ decode_sequence(Array.new(count, type.element), data, position + 32)
71
+ end
72
+
73
+ # @param data [String] binary
74
+ # @param position [Integer] position of the length word
75
+ # @return [String] the bytes after the length word, binary
76
+ # @raise [BlockGiven::AbiDecodingError] when the data is shorter than the announced length
77
+ def decode_bytes(data, position) = read(data, position + 32, read_uint(data, position))
78
+
79
+ # @param data [String] binary
80
+ # @param position [Integer]
81
+ # @return [Integer] an offset, bounded by the data size
82
+ # @raise [BlockGiven::AbiDecodingError] when the offset points outside of the data
83
+ def read_offset(data, position)
84
+ offset = read_uint(data, position)
85
+ raise AbiDecodingError, "offset #{offset} points outside of the data" if offset > data.bytesize
86
+
87
+ offset
88
+ end
89
+
90
+ # @param data [String] binary
91
+ # @param position [Integer]
92
+ # @return [Integer] the unsigned 32-byte word at the position
93
+ # @raise [BlockGiven::AbiDecodingError] when fewer than 32 bytes remain
94
+ def read_uint(data, position) = read(data, position, 32).unpack1("H*").to_i(16)
95
+
96
+ # @param data [String] binary
97
+ # @param position [Integer]
98
+ # @param length [Integer]
99
+ # @return [String] exactly `length` bytes, binary
100
+ # @raise [BlockGiven::AbiDecodingError] when the data is too short
101
+ def read(data, position, length)
102
+ if position.negative? || length.negative? || position + length > data.bytesize
103
+ raise AbiDecodingError, "not enough data: needed #{position + length} bytes, got #{data.bytesize}"
104
+ end
105
+
106
+ data.byteslice(position, length)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlockGiven
4
+ module Abi
5
+ # The Solidity contract ABI encoding: heads and tails, offsets, two's complement integers, padded bytes.
6
+ #
7
+ # Works on already coerced values (see {Coder} for the Ruby-facing layer): Integers for `intN` / `uintN`,
8
+ # `0x` hex Strings for `address`, `0x` hex or binary Strings for `bytes` / `bytesN`, Strings for `string`,
9
+ # `true` / `false`, and Arrays for arrays and tuples. Decoding returns the same shapes, with lowercase
10
+ # addresses and binary Strings for bytes.
11
+ #
12
+ # @api private
13
+ module Codec
14
+ module_function
15
+
16
+ # Encodes values for a list of types, as function arguments are.
17
+ #
18
+ # @param types [Array<String, Type>] canonical types
19
+ # @param values [Array] one value per type
20
+ # @return [String] the encoding, binary
21
+ # @raise [BlockGiven::AbiEncodingError] when a value does not fit its type
22
+ # @raise [BlockGiven::AbiError] when a type is not supported
23
+ def encode(types, values)
24
+ types = types.map { |type| type.is_a?(Type) ? type : Type.parse(type) }
25
+ raise AbiEncodingError, "expected #{types.size} value(s), got #{values.size}" if types.size != values.size
26
+
27
+ encode_sequence(types, values)
28
+ end
29
+
30
+ # Decodes data produced for a list of types.
31
+ #
32
+ # @param types [Array<String, Type>] canonical types
33
+ # @param data [String] the encoding, binary
34
+ # @return [Array] one value per type
35
+ # @raise [BlockGiven::AbiDecodingError] when the data is too short or an offset points outside of it
36
+ # @raise [BlockGiven::AbiError] when a type is not supported
37
+ def decode(types, data)
38
+ types = types.map { |type| type.is_a?(Type) ? type : Type.parse(type) }
39
+ Decoder.decode_sequence(types, data.b, 0)
40
+ end
41
+
42
+ # Encodes a sequence (arguments, tuple components or array elements): static values in place, dynamic
43
+ # ones behind an offset relative to the start of the sequence.
44
+ #
45
+ # @param types [Array<Type>]
46
+ # @param values [Array]
47
+ # @return [String] binary
48
+ def encode_sequence(types, values)
49
+ offset = types.sum(&:head_size)
50
+ heads = +"".b
51
+ tails = +"".b
52
+ types.zip(values).each do |type, value|
53
+ encoded = encode_value(type, value)
54
+ if type.dynamic?
55
+ heads << uint_word(offset + tails.bytesize)
56
+ tails << encoded
57
+ else
58
+ heads << encoded
59
+ end
60
+ end
61
+ heads + tails
62
+ end
63
+
64
+ # Encodes one value.
65
+ #
66
+ # @param type [Type]
67
+ # @param value [Object]
68
+ # @return [String] binary
69
+ # @raise [BlockGiven::AbiEncodingError] when the value does not fit the type
70
+ def encode_value(type, value)
71
+ return encode_array(type, value) if type.array?
72
+
73
+ case type.base
74
+ when "tuple"
75
+ unless value.is_a?(Array) && value.size == type.components.size
76
+ raise AbiEncodingError, "#{type} expects an Array of #{type.components.size} values, got #{value.inspect}"
77
+ end
78
+
79
+ encode_sequence(type.components, value)
80
+ when "uint", "int" then encode_integer(type, value)
81
+ when "address" then encode_address(value)
82
+ when "bool"
83
+ raise AbiEncodingError, "bool expects true/false, got #{value.inspect}" unless [true, false].include?(value)
84
+
85
+ uint_word(value ? 1 : 0)
86
+ when "string" then encode_bytes(string_bytes(value))
87
+ else type.size ? encode_fixed_bytes(type, value) : encode_bytes(binary(type, value))
88
+ end
89
+ end
90
+
91
+ # @param type [Type] an array type
92
+ # @param value [Array]
93
+ # @return [String] binary: the length (dynamic arrays only) then the elements as a sequence
94
+ # @raise [BlockGiven::AbiEncodingError] when the value is not an Array of the declared length
95
+ def encode_array(type, value)
96
+ raise AbiEncodingError, "#{type} expects an Array, got #{value.inspect}" unless value.is_a?(Array)
97
+ if type.length && value.size != type.length
98
+ raise AbiEncodingError, "#{type} expects #{type.length} elements, got #{value.size}"
99
+ end
100
+
101
+ elements = encode_sequence(Array.new(value.size, type.element), value)
102
+ type.length ? elements : uint_word(value.size) + elements
103
+ end
104
+
105
+ # @param type [Type] `intN` or `uintN`
106
+ # @param value [Integer]
107
+ # @return [String] the two's complement 32-byte word, binary
108
+ # @raise [BlockGiven::AbiEncodingError] when the value is not an Integer or is out of bounds
109
+ def encode_integer(type, value)
110
+ raise AbiEncodingError, "#{type} expects an Integer, got #{value.inspect}" unless value.is_a?(Integer)
111
+
112
+ range = type.base == "int" ? (-(2**(type.size - 1))...(2**(type.size - 1))) : (0...(2**type.size))
113
+ raise AbiEncodingError, "value #{value} is out of bounds for #{type}" unless range.cover?(value)
114
+
115
+ uint_word(value % (2**256))
116
+ end
117
+
118
+ # @param value [String] `0x` hex address
119
+ # @return [String] the left-padded word, binary
120
+ # @raise [BlockGiven::AbiEncodingError] when the value is not a 20-byte hex address
121
+ def encode_address(value)
122
+ raise AbiEncodingError, "invalid address #{value.inspect}" unless Utils.address?(value)
123
+
124
+ Utils.hex_to_bin(Utils.pad_hex(value))
125
+ end
126
+
127
+ # @param type [Type] `bytesN`
128
+ # @param value [String] `0x` hex or binary, at most N bytes
129
+ # @return [String] the right-padded word, binary
130
+ # @raise [BlockGiven::AbiEncodingError] when the value is longer than N bytes
131
+ def encode_fixed_bytes(type, value)
132
+ bytes = binary(type, value)
133
+ raise AbiEncodingError, "#{bytes.bytesize} bytes are out of bounds for #{type}" if bytes.bytesize > type.size
134
+
135
+ bytes.ljust(32, "\x00".b)
136
+ end
137
+
138
+ # @param bytes [String] binary
139
+ # @return [String] the length word, then the bytes right-padded to a multiple of 32, binary
140
+ def encode_bytes(bytes)
141
+ uint_word(bytes.bytesize) + bytes.ljust(((bytes.bytesize + 31) / 32) * 32, "\x00".b)
142
+ end
143
+
144
+ # @param type [Type] for error messages
145
+ # @param value [String] `0x` hex (decoded) or any other String (taken as raw bytes)
146
+ # @return [String] binary
147
+ # @raise [BlockGiven::AbiEncodingError] when the value is not a String or is odd-length hex
148
+ def binary(type, value)
149
+ raise AbiEncodingError, "#{type} expects a String, got #{value.inspect}" unless value.is_a?(String)
150
+ return value.b unless Utils.hex?(value)
151
+ raise AbiEncodingError, "#{type} expects an even number of hex digits" if value.size.odd?
152
+
153
+ Utils.hex_to_bin(value)
154
+ end
155
+
156
+ # @param value [String]
157
+ # @return [String] the UTF-8 bytes, binary
158
+ # @raise [BlockGiven::AbiEncodingError] when the value is not a String
159
+ def string_bytes(value)
160
+ raise AbiEncodingError, "string expects a String, got #{value.inspect}" unless value.is_a?(String)
161
+
162
+ value.encode(Encoding::UTF_8).b
163
+ end
164
+
165
+ # @param value [Integer] 0 to 2**256 - 1
166
+ # @return [String] 32 big-endian bytes, binary
167
+ def uint_word(value) = [value.to_s(16).rjust(64, "0")].pack("H*")
168
+ end
169
+ end
170
+ end