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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/README.md +92 -55
- data/lib/block_given/abi/codec/decoder.rb +111 -0
- data/lib/block_given/abi/codec.rb +170 -0
- data/lib/block_given/abi/coder.rb +122 -11
- data/lib/block_given/abi/custom_error.rb +33 -1
- data/lib/block_given/abi/event.rb +85 -3
- data/lib/block_given/abi/function.rb +94 -2
- data/lib/block_given/abi/interface.rb +99 -4
- data/lib/block_given/abi/parameter.rb +60 -3
- data/lib/block_given/abi/standards/erc1155.rb +41 -0
- data/lib/block_given/abi/standards/erc20.rb +33 -0
- data/lib/block_given/abi/standards/erc4626.rb +41 -0
- data/lib/block_given/abi/standards/erc721.rb +47 -0
- data/lib/block_given/abi/standards.rb +124 -0
- data/lib/block_given/abi/type.rb +138 -0
- data/lib/block_given/chain.rb +114 -2
- data/lib/block_given/client.rb +322 -20
- data/lib/block_given/configuration.rb +85 -3
- data/lib/block_given/connectors/alchemy.rb +38 -4
- data/lib/block_given/connectors/base.rb +32 -5
- data/lib/block_given/connectors/http.rb +91 -5
- data/lib/block_given/connectors/stub.rb +70 -4
- data/lib/block_given/contract.rb +422 -26
- data/lib/block_given/crypto/keccak.rb +152 -0
- data/lib/block_given/crypto/secp256k1.rb +168 -0
- data/lib/block_given/crypto.rb +22 -0
- data/lib/block_given/eip712.rb +199 -0
- data/lib/block_given/errors.rb +134 -14
- data/lib/block_given/event.rb +51 -1
- data/lib/block_given/normalizer.rb +27 -2
- data/lib/block_given/poller.rb +177 -15
- data/lib/block_given/receipt.rb +63 -3
- data/lib/block_given/rlp.rb +146 -0
- data/lib/block_given/signed_transaction.rb +148 -47
- data/lib/block_given/transaction.rb +103 -12
- data/lib/block_given/transaction_envelope/fields.rb +104 -0
- data/lib/block_given/transaction_envelope.rb +183 -0
- data/lib/block_given/utils.rb +143 -9
- data/lib/block_given/version.rb +2 -1
- data/lib/block_given/wallet.rb +216 -35
- data/lib/block_given.rb +67 -4
- metadata +19 -23
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9b06bd63a9b8cf930fffa7ddb4d38f0df49cba13bdf89f8cdec7dfc58d2afe4
|
|
4
|
+
data.tar.gz: 2089baad3a9d053cea769f9d40aac2f1344f74070522b2ed992048826e0ead42
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
[](https://github.com/Bolero-Music/block_given/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/block_given)
|
|
5
5
|
[](LICENSE.txt)
|
|
6
|
+
[](https://rubydoc.info/gems/block_given)
|
|
6
7
|

|
|
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
|
-
|
|
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",
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
116
|
-
|
|
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
|
|
121
|
-
abi_file "
|
|
122
|
-
address "
|
|
123
|
-
chain :base
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 =
|
|
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 =
|
|
192
|
-
signed.hash, signed.nonce, signed.raw
|
|
193
|
-
|
|
194
|
-
signed.broadcast
|
|
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(
|
|
198
|
-
tx = signed.transaction
|
|
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
|
|
201
|
-
when :reverted then
|
|
202
|
-
when :unknown then signed.broadcast
|
|
203
|
-
when :pending then signed.replacement.broadcast if
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
433
|
-
|
|
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
|
|
436
|
-
|
|
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).
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
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
|