From b3cc9b0b8ffe9c62b27d1ffaf645a9c2c481c779 Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Sat, 9 May 2026 12:43:31 +0800 Subject: [PATCH] feat(rpc): decode get_block_by_number maps like transactions (Task 57) - Helpers: parse_block_response/1, parse_transaction_map/1 - RPC: decode eth_getBlockByNumber via decode_get_block_result/1 - Block: summarize_block from decoded RPC; {:error,:block_not_found} for nil - Bump to v0.6.0; CHANGELOG v0.6.0; ROADMAP/README/CLAUDE updates Co-authored-by: Cursor --- CHANGELOG.md | 11 ++- CLAUDE.md | 2 +- README.md | 6 +- ROADMAP.md | 21 ++--- lib/onchain/block.ex | 24 +++--- lib/onchain/rpc.ex | 57 ++++++------- lib/onchain/rpc/helpers.ex | 85 +++++++++++++++++++ mix.exs | 2 +- test/onchain/rpc/helpers_test.exs | 61 +++++++++++++ test/onchain/rpc/receipt_integration_test.exs | 6 +- .../rpc/transaction_integration_test.exs | 6 +- test/onchain/rpc_integration_test.exs | 16 ++-- 12 files changed, 220 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b19354..bfd5e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ Completed roadmap tasks. - Enumerated the `defi-skills` CLI action surface (`defi-skills actions --json`) and mapped it to **onchain** scope versus sibling repos. Mainnet lists 53 actions across twelve protocol groups; Arbitrum samples smaller coverage (25 actions, six groups). Added **Proposed additions from defi-skills mining** to `ROADMAP.md` with three scored proposals (ERC-721 writes, WETH helpers, allowance-gap pure helpers) plus cross-references to existing Tasks 51, 57, and 73. No library code changes. +## v0.6.0 — RPC block decode unification (2026-05-09) + +### Changed — `Onchain.RPC.get_block_by_number/2` decoded shape (Task 57, **breaking**) + +- **`get_block_by_number/2` + `!`** — responses from `eth_getBlockByNumber` are decoded like `get_transaction_by_hash/2`: **atom keys** (`:number`, `:timestamp`, `:transactions`, …), quantity fields as integers, `miner` checksummed, hashes/bloom/roots/`extra_data`/PoW header `nonce` left as 0x hex. **`transactions`** remains a list of tx hashes when `full_transactions` is `false` (current default); if the node returns full tx objects, each entry is passed through `parse_transaction_map/1`. +- **`Onchain.RPC.Helpers`** — `parse_block_response/1` and `parse_transaction_map/1` (`@doc false`) centralize decoding; `Onchain.RPC` delegates via existing `import`. +- **`Onchain.Block.get_by_number/2`** — builds its summary from the decoded RPC map; `{:ok, nil}` from RPC becomes `{:error, :block_not_found}` (previously would have crashed in the old raw-map parser). Pending blocks (`number: nil`) still surface as `{:error, :pending_block}`. +- **Migration:** replace string keys and hex quantities — e.g. `block["number"]` → `block.number`, `String.to_integer(..., 16)` no longer needed for standard quantities. + ## v0.5.4 — Cartouche 0.2 + ABI revert decoding + fee history (2026-05-07) ### Bumped — cartouche 0.2.0 + ex_ast 0.10.1 @@ -30,7 +39,7 @@ Completed roadmap tasks. - **Validators:** `ensure_hex_address/1` for the address; new `validate_storage_keys/1` (private in `rpc.ex`) walks the list calling `Helpers.ensure_storage_key/1` per element with `Enum.reduce_while`, returning `{:ok, [normalized]}` or the first `{:error, {:invalid_storage_key, input}}`; `normalize_block/1` for the block tag. Empty `storage_keys` list is valid (account-only proof). Non-list `storage_keys` returns `{:error, {:invalid_storage_keys, input}}`. `:block` lives in opts (default `"latest"`). - **Return shape:** atom-keyed map produced by a private `parse_proof/1` mirroring `parse_transaction/1`. `balance` and `nonce` decode to integers via `parse_hex_integer/1`; `address` returns checksummed via `parse_address/1`; `code_hash`, `storage_hash`, and the proof byte arrays pass through as raw 0x-hex strings (callers verifying the proof want them byte-shaped, not decoded). Each `storage_proof` entry is also atom-keyed: `%{key, value, proof}`. Storage `value` is **not** decoded — slots can hold anything (addresses, hashes, packed integers); the caller knows the schema. - **New helper in `Onchain.RPC.Helpers`:** `ensure_storage_key/1` — delegates to `ensure_tx_hash/1` (same 32-byte shape) but re-tags the error as `{:invalid_storage_key, input}`. Reusing `ensure_tx_hash` directly would lie at the boundary, tagging storage-slot errors as "tx hash" errors; the thin re-tag wrapper keeps the boundary honest without duplicating validation code. -- **Doesn't pre-commit Task 57.** This wrapper lands in the decoded-atom-keyed-map camp (matching `get_transaction_by_hash/2`) rather than the raw-string-keyed-map camp (`get_block_by_number/2`). When Task 57 unifies the return shapes, both `parse_proof/1` and any future struct can produce the same atom-keyed shape — no breaking change locked in here. +- **Aligned with Task 57** — `get_block_by_number/2` now returns the same decoded-atom-keyed convention as this proof map and `get_transaction_by_hash/2` (v0.6.0). - **Coverage:** `Onchain.RPC` 91.84% → 92.18%; `Onchain.RPC.Helpers` → 94.12%. Both above the 80% standard tier. ### Added — `Onchain.ABI.decode_call/3` + `decode_error/2` (Task 72) diff --git a/CLAUDE.md b/CLAUDE.md index 137dfce..8d0b078 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ lib/onchain/ decimal.ex # to_decimal/2, to_basis_points/1, div_pow10/2 fees.ex # suggest_fees/2 — EIP-1559 fee recommendation over Cartouche.FeeHistory.t() rpc.ex # eth_call, eth_getLogs, eth_getBalance, receipts, nonces, syncing, fee_history, get_proof, generic call/3 passthrough - rpc/helpers.ex # shared RPC helpers; do_rpc enriches revert maps with :data hex for decode_error/2 + rpc/helpers.ex # shared RPC helpers; parse_block_response/1, parse_transaction_map/1; do_rpc enriches revert maps with :data hex for decode_error/2 signer.ex # key management, transaction signing erc20.ex # reads + writes: balanceOf, allowance, decimals, symbol, totalSupply, approve, transfer erc721.ex # ERC-721 NFT reads: ownerOf, tokenURI, balanceOf diff --git a/README.md b/README.md index 0f2d1da..8407031 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Pick what you need — consumers who only need `eth_call` never compile Rust or ```elixir def deps do [ - {:onchain, "~> 0.5"}, + {:onchain, "~> 0.6"}, # Add if you need Aave: {:onchain_aave, "~> 0.1"}, # Add if you need EVM simulation / Solidity parsing: @@ -84,8 +84,8 @@ balance = Onchain.ERC20.balance_of!(usdc, "0xYourAddress") | `Onchain.Address` | Address validation, EIP-55 checksum, normalization | | `Onchain.Decimal` | Decimal precision helpers (to_decimal, div_pow10, to_basis_points) | | `Onchain.Fees` | EIP-1559 fee recommendation (`suggest_fees/2`) over `Cartouche.FeeHistory.t()` — pure function, returns `{base_fee, max_priority, max_fee}` | -| `Onchain.RPC` | Ethereum JSON-RPC wrapper (eth_call, eth_getLogs, receipts, nonces, balances, block_number, chain_id, get_block_by_number, get_transaction_by_hash, eth_get_code, eth_send_raw_transaction, syncing, fee_history, get_proof; `call/3` for any other method). `eth_get_logs/2` accepts atom keys or canonical camelCase string aliases (`"fromBlock"`, `"toBlock"`, `"blockHash"`, `"address"`, `"topics"`); `:block_hash` is mutually exclusive with `:from_block`/`:to_block` per EIP-1474 | -| `Onchain.RPC.Helpers` | Shared RPC helper functions (hex normalization, block tags, tx hash validation; execution-revert maps get `:data` hex for `decode_error/2`) | +| `Onchain.RPC` | Ethereum JSON-RPC wrapper (eth_call, eth_getLogs, receipts, nonces, balances, block_number, chain_id, **decoded** `get_block_by_number`, `get_transaction_by_hash`, eth_get_code, eth_send_raw_transaction, syncing, fee_history, get_proof; `call/3` for any other method). `get_block_by_number/2` returns atom-keyed maps (quantities as integers — aligned with `get_transaction_by_hash/2`). `eth_get_logs/2` accepts atom keys or canonical camelCase string aliases (`"fromBlock"`, `"toBlock"`, `"blockHash"`, `"address"`, `"topics"`); `:block_hash` is mutually exclusive with `:from_block`/`:to_block` per EIP-1474 | +| `Onchain.RPC.Helpers` | Shared RPC helpers (hex normalization, block tags, tx hash validation; `parse_block_response/1`, `parse_transaction_map/1`; execution-revert maps get `:data` hex for `decode_error/2`) | | `Onchain.Block` | Block fetching with parsed fields, timestamp-based binary search | | `Onchain.Contract` | Generic contract call (encode -> eth_call -> decode in one function) | | `Onchain.Multicall` | Batch multiple eth_call via Multicall3 | diff --git a/ROADMAP.md b/ROADMAP.md index 6345684..9469444 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,11 +15,11 @@ **Phase 8: Chain Intelligence Primitives ✅** — All tasks complete. Wallet analytics, transfer parsing, ENS resolution, NFT reads, and real-time subscriptions. Phase 9 (JS bridge) extracted to [onchain_js](../onchain_js/ROADMAP.md). -**Last shipped:** v0.5.4 (2026-05-07) — Tasks 49, 53, 71, 72 + cartouche 0.2.0 / ex_ast 0.10.1 dep bumps. +**Last shipped:** v0.6.0 (2026-05-09) — Task 57 (`get_block_by_number` decoded atom-keyed map). Prior: v0.5.4 (2026-05-07) — Tasks 49, 53, 71, 72 + cartouche 0.2.0 / ex_ast 0.10.1 dep bumps. **Pending decision:** Promote P1/P2/P3 from [Proposed additions from defi-skills mining](#proposed-additions-from-defi-skills-mining) (Effs 2.50 / 2.17 / 2.17 — all outrank existing Code Health work) or discard. Task 68 (defi-skills mining) complete; the proposals are awaiting accept/reject before being scored into Code Health. -**Up next:** **Task 57** — unify `get_block_*` / `get_transaction_*` RPC return shapes (Eff 1.50 🚀). **Task 73** (revert `data` for `decode_error/2`) is complete; see [CHANGELOG](CHANGELOG.md). +**Up next:** **Task 51** — `Onchain.RPC.batch/2` (Phase 10). **Task 73** (revert `data` for `decode_error/2`) is complete; see [CHANGELOG](CHANGELOG.md). Task 48 (`onchain_ws` extraction) closed as won't-fix on 2026-05-02 — see Code Health rationale. @@ -31,9 +31,10 @@ Task 48 (`onchain_ws` extraction) closed as won't-fix on 2026-05-02 — see Code > > **RPC-layer serialization:** Tasks **51**, **52**, **54**, **57**, and **63** omit `[P]`—they converge on `lib/onchain/rpc.ex` and RPC helpers; avoid parallel sessions on those rows. **64** and **66** omit `[P]` because they are gated on **63** / **64**. -### ✅ Recently Completed (8) +### ✅ Recently Completed (9) | Task | Description | Notes | |------|-------------|-------| +| 57 | Unify RPC return shapes: `get_block_by_number/2` decoded atom-keyed map (matches `get_transaction_by_hash/2`) | `Onchain.RPC.Helpers.parse_block_response/1`, `parse_transaction_map/1`; `Onchain.Block` summary from decoded map; breaking — minor v0.6.0. CHANGELOG migration note. | | 73 | Surface revert `data` on execution-reverted RPC errors for `Onchain.ABI.decode_error/2` | `Onchain.RPC.Helpers.do_rpc/3` enriches cartouche's `:revert` binary with `:data` as lowercase 0x hex when absent. Docs: `Onchain.RPC`, `Onchain.Contract`, README. | | 68 | Mine `defi-skills` action surface for onchain coverage gaps | Discovery-only: `defi-skills actions --json` on mainnet (53 actions / 12 protocol groups) vs Arbitrum (25 actions / 6 groups). Cross-protocol gaps proposed under [Proposed additions from defi-skills mining](#proposed-additions-from-defi-skills-mining); protocol-specific surfaces delegated to sibling repos in that section. | | 49 | `Onchain.RPC.get_proof/3` (+ bang) wraps `eth_getProof` | Account + storage Merkle proofs for light clients and cross-chain proofs. Validates address (`ensure_hex_address/1`), 32-byte storage keys (new `Helpers.ensure_storage_key/1` re-tagging `ensure_tx_hash/1`), and block tag. Returns atom-keyed map with `balance`/`nonce` decoded to integers and proof byte arrays passed through as 0x hex (caller verifies the Merkle proof). Matches `parse_transaction/1` shape rather than `get_block_by_number`'s raw map; doesn't pre-commit Task 57's unification choice. | @@ -47,7 +48,7 @@ Task 48 (`onchain_ws` extraction) closed as won't-fix on 2026-05-02 — see Code ## Release Plan -Last shipped: **v0.5.4** (2026-05-07) — Tasks 49, 53, 71, 72 + cartouche 0.2.0 / ex_ast 0.10.1 dep bumps. +Last shipped: **v0.6.0** (2026-05-09) — Task 57 (`get_block_by_number` decoded map). Prior: **v0.5.4** (2026-05-07) — Tasks 49, 53, 71, 72 + cartouche 0.2.0 / ex_ast 0.10.1 dep bumps. Prior: **v0.5.3** (2026-05-02) — Bundled subscription hardening (v0.5.2 task set: 38, 39, 42, 43, 55, 56, 59, 62, 67) + surface-area polish (v0.5.3 task set: 50, 58, 60, 61) under a single tag covering `v0.5.1..HEAD`. v0.5.2 work was committed but never tagged separately — folded into the v0.5.3 tag rather than retroactively labeling. @@ -184,7 +185,7 @@ Add ERC-4337 support: UserOperation construction, signing, and bundler RPC (`eth | 48 | Extract `Onchain.Subscription` into `onchain_ws` package — see won't-fix rationale below | 🔶 Won't fix (2026-05-02) | — | — | — | — | `onchain_ws` (new package) | | 55 | Harden `Onchain.RPC.Helpers` address/data validation (four silent-corruption paths) — see [CHANGELOG](CHANGELOG.md#changed--rpc-input-hardening-tasks-55-56) | ✅ | 2 | 9 | 8 | 4.25 🎯 | `Onchain.RPC.Helpers` | | 56 | 🐛 `eth_get_logs/2` silently dropped wrong filter keys — see [CHANGELOG](CHANGELOG.md#changed--rpc-input-hardening-tasks-55-56) | ✅ | 2 | 6 | 7 | 3.25 🎯 | `Onchain.RPC` | -| 57 | Unify RPC return shapes: `get_transaction_by_hash/2` returns decoded atom-keyed struct, `get_block_by_number/2` returns raw string-keyed hex map — pick one | ⬜ | 4 | 6 | 6 | 1.50 🚀 | `Onchain.RPC` | +| 57 | Unify RPC return shapes: `get_transaction_by_hash/2` returns decoded atom-keyed struct, `get_block_by_number/2` returns raw string-keyed hex map — pick one | ✅ | 4 | 6 | 6 | 1.50 🚀 | `Onchain.RPC` | | 58 | `Onchain.ABI.decode_types/2` + bang variant alias of `decode_response/2`; tuple-sig footgun documented — see [CHANGELOG](CHANGELOG.md#added--onchainabidecode_types2-alias-task-58) | ✅ | 1 | 3 | 4 | 3.50 🎯 | `Onchain.ABI` | | 60 | `eth_get_logs/2` accepts canonical camelCase string-key aliases — see [CHANGELOG](CHANGELOG.md#changed--eth_get_logs2-filter-ergonomics-tasks-60-61) | ✅ | 2 | 4 | 4 | 2.00 🚀 | `Onchain.RPC` | | 61 | `eth_get_logs/2` accepts `:block_hash` (EIP-1474) — see [CHANGELOG](CHANGELOG.md#changed--eth_get_logs2-filter-ergonomics-tasks-60-61) | ✅ | 2 | 3 | 4 | 1.75 🚀 | `Onchain.RPC` | @@ -197,13 +198,7 @@ Add ERC-4337 support: UserOperation construction, signing, and bundler RPC (`eth | 70 `[P]` | Harden `Onchain.Subscription.lookup_or_buffer/3` against unsolicited sub_id keys: `pending` map has per-key cap of 100 but unbounded distinct-keys count. Server emitting notifications for never-`subscribe`-d sub_ids grows key set until connection closes (per-connection Agent dies with the conn, so blast radius is bounded — but worth fixing). Buffer only sub_ids in an in-flight subscribe state, or add a global key cap with eviction. | ⬜ | 3 | 4 | 3 | 1.17 📋 | `Onchain.Subscription` | | 73 | Surface `data` field on `eth_call` revert errors so consumers can feed it to `Onchain.ABI.decode_error/2`. Cartouche attaches `:revert` bytes for JSON-RPC `code: 3` + `data`; onchain mirrors them as `:data` (0x hex) in `do_rpc/3` unless already present. See [CHANGELOG](CHANGELOG.md#unreleased). | ✅ | 3 | 5 | 5 | 1.67 🚀 | `Onchain.RPC` + `Onchain.RPC.Helpers` | -**Task 57 — Unify `get_block_*` / `get_transaction_*` return shapes.** - -`get_transaction_by_hash/2` returns an atom-keyed struct with integers decoded (`%{value: 0, block_number: 24933341, …}`). `get_block_by_number/2` returns a raw string-keyed map with hex-string values (`%{"baseFeePerGas" => "0x7e479377", …}`). Callers have to remember which returns which, and the second shape forces manual `String.to_integer/2`. - -Pick one: either both decode to atom-keyed structs (`Onchain.Block.t()`, `Onchain.Transaction.t()`), or both surface raw string-keyed maps (caller-decodes). Leaning toward decoded structs — matches the Phase 8 transfer-parser direction and the `Onchain.Transfer` pattern. - -Breaking change for consumers of `get_block_by_number/2`; justify with a minor-version bump and a brief migration note in CHANGELOG. +**Task 57 — Unify `get_block_*` / `get_transaction_*` return shapes.** ✅ Shipped v0.6.0 — `get_block_by_number/2` returns an atom-keyed map decoded via `Onchain.RPC.Helpers.parse_block_response/1` (same conventions as `get_transaction_by_hash/2` / `parse_transaction_map/1`). Migration: CHANGELOG **v0.6.0** section. --- @@ -526,7 +521,7 @@ lib/ contract.ex # generic call/4 (encode → eth_call → decode) rpc.ex # eth_call, eth_getLogs, get_transaction_receipt, fee_history, etc. rpc/ - helpers.ex # shared RPC helper functions + helpers.ex # shared RPC helpers; parse_block_response/1, parse_transaction_map/1; do_rpc revert :data log.ex # event log parsing against ABI signatures multicall.ex # Multicall3 batched reads signer.ex # key management, transaction signing diff --git a/lib/onchain/block.ex b/lib/onchain/block.ex index d491cb3..506a106 100644 --- a/lib/onchain/block.ex +++ b/lib/onchain/block.ex @@ -49,8 +49,8 @@ defmodule Onchain.Block do @spec get_by_number(integer() | String.t(), keyword()) :: {:ok, map()} | {:error, term()} def get_by_number(block_id, opts \\ []) do - with {:ok, raw} <- Onchain.RPC.get_block_by_number(block_id, opts) do - parse_block(raw) + with {:ok, block} <- Onchain.RPC.get_block_by_number(block_id, opts) do + summarize_block(block) end end @@ -204,15 +204,15 @@ defmodule Onchain.Block do end @doc false - # Parses a raw RPC block map into native types. - # Pending blocks have nil for number/timestamp — return a clear error instead of crashing. - @spec parse_block(map()) :: {:ok, map()} | {:error, term()} - defp parse_block(%{"number" => nil}), do: {:error, :pending_block} - - defp parse_block(raw) do - with {:ok, number} <- Onchain.Hex.to_integer(raw["number"]), - {:ok, timestamp} <- Onchain.Hex.to_integer(raw["timestamp"]) do - {:ok, %{number: number, timestamp: timestamp, hash: raw["hash"]}} - end + # RPC blocks are decoded in Onchain.RPC; pending blocks have number: nil. + @spec summarize_block(map() | nil) :: {:ok, map()} | {:error, term()} + defp summarize_block(nil), do: {:error, :block_not_found} + + defp summarize_block(%{number: nil}), do: {:error, :pending_block} + + defp summarize_block(%{number: n, timestamp: ts, hash: h}) when is_integer(n) and is_integer(ts) do + {:ok, %{number: n, timestamp: ts, hash: h}} end + + defp summarize_block(_), do: {:error, :invalid_block} end diff --git a/lib/onchain/rpc.ex b/lib/onchain/rpc.ex index d273e9d..84b71d7 100644 --- a/lib/onchain/rpc.ex +++ b/lib/onchain/rpc.ex @@ -39,7 +39,7 @@ defmodule Onchain.RPC do | `block_number!/1` | Same, raises on error | | `syncing/1` | Node sync status (`false` or sync-status map) | | `syncing!/1` | Same, raises on error | - | `get_block_by_number/2` | Fetch block by number or tag | + | `get_block_by_number/2` | Fetch block by number or tag → atom-keyed decoded map (same conventions as `get_transaction_by_hash`) | | `get_block_by_number!/2` | Same, raises on error | | `chain_id/1` | Network chain ID | | `chain_id!/1` | Same, raises on error | @@ -270,9 +270,11 @@ defmodule Onchain.RPC do opts: [kind: :value, default: [], description: "Options: :rpc_url, :timeout"] ], returns: %{ - type: "{:ok, map} | {:error, term}", - description: "Raw block map with hex-encoded fields from the node", - example: ~s(%{"number" => "0x1312d00", "timestamp" => "0x665ba27f", ...}) + type: "{:ok, map | nil} | {:error, term}", + description: + "Decoded block map (atom keys): quantities as integers, miner checksummed, " <> + "blooms/hashes/roots/extra_data as 0x hex; transactions are tx hashes or decoded maps if full txs requested", + example: ~s(%{number: 20_000_000, timestamp: 1_717_281_407, hash: "0x...", transactions: ["0x...", ...]}) } ) @@ -296,21 +298,28 @@ defmodule Onchain.RPC do } @spec get_block_by_number(integer() | String.t(), keyword()) :: - {:ok, map()} | {:error, term()} + {:ok, map() | nil} | {:error, term()} def get_block_by_number(block_id, opts \\ []) def get_block_by_number(block_id, opts) when is_integer(block_id) and block_id >= 0 do hex = Onchain.Hex.from_integer(block_id) - do_rpc("eth_getBlockByNumber", [hex, false], to_rpc_opts(opts)) + + "eth_getBlockByNumber" + |> do_rpc([hex, false], to_rpc_opts(opts)) + |> decode_get_block_result() end def get_block_by_number(tag, opts) when tag in @block_tags do - do_rpc("eth_getBlockByNumber", [tag, false], to_rpc_opts(opts)) + "eth_getBlockByNumber" + |> do_rpc([tag, false], to_rpc_opts(opts)) + |> decode_get_block_result() end def get_block_by_number("0x" <> _ = hex_num, opts) do if Onchain.Hex.valid?(hex_num) do - do_rpc("eth_getBlockByNumber", [hex_num, false], to_rpc_opts(opts)) + "eth_getBlockByNumber" + |> do_rpc([hex_num, false], to_rpc_opts(opts)) + |> decode_get_block_result() else {:error, {:invalid_block_id, hex_num}} end @@ -328,10 +337,10 @@ defmodule Onchain.RPC do ], opts: [kind: :value, default: [], description: "Options: :rpc_url, :timeout"] ], - returns: %{type: :map, description: "Raw block map with hex-encoded fields"} + returns: %{type: "map | nil", description: "Decoded atom-keyed block map"} ) - @spec get_block_by_number!(integer() | String.t(), keyword()) :: map() + @spec get_block_by_number!(integer() | String.t(), keyword()) :: map() | nil def get_block_by_number!(block_id, opts \\ []) do case get_block_by_number(block_id, opts) do {:ok, result} -> result @@ -517,7 +526,7 @@ defmodule Onchain.RPC do with {:ok, _hex} <- ensure_tx_hash(tx_hash) do case do_rpc("eth_getTransactionByHash", [tx_hash], to_rpc_opts(opts)) do {:ok, nil} -> {:ok, nil} - {:ok, tx} when is_map(tx) -> {:ok, parse_transaction(tx)} + {:ok, tx} when is_map(tx) -> {:ok, parse_transaction_map(tx)} error -> error end end @@ -874,27 +883,11 @@ defmodule Onchain.RPC do end @doc false - # Parses a raw transaction map from the RPC response into atom-keyed map. - @spec parse_transaction(map()) :: map() - defp parse_transaction(tx) when is_map(tx) do - %{ - hash: tx["hash"], - nonce: parse_hex_integer(tx["nonce"]), - block_hash: tx["blockHash"], - block_number: parse_hex_integer(tx["blockNumber"]), - transaction_index: parse_hex_integer(tx["transactionIndex"]), - from: parse_address(tx["from"]), - to: parse_address(tx["to"]), - value: parse_hex_integer(tx["value"]), - gas: parse_hex_integer(tx["gas"]), - gas_price: parse_hex_integer(tx["gasPrice"]), - max_fee_per_gas: parse_hex_integer(tx["maxFeePerGas"]), - max_priority_fee_per_gas: parse_hex_integer(tx["maxPriorityFeePerGas"]), - input: tx["input"], - type: parse_hex_integer(tx["type"]), - chain_id: parse_hex_integer(tx["chainId"]) - } - end + defp decode_get_block_result({:ok, nil}), do: {:ok, nil} + + defp decode_get_block_result({:ok, block}) when is_map(block), do: {:ok, parse_block_response(block)} + + defp decode_get_block_result(other), do: other @doc false # Validates that storage_keys is a list of 32-byte 0x-hex strings, normalizing each. diff --git a/lib/onchain/rpc/helpers.ex b/lib/onchain/rpc/helpers.ex index f6e6d67..adeec99 100644 --- a/lib/onchain/rpc/helpers.ex +++ b/lib/onchain/rpc/helpers.ex @@ -219,4 +219,89 @@ defmodule Onchain.RPC.Helpers do nil end end + + @doc false + # Decodes eth_getTransactionByHash JSON object (camelCase keys) to atom-keyed map. + @spec parse_transaction_map(map()) :: map() + def parse_transaction_map(tx) when is_map(tx) do + %{ + hash: tx["hash"], + nonce: parse_hex_integer(tx["nonce"]), + block_hash: tx["blockHash"], + block_number: parse_hex_integer(tx["blockNumber"]), + transaction_index: parse_hex_integer(tx["transactionIndex"]), + from: parse_address(tx["from"]), + to: parse_address(tx["to"]), + value: parse_hex_integer(tx["value"]), + gas: parse_hex_integer(tx["gas"]), + gas_price: parse_hex_integer(tx["gasPrice"]), + max_fee_per_gas: parse_hex_integer(tx["maxFeePerGas"]), + max_priority_fee_per_gas: parse_hex_integer(tx["maxPriorityFeePerGas"]), + input: tx["input"], + type: parse_hex_integer(tx["type"]), + chain_id: parse_hex_integer(tx["chainId"]) + } + end + + @doc false + # Decodes eth_getBlockByNumber JSON object when full_transactions is false (hashes only) + # or true (full tx maps). Aligns with parse_transaction_map/1 field conventions. + @spec parse_block_response(map()) :: map() + def parse_block_response(raw) when is_map(raw) do + %{ + number: parse_hex_integer(raw["number"]), + hash: raw["hash"], + parent_hash: raw["parentHash"], + sha3_uncles: raw["sha3Uncles"], + logs_bloom: raw["logsBloom"], + transactions_root: raw["transactionsRoot"], + state_root: raw["stateRoot"], + receipts_root: raw["receiptsRoot"], + miner: parse_address(raw["miner"]), + difficulty: parse_hex_integer(raw["difficulty"]), + total_difficulty: parse_hex_integer(raw["totalDifficulty"]), + extra_data: raw["extraData"], + size: parse_hex_integer(raw["size"]), + gas_limit: parse_hex_integer(raw["gasLimit"]), + gas_used: parse_hex_integer(raw["gasUsed"]), + timestamp: parse_hex_integer(raw["timestamp"]), + transactions: parse_block_transactions(raw["transactions"]), + uncles: raw["uncles"] || [], + mix_hash: raw["mixHash"], + nonce: raw["nonce"], + base_fee_per_gas: parse_hex_integer(raw["baseFeePerGas"]), + withdrawals_root: raw["withdrawalsRoot"], + withdrawals: parse_withdrawals(raw["withdrawals"]), + blob_gas_used: parse_hex_integer(raw["blobGasUsed"]), + excess_blob_gas: parse_hex_integer(raw["excessBlobGas"]), + parent_beacon_block_root: raw["parentBeaconBlockRoot"], + requests_hash: raw["requestsHash"] + } + end + + defp parse_block_transactions(nil), do: [] + + defp parse_block_transactions(list) when is_list(list) do + Enum.map(list, fn + %{} = tx -> parse_transaction_map(tx) + other when is_binary(other) -> other + end) + end + + defp parse_block_transactions(_), do: [] + + defp parse_withdrawals(nil), do: nil + + defp parse_withdrawals(list) when is_list(list) do + Enum.map(list, fn w when is_map(w) -> + %{ + index: parse_hex_integer(w["index"]), + validator_index: parse_hex_integer(w["validatorIndex"]), + address: parse_address(w["address"]), + amount: parse_hex_integer(w["amount"]) + } + end) + end + + defp parse_withdrawals(_), do: nil end diff --git a/mix.exs b/mix.exs index 316ccae..9132575 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Onchain.MixProject do use Mix.Project - @version "0.5.4" + @version "0.6.0" @source_url "https://github.com/ZenHive/onchain" def project do diff --git a/test/onchain/rpc/helpers_test.exs b/test/onchain/rpc/helpers_test.exs index be51f3a..1edda86 100644 --- a/test/onchain/rpc/helpers_test.exs +++ b/test/onchain/rpc/helpers_test.exs @@ -30,4 +30,65 @@ defmodule Onchain.RPC.HelpersTest do assert %{data: "0x"} = Helpers.maybe_put_revert_data_hex(map) end end + + describe "parse_block_response/1" do + test "decodes quantities and keeps tx hashes as binaries" do + raw = %{ + "number" => "0x1312d00", + "timestamp" => "0x665ba27f", + "hash" => "0xd24fd97aa00ee83dad68403760f798f91f76f38007ec11516bf38993af9fee45", + "miner" => "0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5", + "transactions" => [ + "0xaaa43bbbfc910f02df998749665040163cd840fcfe358bfaa226662e03bf091b", + "0xbbb43bbbfc910f02df998749665040163cd840fcfe358bfaa226662e03bf091b" + ], + "gasLimit" => "0x1c9c380", + "gasUsed" => "0x123456", + "baseFeePerGas" => "0x3b9aca00" + } + + assert %{ + number: 20_000_000, + timestamp: 1_717_281_407, + gas_limit: 30_000_000, + gas_used: 1_193_046, + base_fee_per_gas: 1_000_000_000, + transactions: [_, _], + miner: miner + } = Helpers.parse_block_response(raw) + + assert String.starts_with?(miner, "0x") + assert byte_size(miner) == 42 + end + + test "decodes full transaction objects when present in transactions list" do + raw = %{ + "number" => "0x1", + "timestamp" => "0x2", + "hash" => "0xcc", + "transactions" => [ + %{ + "hash" => "0x" <> String.duplicate("ab", 32), + "nonce" => "0x0", + "blockHash" => "0xdd", + "blockNumber" => "0x1", + "transactionIndex" => "0x0", + "from" => "0x1111111111111111111111111111111111111111", + "to" => nil, + "value" => "0x0", + "gas" => "0x5208", + "gasPrice" => "0x3b9aca00", + "input" => "0x", + "type" => "0x0", + "chainId" => "0x1" + } + ] + } + + assert %{transactions: [tx]} = Helpers.parse_block_response(raw) + assert tx.hash =~ ~r/^0x/ + assert tx.nonce == 0 + assert tx.type == 0 + end + end end diff --git a/test/onchain/rpc/receipt_integration_test.exs b/test/onchain/rpc/receipt_integration_test.exs index 566de93..b52250d 100644 --- a/test/onchain/rpc/receipt_integration_test.exs +++ b/test/onchain/rpc/receipt_integration_test.exs @@ -13,7 +13,7 @@ defmodule Onchain.RPC.ReceiptIntegrationTest do describe "get_transaction_receipt/2" do test "fetches receipt from a known block's first transaction" do {:ok, block} = RPC.get_block_by_number(@test_block, rpc_opts()) - tx_hashes = block["transactions"] + tx_hashes = block.transactions assert tx_hashes != [], "Expected block #{@test_block} to have transactions" @@ -50,7 +50,7 @@ defmodule Onchain.RPC.ReceiptIntegrationTest do test "receipt logs match eth_get_logs structure" do {:ok, block} = RPC.get_block_by_number(@test_block, rpc_opts()) - tx_hash = hd(block["transactions"]) + tx_hash = hd(block.transactions) {:ok, receipt} = RPC.get_transaction_receipt(tx_hash, rpc_opts()) # If there are logs, verify they have the same structure as eth_get_logs @@ -76,7 +76,7 @@ defmodule Onchain.RPC.ReceiptIntegrationTest do describe "get_transaction_receipt!/2" do test "returns receipt directly" do {:ok, block} = RPC.get_block_by_number(@test_block, rpc_opts()) - tx_hash = hd(block["transactions"]) + tx_hash = hd(block.transactions) receipt = RPC.get_transaction_receipt!(tx_hash, rpc_opts()) assert is_map(receipt) diff --git a/test/onchain/rpc/transaction_integration_test.exs b/test/onchain/rpc/transaction_integration_test.exs index e5741ac..76e2424 100644 --- a/test/onchain/rpc/transaction_integration_test.exs +++ b/test/onchain/rpc/transaction_integration_test.exs @@ -13,7 +13,7 @@ defmodule Onchain.RPC.TransactionIntegrationTest do describe "get_transaction_by_hash/2" do test "fetches transaction from a known block's first transaction" do {:ok, block} = RPC.get_block_by_number(@test_block, rpc_opts()) - tx_hashes = block["transactions"] + tx_hashes = block.transactions assert tx_hashes != [], "Expected block #{@test_block} to have transactions" @@ -48,7 +48,7 @@ defmodule Onchain.RPC.TransactionIntegrationTest do test "gas price fields vary by transaction type" do {:ok, block} = RPC.get_block_by_number(@test_block, rpc_opts()) - tx_hash = hd(block["transactions"]) + tx_hash = hd(block.transactions) {:ok, tx} = RPC.get_transaction_by_hash(tx_hash, rpc_opts()) # At least one gas price mechanism must be present @@ -66,7 +66,7 @@ defmodule Onchain.RPC.TransactionIntegrationTest do describe "get_transaction_by_hash!/2" do test "returns transaction directly" do {:ok, block} = RPC.get_block_by_number(@test_block, rpc_opts()) - tx_hash = hd(block["transactions"]) + tx_hash = hd(block.transactions) tx = RPC.get_transaction_by_hash!(tx_hash, rpc_opts()) assert is_map(tx) diff --git a/test/onchain/rpc_integration_test.exs b/test/onchain/rpc_integration_test.exs index d4bbe85..caec33d 100644 --- a/test/onchain/rpc_integration_test.exs +++ b/test/onchain/rpc_integration_test.exs @@ -55,29 +55,29 @@ defmodule Onchain.RPC.IntegrationTest do end describe "get_block_by_number/2" do - test "fetches a known block and returns raw map" do + test "fetches a known block and returns decoded map" do assert {:ok, block} = RPC.get_block_by_number(20_000_000, rpc_opts()) assert is_map(block) - assert block["number"] == "0x1312d00" - assert is_binary(block["timestamp"]) - assert is_binary(block["hash"]) + assert block.number == 20_000_000 + assert is_integer(block.timestamp) + assert is_binary(block.hash) end test "accepts 'latest' tag" do assert {:ok, block} = RPC.get_block_by_number("latest", rpc_opts()) assert is_map(block) - assert is_binary(block["number"]) + assert is_integer(block.number) end test "accepts 'finalized' tag" do assert {:ok, block} = RPC.get_block_by_number("finalized", rpc_opts()) assert is_map(block) - assert is_binary(block["number"]) + assert is_integer(block.number) end test "accepts hex block number" do assert {:ok, block} = RPC.get_block_by_number("0x1312d00", rpc_opts()) - assert block["number"] == "0x1312d00" + assert block.number == 20_000_000 end end @@ -118,7 +118,7 @@ defmodule Onchain.RPC.IntegrationTest do test "returns block map directly" do block = RPC.get_block_by_number!(20_000_000, rpc_opts()) assert is_map(block) - assert block["number"] == "0x1312d00" + assert block.number == 20_000_000 end end