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}. failif the endpoint 404s (Blockscout has no verified source for this address) or ifis_verifiedisfalse.passifis_verifiedistrue- the fact includes the contract name and compiler version from the response.unknownif 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).
unknownif 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()viaeth_call, decodes the returned address, and: passif it's the zero address (ownership renounced).passif it's a contract (checked viaeth_getCodereturning non-empty) - consistent with a multisig or timelock, though this rule doesn't verify which.warnif it's an EOA (eth_getCodereturns empty) - a single private key still controls whateverowner()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.
unknownif unverified or Blockscout errors. passif no function namedmintappears in the ABI: fixed supply.warnif 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 notowner()also gates it. The fact notes whetherowner()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}foris_contract,creator_address_hash(the deployer), and the embeddedtoken.total_supply. unknownif 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).unknownif 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 verifiedUniswapV3Factoryat0x1f7d7550B1b028f7571E69A784071F0205FD2EfA. - Calls
getPool(token, WETH, fee)on the factory viaeth_callfor each of the three standard fee tiers - 0.05%, 0.3%, 1% - stopping at the first one found. failif none of the three tiers has a pool.- If a pool exists, calls
balanceOfon both the token and WETH contracts for the pool's address: passif both balances are nonzero - the pool is actually funded.warnif 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_callagainst 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.