mizukara · reference

rule reference

What each check does, its verdict thresholds, and how to read the result.

The watchdog's rule engine (mizukara.watchdog.engine) runs a fixed list of pure functions against a ChainView pinned to one block height. Every rule has the same signature - run(chain_view, address) -> RuleResult - and every fact it produces cites the exact query (Blockscout endpoint + params, or RPC method + params) and a SHA-256 hash of the response that produced it. That's what makes mizukara scan <address> --at-block N reproducible: same block, same facts, byte-for-byte.

This is the complete list of checks that run in every scan today. Four more rules (R6–R9) are coming - see the end of this page.

Verdicts

Every rule returns one of four verdicts:

Verdict Meaning
pass Checked, and it's fine.
warn Checked, and there's a real but non-fatal risk factor.
fail Checked, and it's a genuine red flag.
unknown Couldn't be checked - source unverified, or the data source itself failed. Never treated as a finding.

unknown is not the same as "not checked." It means the rule ran, made a real request, and got back either a confirmed-absent result it can't reason about (e.g. no verified source to read an ABI from) or an upstream failure. See "Failure handling" below - this distinction matters and is enforced in code, not just convention.


R1 - source-verified

File: mizukara/watchdog/rules/r1_source_verified.py Checks: is the deployed bytecode's source published and verified on Blockscout?

  • Reads GET /api/v2/smart-contracts/{address}.
  • fail if the endpoint 404s (Blockscout has no verified source for this address) or if is_verified is false.
  • pass if is_verified is true - the fact includes the contract name and compiler version from the response.
  • unknown if Blockscout itself errors (5xx) rather than returning a clean 404.

This is the only rule where "no data" is a fail, not unknown - a missing verified source is itself the finding R1 exists to report.

R2 - ownership

File: mizukara/watchdog/rules/r2_ownership.py Checks: is there a privileged owner(), and if so, what kind?

  • Requires a verified ABI (same endpoint as R1). unknown if unverified or the Blockscout call errors.
  • If the ABI has no zero-argument owner() function: pass ("no Ownable-style privileged owner").
  • If it does: calls owner() via eth_call, decodes the returned address, and:
  • pass if it's the zero address (ownership renounced).
  • pass if it's a contract (checked via eth_getCode returning non-empty) - consistent with a multisig or timelock, though this rule doesn't verify which.
  • warn if it's an EOA (eth_getCode returns empty) - a single private key still controls whatever owner() gates.

Out of scope for this rule: proxy-admin ownership (a separate privileged role from owner(), e.g. in a UUPS/Transparent proxy) - that's covered by the upcoming R9.

R3 - mint authority

File: mizukara/watchdog/rules/r3_mint_authority.py Checks: is there a callable mint() that could increase supply after launch?

  • Requires a verified ABI. unknown if unverified or Blockscout errors.
  • pass if no function named mint appears in the ABI: fixed supply.
  • warn if one does. Blockscout's ABI only lists public/external members, so its presence means it's externally callable by someone - mintability itself is the finding, whether or not owner() also gates it. The fact notes whether owner() was also detected, as a secondary signal, but doesn't change the verdict.

Known limitation: this only matches a function literally named mint. A renamed or role-gated mint function (e.g. an AccessControl MINTER_ROLE calling a differently named function) won't be caught. Not addressed in this slice.

R4 - supply concentration

File: mizukara/watchdog/rules/r4_supply_concentration.py Checks: how much of the supply sits in a few wallets, and how much does the deployer itself hold?

  • Reads GET /api/v2/addresses/{address} for is_contract, creator_address_hash (the deployer), and the embedded token.total_supply.
  • unknown if the address isn't a contract, has no token data, or Blockscout errors.
  • Reads GET /api/v2/tokens/{address}/holders?items_count=20 (top 20 holders, sorted descending). unknown if this call errors.
  • Computes:
  • top-10 share = sum of the top 10 holders' balances / total supply.
  • deployer share = the deployer's balance (if found within the fetched top 20) / total supply. If the deployer isn't in the top 20, that's reported explicitly as uncertainty ("holds less than the smallest listed balance") - never assumed to be zero.
  • Verdict thresholds (constants in the module, tunable):
fail warn pass
top-10 share ≥ 70% ≥ 40% below both
deployer share ≥ 20% ≥ 10% below both

Either threshold being hit at the fail level fails the rule; either at warn (and neither at fail) warns.

Live example: a real token scanned during development (0x6CA3093c...Bf17777, SDOG) had 75.38% of supply in its top 10 holders - a genuine fail, not a synthetic test case.

R5 - liquidity

File: mizukara/watchdog/rules/r5_liquidity.py Checks: does a funded Uniswap V3 pool exist for this token against WETH?

  • Chain 4663 constants (confirmed live, not assumed): WETH at 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73, the verified UniswapV3Factory at 0x1f7d7550B1b028f7571E69A784071F0205FD2EfA.
  • Calls getPool(token, WETH, fee) on the factory via eth_call for each of the three standard fee tiers - 0.05%, 0.3%, 1% - stopping at the first one found.
  • fail if none of the three tiers has a pool.
  • If a pool exists, calls balanceOf on both the token and WETH contracts for the pool's address:
  • pass if both balances are nonzero - the pool is actually funded.
  • warn if a pool exists but isn't funded on both sides (created but never seeded, or fully drained).

Out of scope for this rule: LP position ownership and lock-duration analysis. Uniswap V3 liquidity positions are NFTs minted by whoever called NonfungiblePositionManager.mint, not a fungible LP token balance the way V2 works - position lock analysis is planned for a future release. R5 answers "is there liquidity," not "can it be pulled."

Live example: SHERWOOD (0xd5E0EC8C81479fcB521a5800CdF0eDDc49ea5B7F) has a real funded pool at the 1% tier - confirmed pass with actual on-chain reserve balances in the facts. SDOG has no pool at any tier - confirmed fail.


Coming next (R6–R9)

Four more rules are on the roadmap:

  • R6 honeypot simulation - simulate a buy-then-sell round trip via eth_call against the Uniswap router/quoter to detect tokens that can be bought but not sold, and estimate any transfer tax.
  • R7 deployer history - funding provenance (where did the deployer's ETH come from) and the deployer's prior deploys and their outcomes on chain 4663.
  • R8 permission drift - post-launch monitoring: did owner() change, did a mint happen after launch, did liquidity get pulled? Change over time, not a single scan.
  • R9 proxy hygiene - is the implementation contract verified, is the proxy admin timelocked?

Failure handling

Blockscout returns a 500 (not a clean 404) for some unverified addresses' /smart-contracts endpoint - observed live during development, not a hypothetical. ChainView.blockscout_get distinguishes this from a genuine 404 via Reading.error:

  • 404 → Reading(data=None, error=None) - confirmed absent, e.g. "not verified."
  • 5xx → Reading(data=None, error="blockscout returned 5xx") - we don't know.

R1-R4 check reading.error first and return unknown with the error message as the fact, rather than either crashing (the original bug) or silently treating an upstream outage as a real finding.