#!/usr/bin/env python3 """Charter & risk checks for contract metadata (Musechain task 239). Deterministic, testable rules over a supplied ABI (and optional source text). Every finding carries severity, reason, exact function signature or source line, and a source label: "automated" (deterministic rule) vs "human_review" (the check cannot be decided from metadata alone). Rule set (all deterministic): R1 payable_entry ABI entry with stateMutability "payable" -> high finding. R2 value_bearing_src source line uses msg.value / .call{value:} / .transfer( / .send( -> warn. R3 unbounded_input state-changing fn with an unbounded dynamic input (bytes, string, T[], T[][]...) -> warn, names bounded vs unbounded. Fixed-size inputs (bytesN, T[k]) are explicitly reported as bounded (no finding). R4 restricted_access state-changing fn guarded by an access-control modifier (onlyOwner/onlyRole/... from source) -> info finding with the modifier; ABI-only runs report it as human_review (modifiers are not in the ABI). R5 missing_verify verification info absent -> info finding, explicitly NOT treated as proof of unsafe behavior. R6 selfdestruct_src source line contains selfdestruct / delegatecall -> high. Usage: python3 charter_risk_checks.py --abi abi.json [--source src.sol] [--verified] Output: JSON list of findings on stdout. """ import json import re import sys import argparse DYNAMIC_BASE = {"bytes", "string"} ACCESS_MODIFIERS = { "onlyowner", "onlyadmin", "onlyrole", "requiresauth", "authorized", "onlyguardian", "onlyoperator", "whennotpaused", "nonreentrant", } VALUE_PATTERNS = [ (r"\bmsg\.value\b", "reads msg.value"), (r"\.call\s*\{\s*value\s*:", "call with value"), (r"\.(transfer|send)\s*\(", "transfer/send call"), ] def signature(entry): name = entry.get("name", entry.get("type", "?")) inputs = ",".join(i.get("type", "?") for i in entry.get("inputs", [])) return f"{name}({inputs})" def is_dynamic(typ): if typ in DYNAMIC_BASE: return True if typ.endswith("[]"): return True return False def is_bounded(typ): # bytes32, uint256, address[5] etc. are bounded if typ in DYNAMIC_BASE: return False if typ.endswith("]"): inner = re.match(r"^(.*)\[(\d*)\]$", typ) if inner and inner.group(2) == "": return False return True return True def state_changing(entry): if entry.get("type") in ("constructor", "fallback", "receive"): return True if entry.get("type") != "function": return False return entry.get("stateMutability") not in ("view", "pure") def source_lines(source): return source.splitlines() if source else [] def check(abi, source=None, verified=False): findings = [] lines = source_lines(source) for entry in abi: etype = entry.get("type") if etype == "function": sig = signature(entry) elif etype in ("constructor", "fallback", "receive"): sig = f"{etype}({','.join(i.get('type','?') for i in entry.get('inputs',[]))})" else: continue # R1: payable entries (automated, deterministic from ABI) if entry.get("stateMutability") == "payable": findings.append({ "check": "R1 payable_entry", "severity": "high", "signature": sig, "reason": "Entry is payable; it can receive native value. " "Charter-restricted networks reject payable entries.", "evidence": "automated", }) # R3: unbounded dynamic inputs on state-changing functions if etype == "function" and state_changing(entry): for inp in entry.get("inputs", []): t = inp.get("type", "") if is_dynamic(t): findings.append({ "check": "R3 unbounded_input", "severity": "warn", "signature": sig, "reason": f"Parameter '{inp.get('name','?')}' has unbounded " f"dynamic type '{t}' on a state-changing function; " f"bounded types (bytesN, T[k]) carry no finding.", "evidence": "automated", }) # Source-based checks (deterministic regex; flagged human_review for context) for i, line in enumerate(lines, start=1): stripped = line.strip() # R2: value-bearing interfaces visible in source for pat, what in VALUE_PATTERNS: if re.search(pat, stripped): findings.append({ "check": "R2 value_bearing_src", "severity": "warn", "source_line": f"line {i}: {stripped[:120]}", "reason": f"Source {what}; confirm whether value can be " f"drained or misdirected.", "evidence": "automated_heuristic_human_review", }) break # R6: selfdestruct / delegatecall if re.search(r"\bselfdestruct\b", stripped): findings.append({ "check": "R6 selfdestruct_src", "severity": "high", "source_line": f"line {i}: {stripped[:120]}", "reason": "selfdestruct present; charter bans selfdestruct.", "evidence": "automated", }) if re.search(r"\bdelegatecall\b", stripped): findings.append({ "check": "R6 delegatecall_src", "severity": "high", "source_line": f"line {i}: {stripped[:120]}", "reason": "delegatecall present; review target contract and " "access control by a human.", "evidence": "automated", }) # R4: access-control modifiers on functions m = re.search(r"\bfunction\s+(\w+)\s*\([^)]*\)[^{]*\b(\w+)\s*\{", stripped) if m: fname, mod = m.group(1), m.group(2) if mod.lower() in ACCESS_MODIFIERS and mod.lower() != "nonreentrant": findings.append({ "check": "R4 restricted_access", "severity": "info", "source_line": f"line {i}: {stripped[:120]}", "reason": f"Function '{fname}' is guarded by access-control " f"modifier '{mod}'; verify the admin key holder.", "evidence": "automated_heuristic_human_review", }) # R5: missing verification — info only, never proof of unsafe behavior if not verified: findings.append({ "check": "R5 missing_verify", "severity": "info", "signature": None, "reason": "No verification info supplied with this metadata. " "Reported for completeness; absence of verification is " "NOT evidence of unsafe behavior.", "evidence": "automated", }) return findings def main(): ap = argparse.ArgumentParser() ap.add_argument("--abi", required=True) ap.add_argument("--source", default=None) ap.add_argument("--verified", action="store_true") args = ap.parse_args() abi = json.load(open(args.abi)) source = open(args.source).read() if args.source else None print(json.dumps(check(abi, source, args.verified), indent=2)) if __name__ == "__main__": main()