Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment on lines +22 to +23
- **`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}`.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep Onchain.Block.get_by_number/2 error tuples in the standard shape.

Line 24 documents {:error, :block_not_found}. That creates a public API inconsistency versus the project-wide tuple contract. Prefer {:error, {:block_not_found, reason}} (or similar tagged-reason form) for uniform matching.

As per coding guidelines **/*.ex: "Use standard error tuples format: {:ok, result} | {:error, {:tag, reason}} for all public functions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 24, The CHANGELOG entry and implementation for
Onchain.Block.get_by_number/2 currently document/emit {:error,
:block_not_found}, which violates the project's standard error-tuple shape;
update the function (and its changelog) so it returns a shaped error tuple like
{:error, {:block_not_found, reason}} (e.g., use a descriptive atom or the
original RPC response as reason) for both RPC nil results and pending blocks,
and ensure callers/tests expect the new {:error, {:block_not_found, reason}}
form instead of a bare atom.

- **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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down
21 changes: 8 additions & 13 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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. |
Expand All @@ -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.

Expand Down Expand Up @@ -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` |
Expand All @@ -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.

---

Expand Down Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions lib/onchain/block.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +52 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize get_by_number/2 errors to tagged tuples.

These new {:error, :block_not_found | :pending_block | :invalid_block} branches leak bare atoms from a public API. That makes Onchain.Block.get_by_number/2 inconsistent with the repo’s normal error shape and with find_by_timestamp/2 in this same module.

💡 One straightforward way to preserve the new behavior without changing the public contract
 def get_by_number(block_id, opts \\ []) do
   with {:ok, block} <- Onchain.RPC.get_block_by_number(block_id, opts) do
-    summarize_block(block)
+    case summarize_block(block) do
+      {:ok, summary} -> {:ok, summary}
+      {:error, :block_not_found} -> {:error, {:block_not_found, block_id}}
+      {:error, :pending_block} -> {:error, {:pending_block, block_id}}
+      {:error, :invalid_block} -> {:error, {:invalid_block, block}}
+    end
   end
 end

As per coding guidelines, "Use standard error tuples format: {:ok, result} | {:error, {:tag, reason}} for all public functions".

Also applies to: 207-217

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/onchain/block.ex` around lines 52 - 53, Onchain.Block.get_by_number/2
currently forwards bare error atoms from Onchain.RPC.get_block_by_number (e.g.,
:block_not_found | :pending_block | :invalid_block); change it to normalize
those into the repo's standard tagged error shape by matching the {:error,
reason_atom} from Onchain.RPC.get_block_by_number and returning {:error,
{reason_atom, nil}} (or {:error, {reason_atom, detailed_reason}} if you can
extract one) instead of returning the bare atom, and make the same normalization
in the other branch referenced (lines ~207-217); update the code paths around
get_by_number/2, summarize_block, and ensure consistency with
find_by_timestamp/2.

end
end

Expand Down Expand Up @@ -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}}
Comment on lines 206 to +214
end

defp summarize_block(_), do: {:error, :invalid_block}
end
Loading