Notes / Findings

Scorer test audit, 2026-09-20: what executing the untested scorer bodies found

Findings2026-09-20Chain: Ethereum, Base, Solana, Plasma, Zcash

Source: data/finding_2026-09-20-scorer-test-audit.md in the repository, rendered here without changes. Source file

Phase: cross-ecosystem (Zcash, Solana, Ethereum L1, Base, Plasma). Changes two computations (both provably value-preserving on every published score) and pins the rest as tests. Nothing is re-pushed and no anchor changes.

Why#

Coverage measured over chains/ and scripts/ showed 19 scorer functions whose bodies no unit test executed, only their def line: score_marinade, score_solend_dao_governance, score_meteora_damm_v2, score_sparklend_pool, score_compound_v3_cusdc, score_uniswap_v4_pool_manager, score_wbtc, score_moonwell_comptroller_base, score_aerodrome_slipstream_clfactory, score_fluid_liquidity_plasma, and Zcash's score_all, score_ext_zec_omft, score_maya_asgard_vault, score_l1, cross_exposure, among others. They were checked only by live runs.

Result#

Statement coverage of chains/ and scripts/ went from 70.4% (2,303 of 7,789 statements not run) to 77.3% (1,771 not run). Each of the 19 targeted functions now has every statement executed. The suite went from 1,087 to 1,643 tests, all passing, with 20 expectedFailure tests that document the open findings below and 6 skips (tests that need a key file). After the arithmetic fixes, the Zcash ARO2 commitment still re-derives from a live score_all() run (attest_scores.py verify --live: MATCH).

Method#

One author per scorers file wrote a new offline test file with the chain reads patched. Expected values had to come from documented real observations or from hand arithmetic on the written formula, never from the function's own output. A separate verifier then sabotaged a COPY of each scorer (at least three realistic mutations per function: a threshold, a comparison, a swapped sub-score, a dropped degrade path, a deleted note) and checked that the tests noticed, and the author repaired the surviving mutants. Tests: scripts/lib/tests/test_{zcash,solana,ethereum_l1,base,plasma}_scorer_bodies.py.

FileTestsMutations triedKilled before repairNon-equivalent survivors before repair
test_zcash_scorer_bodies.py9617186%16
test_solana_scorer_bodies.py10812984%8
test_ethereum_l1_scorer_bodies.py15516690%4
test_base_scorer_bodies.py8811388%3
test_plasma_scorer_bodies.py1048277%15

The findings below come from the authoring pass. Every one is pinned by a test (a characterization test, or an expectedFailure test that asserts the documented behaviour). The code lines behind the Ethereum L1 timelock and multisig items, Fluid's multisig = 100, Base's unconditional forwarder note and the two fixed items were re-read by the maintainer's session; the rest is as reported by the authors and has not been independently re-derived.

Fixed in this change#

Open: possible bugs (mostly degraded or dormant paths; live published scores reproduce)#

