Notes / Audits and snapshots

ABI return-data audit, all ecosystems, 2026-09-21

Audits and snapshots2026-09-21Chain: Base, Robinhood Chain

Source: data/audit_abi_returndata_2026-09-21.md in the repository, rendered here without changes. Source file

Phase: maintenance (transverse). Changes no score, no oracle and no push. Adds a guard that refuses an ABI read whose answer has the wrong size, and a tool that audits every ecosystem's score_all() with it, and records the first result. Measured on the tree at 2324848 plus this change; the patch is delivered on a later base, and the commits in between change no scoring logic (checked when the patch was rebased: only comments and docstrings in the scorers they touch).

Why#

eth_abi.decode(["uint256"], data) returns the first 32-byte word of data and ignores the rest, and web3.py's contract.functions.f().call() goes through the same decoder. A getter that returns a struct or several values, read with a single-value ABI, therefore does not revert and does not raise: it hands back the first word as if it were the answer. It happened on 2026-09-20: getScore(address) returns the AuthorityScore struct, was read as uint256, gave adminKeyScore = 3 instead of compositeScore, and a verification "agreed" on the wrong number (data/rotation_audit_2026-09-20-robinhood-chain-index16.md, the finding that opened this task on the tracker). The same decoder is behind every scorer read in this repository, and a scorer reading an authority getter with an ABI that is too short would produce a confident wrong score.

What was added#

Result (score_all() at 2324848 plus this change, run in record mode)#

EcosystemEntries scoredABI decodes checkedSize or layout mismatchesEmpty-data probes
Robinhood Chain6228704
Ethereum L11916700
Arbitrum1012304
Base96000
Solana180 (no ABI)00
Hyperliquid1517 (see below)00
Tempo1493200
Zcash60 (no ABI)00
Plasma910300
Monad96900

1,758 ABI decodes checked, no silent truncation, no dropped value and no short answer on any path a live run takes today, every ecosystem scored completely (no skipped target). The eight empty-data occurrences are two records (four each, Robinhood Chain and Arbitrum) from one call site, scripts/lib/web3_utils.py:162, getOwners() on an address that has no code (a bare EOA probed to see whether it is a Safe): web3 raises BadFunctionCallOutput, the helper returns None, the caller treats the address as not a Safe. Nothing in the scorers takes an empty answer for a success.

Hyperliquid is prevention, not coverage: its reads go through chains/hyperliquid/scripts/methodology_test.py::_eth_call (a raw eth_call and hex slicing, with its own shape checks such as HyperEvmReadError), so 15 entries produce only 17 ABI decodes. The guard is installed there for future decodes; it does not check today's reads.

Neutrality (live, the same public reads on two trees)#

The guard changes a result only when an answer has a wrong non-empty size or layout, and the record-mode run above saw none, so it cannot change a score. Checked anyway: score_all() of every ecosystem run live on the tree at 2324848 (without the guard) and on the same tree with it, the six sub-scores of every target compared (runs/2026-09-20-scorer-fixes/live_diff.py, check_abi_neutrality.py), each ecosystem run on its own so that the public RPCs are not overloaded.

EcosystemTargets comparedSub-score differences
Robinhood Chain620
Ethereum L1190
Arbitrum100
Base90 (see below)
Solana180
Hyperliquid150
Tempo140
Zcash60
Plasma90
Monad90

One transient difference is kept here rather than hidden: in the first pass over Base, Aave V3 Base read crossExposureScore 80 on the tree without the guard and 100 with it (composite 71 on both). That value comes from a live read of the Aave guardian Safe on Arbitrum inside Base's own run, and a failed read makes the overlap unfound, which reads as 100. Six further one-at-a-time runs of Base (three per tree) and a rerun through the comparison tool all gave 80 on both trees and no difference; no refusal line of the guard was logged.

Two things a first, fully parallel attempt taught, kept because they matter beyond this change. Running twenty ecosystem runs at once against the public RPCs made reads fail at random: four Base targets differed between the two trees and, just as much, between two runs of the SAME tree (Uniswap V3 Factory on Base read 85 in one run and 38 in another, Morpho Blue 55 or 8), and a Robinhood target (Fables PoolRegistry) and five Hyperliquid entries were missing from a run. So a score computed while the public RPC is rate-limiting can silently be a floor instead of the real value: the helpers in scripts/lib/web3_utils.py turn every exception, a revert and a rate-limit alike, into None, and a persistent None degrades the target (their docstrings say the caller cannot tell the two apart). That is a separate robustness issue, tracked as its own card on the tracker.

Not claimed#

Not an audit, not investment advice. All notes ยท Back to the dashboard.