EcosystemFunctionFinding
zcashscore_ext_zec_omftFixed (#109: the fallback is now 5/16, composite 7, below the confirmed 1-of-3 path). The degraded fallback when no TokenDepositer path resolves (admin_key, multisig = 20, 20; composite 14) scores ABOVE the live-confirmed weakest real path (1-of-3 Requestor group = 5/18, composite 7). Documented as a dormant inversion; only dormant because the live read succeeds every run. Kept as an expectedFailure test asserting the documented invariant (fallback <= 5/18), plus a separate test pinning today's 20/20.
solanascore_marinade / _resolve_legacy_serum_multisig (same gap in _resolve_squads_v3 used by score_meteora_damm_v2)Fixed (#109: Meteora DAMM v2 and Marinade now score None as renounced, the safest band). A renounced (None) program upgrade authority is treated as a failed read and scored 20/20/0 with a 'MISMATCH, degrading' note, instead of the safest band 100/100/100 that METHODOLOGY.md 6.1 assigns to None. The same 2026-09-17 bug (Jupiter v6, Squads v4 via none_means_renounced=True) was fixed there but not for the legacy-multisig and Squads v3 helpers. Dormant today (Marinade's authority is 551FBX..., not None; in Meteora the hardcoded ADMINS[] 5/0/0 masks it). For Marinade the documented value would be combined 70/69/0, composite 49, versus 20/20/0, composite 14 computed. Test kept asserting the documented value and marked expectedFailure. Production change needed if desired: add a ...
ethereum-l1score_uniswap_v4_pool_managerFixed (#111: a delay of at least 172800 s is now required, a project decision matching Compound and SparkLend, METHODOLOGY only says 60-75 by length). timelockScore is 75 for ANY delay() that reads, even 0 or 1 hour. The docstring quotes 'delay = MINIMUM_DELAY = 2 days' and METHODOLOGY.md says only a confirmed real delay scores 60-75, and the sibling scorers score_compound_v3_cusdc and score_sparklend_pool require delay >= 172800. score_uniswap_v3_factory has the same 'delay is not None' rule. Pinned by a characterisation test so a fix is a deliberate test edit.
ethereum-l1score_uniswap_v4_pool_managerFixed (#111). Timelock credit is not gated on PoolManager.owner() being the known Uniswap Timelock. If owner() were a different contract that answers delay(), admin() and quorumVotes(), adminKeyScore drops to 20 with the note 'owner is not the known Timelock' yet timelockScore stays 75, because delay is read from whatever address owner() returned.
ethereum-l1score_sparklend_poolFixed (#111: credit now needs a resolved root and a read FreezerMom ward). timelockScore 60 is credited even when the SparkProxy -> MCD_PAUSE_PROXY -> MCD_PAUSE chain did NOT resolve, because delay() is read from the hardcoded MCD_PAUSE constant, not through the resolved chain. With PoolAddressesProvider.owner() replaced by an EOA, or MCD_PAUSE_PROXY.owner() pointing elsewhere, the scorer publishes admin 20 with the 2-day Sky delay still credited (composite 56).
ethereum-l1score_compound_v3_cusdcFixed (#111 for Compound, SparkLend and V4, which now score multisig 20 when the root is not confirmed, and #110 for Fluid. Still open in score_uniswap_v3_factory and score_makerdao_sky_pause, which keep the old rule). multisigScore is hardcoded 100 ('not applicable, root is a DAO') in the degraded branches of score_compound_v3_cusdc, score_sparklend_pool and score_uniswap_v4_pool_manager, so a run where nothing resolves still publishes composite 38, whereas score_wbtc's unresolved floor is 10/20/0 composite 10 and Ethena/Morpho floors also lower multisig. METHODOLOGY.md reserves 100 for 'the root is a real, active DAO'; an unresolved root is not confirmed to be one.
ethereum-l1score_compound_v3_cusdcThe upgrade-path convergence check uses HARDCODED CometProxyAdmin and Configurator addresses. The docstring says the Comet proxy's EIP-1967 admin slot 'resolves to CometProxyAdmin 0x1EC63B58...', but the scorer never reads that slot, so a proxy-admin swap on the Comet proxy would go unnoticed and the gate would keep reading the old ProxyAdmin. The Configurator is likewise not tied to the Comet by any read.
base-ecosystem_uniswap_direct_forwarder_scoreFixed (#109: the note is now emitted only when both addresses are compared and equal). The note 'Same forwarder and same L1 Governance Timelock as the already-tracked Uniswap V3 Factory (Base): one DAO root for V2/V3/V4 on Base' is appended unconditionally. Nothing compares the forwarder or the L1 timelock with the V3 Factory's (0x31FAfd48...72A9 / 0x1a9C8182...35BC). A different forwarder with a different L1 timelock passes every gate (slot 0 predeploy, resolved delay and admin) and gets the confident 80/100/75/85 with the note still asserting sameness. Scores are not affected, but published evidence text asserts a fact the code never checked, the same defect class already fixed on 2026-09-18 for Aerodrome's 'zero owner overlap' note.
base-ecosystemscore_aerodrome_slipstream_clfactoryA non-Safe owner() is scored with the 'unresolved contract' floor (adminKey 20, composite 8), whatever it is. METHODOLOGY.md says a bare EOA scores 2-10 and 20 is only for an unresolved contract. The scorer never reads bytecode, so an owner rotated to a bare EOA would score better (20) than the methodology allows. Same pattern in score_morpho_blue (lines 138-140) and score_aerodrome_poolfactory (lines 351-355). No published score is affected today (owner is a real 3-of-7 Safe).
plasma-ecosystemscore_fluid_liquidity_plasmaFixed (#110: unresolved admin now scores 20/0/0). multisigScore is hard-coded to 100 for every outcome, including an unreadable getAdmin(). A transient RPC failure or revert on getAdmin() therefore scores (adminKey 20, multisig 100, timelock 0), composite 38, instead of the ecosystem's unresolved floor (20, 0, 0), composite 8, while the note calls it a 'conservative score'.
plasma-ecosystemscore_fluid_liquidity_plasmaFixed (#110: bare EOA 5/0/0, plain contract 20/0/0, Safe by the METHODOLOGY formula). The same unconditional multisig=100 (and adminKey 20) is used when getAdmin() is a bare EOA, a plain contract, or a real Gnosis Safe, contradicting METHODOLOGY. A bare EOA gets adminKey 20 (documented band 2-10) and multisig 100 (documented 0); a 3-of-5 Safe gets multisig 100 (formula gives 55) and adminKey 20 (Aquila's 3-of-4 Safe branch in the same file scores 50).

Open: documentation that no longer matches the code#

Open: oddities (behaviour worth knowing, pinned by tests)#

What to do with them#

Status after the follow-up fixes#

The same day the marked items above were fixed through maker-checker (#109 Zcash, Solana, Base, #110 Plasma, #111 Ethereum L1), each with independent verification on fresh clones: the suite green, every scorer hunk sabotaged on its own and caught by a test (the only survivors are a docstring and pure refactors), and the live scores of 52 targets across 5 ecosystems identical before and after, so no oracle needs a re-push and no anchor changes. Left open on purpose: the two expectedFailure tests (Solana council weight versus signers, Plasma delay tiers), the Ethereum scorers' inability to tell a bare-EOA root from an unresolved one (both get multisig 20 where METHODOLOGY gives 0 for a known EOA), V3 and MakerDAO still on the old multisig and timelock rules, and everything not marked above.

What to do with the rest#

Everything unmarked is still open, because each is a scoring or wording decision rather than a bug with one right answer (for example, what floor an unresolved root should get, or whether a delay under a day earns timelock credit). Each pinned test says which decision it encodes; when a decision is taken, editing that test is the deliberate step, and an expectedFailure turning into an unexpected success shows a fix landed. The natural route is the normal one: a scorer change proposed through maker-checker with independent verification.

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