#!/usr/bin/env python3
"""solidity_prescan.py — deterministic Solidity/EVM contract pre-scan.

ARION service artifact. Scans .sol source for the shapes audit findings
are made of on EVM chains: unguarded public entry points that move the
contract's funds or mutate state, unguarded minting, low-level call
reentrancy (checks-effects-interactions violations), discarded call
results, delegatecall to attacker-influenced targets, selfdestruct,
tx.origin auth, block-value randomness, spot-price oracle reads,
unprotected initializers, max allowances, forced-ether balance
equality, bare ecrecover replay surface, and committed key material.

Function-aware: contract bodies are extracted by brace depth (comments
and string contents blanked first), so a function is judged on its
whole body — including whether an access gate (an auth-named or
sender-checking modifier, or a require/if-revert on msg.sender) exists
anywhere in it. Modifiers defined in-file participate in a fixpoint:
a modifier that calls another gating modifier counts as a gate.

Findings (base severity):
  SOL-TRANSFER-NOAUTH HIGH    public/external fn moves funds that are
                             not the caller's — .call{value:}, .send,
                             .transfer, safeTransfer(From), or
                             transferFrom whose source is not
                             msg.sender/_msgSender() — with no gate
                             and no caller identity anywhere in body
                      MEDIUM  same shape on a caller-referencing path —
                             the caller's identity is in play but no
                             gate was found; verify the check lives
                             upstream
  SOL-MINT-NOAUTH     HIGH    public/external fn contains _mint/mint
                             with no gate — unguarded supply
  SOL-STATE-NOAUTH    MEDIUM  public/external fn writes state
                             (assignment/delete/push on a contract-
                             level variable) with no gate and no
                             caller identity and no value op (a gated
                             value op flags under TRANSFER-NOAUTH)
                      LOW     same write on a caller-referencing path
  SOL-REENTRANCY      HIGH    a .call / .call{value:} (any-call) sits
                             before a state write in the same fn —
                             checks-effects-interactions violated; the
                             callee can re-enter before state settles
                      MEDIUM  .send/.transfer before a state write —
                             gas-forwarding historically failed to
                             protect; still the wrong order
                      (silent)  a reentrancy-guard modifier
                             (nonReentrant/lock/mutex names or a
                             mutex-shaped modifier defined in-file)
                             is present on the fn
  SOL-DELEGATECALL    HIGH    delegatecall target is a caller-supplied
                             arg — arbitrary code in this context
                      MEDIUM  target resolves to a state variable —
                             re-pointable by contract logic
                      LOW     implementation()-style pinned accessor
  SOL-SELFDESTRUCT    MEDIUM  selfdestruct present — force-feeds
                             balance invariants and is deprecated
  SOL-TXORIGIN        HIGH    tx.origin in a require/if/assert or ==
                             comparison — phishable auth
                      LOW     tx.origin present but not gating
  SOL-UNCHECKED-CALL  MEDIUM  bare .call/.send/.delegatecall statement
                             whose result is discarded — a failed call
                             reads as success downstream
                      LOW     (bool ok,) captured but never read
  SOL-BLOCK-RAND      MEDIUM  block.timestamp/number/prevrandao/
                             blockhash feeding keccak256/mod/random/
                             winner selection — miner-influenceable
  SOL-SPOT-PRICE      MEDIUM  getReserves/slot0/balanceOf(this)/kLast
                             used on a price/swap/quote path — single-
                             block spot reads are flash-manipulable
  SOL-INIT-UNGUARDED  MEDIUM  public/external initialize()/init() with
                             no initializer modifier and no gate —
                             uninitialized-proxy takeover
  SOL-MAX-APPROVE     LOW     approve/increaseAllowance with a
                             max-uint literal — unlimited allowance
  SOL-BALANCE-EQ      MEDIUM  address(this).balance compared with
                             ==/!= — selfdestruct/force-feeding
                             breaks exact equality
  SOL-ECRECOVER       LOW     ecrecover present — signature replay /
                             malleability surface; verify nonce,
                             deadline and chain binding
  SOL-SECRET          HIGH    secret-named variable holds a long hex
                             literal — committed key material

Context rules:
  * //, /* */ comments stripped; string contents blanked so prose
    cannot trigger signatures and braces cannot unbalance a body
  * internal/private functions never flag for missing gates — they are
    not entry points (they still count as helper targets)
  * view/pure functions never flag — they cannot move value or state
  * a fn whose body references msg.sender/_msgSender(), or whose spec
    carries an auth-named or sender-checking modifier, is treated as
    gated — a pre-scan cannot prove the check is the right one
  * test/spec/fixture paths downgrade one tier
  * absence is not evidence: an unflagged contract can still be wrong —
    economics, cross-contract trust, and compiler-version semantics
    live outside this file's reach

Scope: deterministic pre-scan for human review — flags the shapes
worth a second look before a full audit; clean output != a safe
contract.

  solidity_prescan.py <file-or-dir> [--json]
  solidity_prescan.py --selftest
Stdlib only. Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage.
"""
import json
import os
import re
import sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
DOWN = {"HIGH": "MEDIUM", "MEDIUM": "LOW", "LOW": "INFO", "INFO": "INFO"}

SKIP_DIRS = {
    "node_modules", ".git", "vendor", "dist", "build", "target",
    "__pycache__", ".venv", "venv", "coverage", ".next", "out",
    "cache", "artifacts", "lib", "dependencies", "broadcast",
    "forge-std", "openzeppelin-contracts", "solmate",
}

TEST_PATH = re.compile(
    r"(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|__mocks__|"
    r"e2e|forge|script)(/|$)|([._-]test|\.t)\.sol$",
    re.I)

# --- lexical ----------------------------------------------------------------

def strip_sol(src):
    """Blank comments and string contents, preserving positions and
    newlines so line numbers and brace depth stay truthful."""
    out = []
    i, n = 0, len(src)
    while i < n:
        c = src[i]
        if c == "/" and i + 1 < n and src[i + 1] == "/":
            while i < n and src[i] != "\n":
                i += 1
        elif c == "/" and i + 1 < n and src[i + 1] == "*":
            i += 2
            while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"):
                out.append("\n" if src[i] == "\n" else " ")
                i += 1
            i += 2 if i + 1 < n else 0
        elif c in "\"'":
            q = c
            out.append(c)
            i += 1
            while i < n and src[i] != q:
                if src[i] == "\\" and i + 1 < n:
                    out.append(" ")
                    i += 1
                if i < n:
                    out.append("\n" if src[i] == "\n" else " ")
                    i += 1
            if i < n:
                out.append(c)
                i += 1
        else:
            out.append(c)
            i += 1
    return "".join(out)


def block_end(src, i):
    """src[i] == '{' — return index just past its balanced close."""
    depth = 0
    for j in range(i, len(src)):
        if src[j] == "{":
            depth += 1
        elif src[j] == "}":
            depth -= 1
            if depth == 0:
                return j + 1
    return len(src)


# --- contract / member extraction -------------------------------------------

CONTRACT_RE = re.compile(
    r"\b(?:abstract\s+contract|contract|library|interface)\s+"
    r"([A-Za-z_]\w*)[^{;]*\{")
FUNC_RE = re.compile(
    r"\bfunction\s+([A-Za-z_]\w*)?\s*\(([^)]*)\)\s*([^{;]*)")
MOD_RE = re.compile(r"\bmodifier\s+([A-Za-z_]\w*)\s*(?:\([^)]*\))?\s*\{")
RECV_RE = re.compile(
    r"\b(receive|fallback)\s*(\([^)]*\))?\s+([A-Za-z_][^{;]*)\{")
CTOR_RE = re.compile(r"\bconstructor\s*\(([^)]*)\)\s*([^{;]*)\{")

VIS_WORDS = {"external", "public", "internal", "private"}
MUT_WORDS = {"view", "pure", "payable", "virtual", "override"}
SPEC_SKIP = VIS_WORDS | MUT_WORDS | {"returns", "memory", "calldata",
                                     "storage", "immutable"}

DECL_SKIP = re.compile(
    r"^\s*(?:function|modifier|event|error|struct|enum|using|mapping"
    r"\s*\(.*=>\s*.*\)\s+is|constructor|import|pragma|type|for|if|"
    r"return|emit|revert|require|assert|while|do|unchecked|assembly|"
    r"else|try|catch|receive|fallback)\b")

IDENT = r"[A-Za-z_]\w*"


DECL_NAMES_SKIP = SPEC_SKIP | {
    "mapping", "constant", "payable", "msg", "block", "tx", "abi",
    "type", "now", "require", "assert", "revert", "keccak256",
    "ecrecover", "true", "false", "wei", "ether", "seconds", "minutes",
    "hours", "days", "weeks", "years"}


def decl_names(stmt):
    """Declared identifier names from a depth-1 contract statement like
    `uint256 public count;` / `mapping(...) balances;` /
    `IERC20 public token = IERC20(0x..);` / `uint a, b = 1;`."""
    stmt = stmt.strip()
    if not stmt or DECL_SKIP.match(stmt):
        return []
    # paren groups first (mapping(...) => ...), then the declarator
    # side before `=` — initializer names are not declarations
    left = re.sub(r"\([^)]*\)", " ", stmt).split("=", 1)[0]
    names = []
    for piece in left.split(","):
        m = re.findall(IDENT, piece)
        if m:
            names.append(m[-1])        # last ident = the variable name
    return [n for n in names if n not in DECL_NAMES_SKIP]


def spec_mods(spec):
    """Modifier/identifier tokens in a function spec that are not plain
    keywords — `public onlyOwner returns (uint)` -> {'onlyOwner'}.
    Paren groups are dropped first so `override(ERC20)` and
    `returns (uint)` cannot mint fake modifier names."""
    spec = re.sub(r"\([^)]*\)", " ", spec)
    out = set()
    for t in re.findall(IDENT, spec):
        if t not in SPEC_SKIP and t != "is":
            out.add(t)
    return out


def arg_names(sig):
    """Parameter names from a signature arg list — `uint256 amount,
    address payable to` -> {amount, to}."""
    out = set()
    for piece in sig.split(","):
        idents = re.findall(IDENT, piece)
        if len(idents) >= 2:
            out.add(idents[-1])
    return out


def line_of(src, pos):
    return src.count("\n", 0, pos) + 1


# --- per-function vocabulary -------------------------------------------------

ETH_CALL = re.compile(r"\.call\s*(?:\{[^}]*\})?\s*\(|\.send\s*\(|"
                      r"\.transfer\s*\(")
ANY_CALL = re.compile(
    r"\.call\s*\{[^}]*value\s*:|\.call\s*(?:\{[^}]*\})?\s*\(")
GAS_CALL = re.compile(r"\.send\s*\(|\.transfer\s*\(")
LOW_CALL_STMT = re.compile(
    r"\.call\s*(?:\{[^}]*\})?\s*\(|\.send\s*\(|\.delegatecall\s*\(")
VALUE_OP = re.compile(
    r"\.call\s*\{[^}]*value\s*:|\.send\s*\(|\.transfer\s*\(|"
    r"\.transferFrom\s*\(|\.safeTransfer(?:From)?\s*\(")
MINT_OP = re.compile(r"(?<![\w.])(?:_mint|mint)\s*\(")
DELEGATE = re.compile(
    r"([A-Za-z_]\w*(?:\.\w+)*(?:\(\s*\))?)\s*\.delegatecall\s*\(|"
    r"(?<![\w.])delegatecall\s*\(")
SELFDESTRUCT = re.compile(r"(?<![\w.])selfdestruct\s*\(")
SENDER = re.compile(r"msg\.sender|_msgSender\s*\(")
# signature-authenticated fns never read msg.sender — the signed
# payload is the gate (permit, meta-tx, multisig exec)
SIG_AUTH = re.compile(
    r"ecrecover\s*\(|isValidSignature|checkNSignatures|"
    r"checkSignatures|ECDSA\.recover|_verifySignature")
GATE_BODY = re.compile(
    r"require\s*\((?:[^;()]|\([^()]*\))*?(?:msg\.sender|_msgSender)|"
    r"assert\s*\((?:[^;()]|\([^()]*\))*?(?:msg\.sender|_msgSender)|"
    r"if\s*\((?:[^;()]|\([^()]*\))*?(?:msg\.sender|_msgSender)|"
    r"_checkRole\s*\(|_checkOwner\s*\(|hasRole\s*\(|"
    r"_authorizeUpgrade\s*\(")
AUTH_MOD_NAME = re.compile(
    r"only[A-Z]|auth|admin|role|permit|allow|whitelist|restrict|"
    r"operator|minter|pauser|initializ", re.I)
GUARD_MOD_NAME = re.compile(r"nonReentrant|lock|mutex|reentrancy", re.I)
DELEGATE_IMPL = re.compile(r"implementation|getImplementation|_impl")

BLOCK_SRC = re.compile(
    r"block\.(?:timestamp|number|difficulty|prevrandao|coinbase)|"
    r"blockhash\s*\(")
RAND_SINK = re.compile(
    r"keccak256|random|rand|seed|winner|lottery|pick|shuffle|"
    r"%\s*(?!\s*2\s*\*\*\s*\d)", re.I)

SPOT_API = re.compile(
    r"\.getReserves\s*\(|\.slot0\s*\(|\.kLast\s*\(|"
    r"\.balanceOf\s*\(\s*address\s*\(\s*this\s*\)\s*\)")
PRICE_CTX = re.compile(
    r"\b(?:price|amountOut|getAmountOut|swap|exchangeRate|oracle|"
    r"quote|rate|twap)\b", re.I)

MAX_UINT = re.compile(
    r"type\s*\(\s*uint(?:256)?\s*\)\s*\.max|"
    r"2\s*\*\*\s*256|uint(?:256|8|16|32|64|128)?\s*\(\s*-1\s*\)|"
    r"MAX_UINT|11579208923731619542357")
APPROVE = re.compile(r"\b(?:approve|increaseAllowance)\s*\(")

BALANCE_EQ = re.compile(
    r"address\s*\(\s*this\s*\)\s*\.balance\s*(?:==|!=)|"
    r"(?:==|!=)\s*address\s*\(\s*this\s*\)\s*\.balance|"
    r"balanceOf\s*\(\s*address\s*\(\s*this\s*\)\s*\)\s*(?:==|!=)")

ECRECOVER = re.compile(r"(?<![\w.])ecrecover\s*\(")
TXORIGIN = re.compile(r"tx\.origin")
TXO_AUTH = re.compile(
    r"require\s*\((?:[^;()]|\([^()]*\))*?tx\.origin|"
    r"if\s*\((?:[^;()]|\([^()]*\))*?tx\.origin|"
    r"assert\s*\((?:[^;()]|\([^()]*\))*?tx\.origin|"
    r"(?:==|!=)\s*tx\.origin|tx\.origin\s*(?:==|!=)")

SECRET_DECL = re.compile(
    r"(?:private|secret|seed|mnemonic|key|signing)[\w]*"
    r"\s*(?:=\s*)(0x[0-9a-fA-F]{40,}|\d{40,})", re.I)
PUB_NAME = re.compile(r"pub|public|address|sender|recipient|owner|"
                      r"admin|deployer|signer$|signers", re.I)

CHECKED_LEAD = re.compile(
    r"^\s*(?:require|assert|if|else|revert|return|emit|try|"
    r"unchecked|assembly|bool|uint|int|address|bytes|string|\(|"
    r"delete|for|while|do|break|continue)")


class Fn:
    __slots__ = ("kind", "name", "args", "spec", "body", "line",
                 "external", "mutating", "mods")

    def __init__(self, kind, name, args, spec, body, line):
        self.kind = kind
        self.name = name
        self.args = arg_names(args or "")
        self.spec = spec or ""
        self.body = body
        self.line = line
        toks = set(re.findall(IDENT, spec or ""))
        self.external = ("external" in toks or "public" in toks or
                         kind in ("receive", "fallback"))
        self.mutating = not ("view" in toks or "pure" in toks)
        self.mods = spec_mods(spec or "")


def extract_members(src):
    """Return (fns, modifiers, state_vars). src must be cleaned.

    Scans each contract/library/interface body at member depth: function
    and receive/fallback bodies become Fn records, modifier bodies are
    kept for the gate fixpoint, and `;`-terminated member statements
    yield state-variable names. State vars merge across contracts in a
    file — a pre-scan accepts the small cross-contract overlap."""
    fns, mods, state_vars = [], {}, set()

    for cm_ in CONTRACT_RE.finditer(src):
        brace = src.find("{", cm_.end() - 1)
        if brace < 0:
            continue
        cend = block_end(src, brace)
        i, end, depth = brace + 1, cend - 1, 0
        while i < end:
            c = src[i]
            if c == "{":
                depth += 1
                i += 1
                continue
            if c == "}":
                depth -= 1
                i += 1
                continue
            if depth != 0 or c.isspace():
                i += 1
                continue
            if CTOR_RE.match(src, i):
                i = block_end(src, src.find("{", i))
                continue
            fm = FUNC_RE.match(src, i)
            if fm:
                if src[fm.end()] == "{":
                    en = block_end(src, fm.end())
                    fns.append(Fn("function", fm.group(1) or "anonymous",
                                  fm.group(2), fm.group(3),
                                  src[fm.end():en], line_of(src, i)))
                    i = en
                else:
                    i = fm.end() + 1     # interface declaration `;`
                continue
            rm = RECV_RE.match(src, i)
            if rm:
                j = src.find("{", i)
                en = block_end(src, j)
                fns.append(Fn(rm.group(1), rm.group(1), "",
                              (rm.group(3) or "") + " external",
                              src[j:en], line_of(src, i)))
                i = en
                continue
            mm = MOD_RE.match(src, i)
            if mm:
                j = src.find("{", i)
                en = block_end(src, j)
                mods[mm.group(1)] = src[j:en]
                i = en
                continue
            # member statement — declaration or braced type body
            j = i
            while j < end and src[j] not in ";{}":
                j += 1
            if j >= end:
                break
            if src[j] == ";":
                for nm in decl_names(src[i:j]):
                    state_vars.add(nm)
                i = j + 1
            elif src[j] == "{":
                i = block_end(src, j)    # struct/enum body — skip
            else:
                i = j + 1
    return fns, mods, state_vars


def state_writes(body, state_vars):
    """Positions of writes to state variables in a fn body."""
    out = []
    for v in state_vars:
        pat = re.compile(
            r"(?<![\w.])" + re.escape(v) +
            r"(?:\s*\[[^\]]*\]|\s*\.\s*" + IDENT + r")*"
            r"\s*(?:=(?!=)|\+=|-=|\*=|/=|\+\+|--)")
        out += [m.start() for m in pat.finditer(body)]
        out += [m.start() for m in re.finditer(
            r"(?<![\w.])delete\s+" + re.escape(v) + r"\b", body)]
        out += [m.start() for m in re.finditer(
            r"(?<![\w.])" + re.escape(v) + r"\s*\.\s*(?:push|pop)\s*\(",
            body)]
    return sorted(out)


def transfer_danger(body, fn):
    """True when a value op moves funds that are not the caller's."""
    for m in VALUE_OP.finditer(body):
        s = m.group(0)
        if "transferFrom" in s or "safeTransferFrom" in s:
            # source arg must be the caller to be self-authorized
            j = body.find("(", m.start())
            if j >= 0:
                e = block_paren(body, j)
                first = body[j + 1:e].split(",", 1)[0]
                if re.search(r"msg\.sender|_msgSender", first):
                    continue
            return True
        else:
            return True
    return False


def block_paren(src, i):
    """src[i] == '(' — index just past balanced close."""
    depth = 0
    for j in range(i, len(src)):
        if src[j] == "(":
            depth += 1
        elif src[j] == ")":
            depth -= 1
            if depth == 0:
                return j + 1
    return len(src)


def sender_locals(body):
    """Local variable names whose initializer reads caller-scoped
    state — `uint bal = balances[msg.sender]` makes `bal` a
    caller-scoped name; a require() on it is a caller gate."""
    out = set()
    for m in re.finditer(
            r"(?:" + IDENT + r"\s+)+(" + IDENT + r")\s*=\s*"
            r"[^;]*?(?:msg\.sender|_msgSender)", body):
        out.add(m.group(1))
    return out


def gate_re(names):
    return re.compile(
        r"require\s*\((?:[^;()]|\([^()]*\))*?\b(?:" + names + r")\b|"
        r"assert\s*\((?:[^;()]|\([^()]*\))*?\b(?:" + names + r")\b|"
        r"if\s*\((?:[^;()]|\([^()]*\))*?\b(?:" + names + r")\b")


def stmt_around(src, pos):
    """The statement (or clause) containing src[pos] — bounded by the
    nearest ; { } on each side."""
    s = max(src.rfind(";", 0, pos), src.rfind("{", 0, pos),
            src.rfind("}", 0, pos)) + 1
    e = len(src)
    for c in (";", "}"):
        j = src.find(c, pos)
        if 0 <= j < e:
            e = j
    return src[s:e]


def unchecked_calls(body, fn_line):
    """(medium_at, low_at) statement positions of discarded low-level
    call results."""
    med, low = [], []
    stmts = re.split(r";", body)
    pos = 0
    for st in stmts:
        st_start = body.find(st, pos)
        pos = st_start + len(st)
        if not LOW_CALL_STMT.search(st):
            continue
        lead = re.sub(r"^[\s{}]*", "", st)
        if CHECKED_LEAD.match(lead):
            # `(bool ok,) = addr.call(...)` — captured; flag only when
            # the captured flag is never read again in the body
            bm = re.search(r"bool\s+(" + IDENT + r")", st)
            if "=" in st and bm:
                name = bm.group(1)
                rest = body[st_start + len(st):]
                if not re.search(r"(?<![\w])" + re.escape(name) +
                                 r"(?![\w])", rest):
                    low.append(st_start)
            continue
        if re.search(r"(?<![\w])require\s*\(|=(?!=)|if\s*\(|return\b|"
                     r"emit\b|assert\s*\(", st):
            continue
        med.append(st_start)
    return med, low


def scan_sol(path, rel, raw):
    findings = []
    is_test = bool(TEST_PATH.search(rel.replace(os.sep, "/")))

    def emit(sev, code, ln, msg):
        if is_test:
            sev = DOWN[sev]
        findings.append(Finding(sev, code, rel, ln, msg))

    cleaned = strip_sol(raw)
    fns, mods, state_vars = extract_members(cleaned)

    # file-level committed key material
    for m in SECRET_DECL.finditer(cleaned):
        seg_start = cleaned.rfind("\n", 0, m.start()) + 1
        seg = cleaned[seg_start:m.start()]
        if PUB_NAME.search(seg):
            continue
        emit("HIGH", "SOL-SECRET", line_of(cleaned, m.start()),
             "secret-shaped declaration holds a long literal — "
             "committed key material")

    # modifier fixpoint: a modifier is a gate when it (a) names-auth
    # or checks the sender in its body, or (b) invokes a known-gating
    # modifier — auth helpers delegate a hop or two
    gate_mods = set()
    for _ in range(3):
        grown = False
        for name, mbody in mods.items():
            if name in gate_mods:
                continue
            if (AUTH_MOD_NAME.search(name) and
                    (SENDER.search(mbody) or "require" in mbody or
                     "revert" in mbody)) or \
                    any(re.search(r"(?<![\w])" + re.escape(g) +
                                  r"(?![\w])", mbody)
                        for g in gate_mods):
                gate_mods.add(name)
                grown = True
        if not grown:
            break
    guard_mods = {n for n in mods if GUARD_MOD_NAME.search(n)}

    for fn in fns:
        if fn.kind not in ("function", "receive", "fallback"):
            continue
        body = fn.body
        mods_used = fn.mods
        authed = bool(mods_used & gate_mods) or \
            any(AUTH_MOD_NAME.search(m) for m in mods_used) or \
            bool(GATE_BODY.search(body)) or \
            (bool(SIG_AUTH.search(body)) and
             bool(re.search(r"require\s*\(|revert\b", body)))
        if not authed:
            sl = sender_locals(body)
            if sl and gate_re("|".join(map(re.escape, sl))).search(body):
                authed = True
        guarded = bool(mods_used & guard_mods) or \
            any(GUARD_MOD_NAME.search(m) for m in mods_used)
        caller_seen = bool(SENDER.search(body))
        has_mint = bool(MINT_OP.search(body))
        has_value = transfer_danger(body, fn)
        writes = state_writes(body, state_vars)

        if fn.external and fn.mutating:
            if has_mint and not authed:
                emit("MEDIUM" if (caller_seen or mods_used) else "HIGH",
                     "SOL-MINT-NOAUTH", fn.line,
                     "%s `%s` mints with no caller gate — unguarded "
                     "supply%s" % (fn.kind, fn.name,
                     "" if not (caller_seen or mods_used) else
                     "; modifiers or caller identity are present — "
                     "verify this mint is meant to be permissionless"))
            if has_value and not authed:
                emit("HIGH" if not caller_seen else "MEDIUM",
                     "SOL-TRANSFER-NOAUTH", fn.line,
                     "%s `%s` moves funds that are not the "
                     "caller's %s" % (fn.kind, fn.name,
                     "with no caller identity anywhere in the body"
                     if not caller_seen else
                     "on a caller-referencing path with no gate — "
                     "verify the caller check lives upstream"))
            if writes and not authed and not (has_value or has_mint):
                emit("MEDIUM" if not caller_seen else "LOW",
                     "SOL-STATE-NOAUTH", fn.line,
                     "%s `%s` writes contract state %s"
                     % (fn.kind, fn.name,
                        "with no caller identity or gate in the body"
                        if not caller_seen else
                        "on a caller-referencing path with no "
                        "explicit gate — likely caller-scoped; verify"))

        # reentrancy: any-call or gas-forwarding call before a state
        # write in the same fn; a guard modifier silences it
        if writes and not guarded:
            first_any = next((m.start() for m in
                              ANY_CALL.finditer(body)), None)
            first_gas = next((m.start() for m in
                              GAS_CALL.finditer(body)), None)
            later_write = next((w for w in writes
                                if w > (first_any or first_gas or -1)),
                               None)
            if first_any is not None and later_write is not None and \
                    first_any < later_write:
                emit("HIGH", "SOL-REENTRANCY",
                     fn.line + body[:first_any].count("\n"),
                     "fn `%s`: low-level .call precedes a state write "
                     "— checks-effects-interactions violated; the "
                     "callee re-enters before state settles" % fn.name)
            elif first_gas is not None and later_write is not None and \
                    first_gas < later_write:
                emit("MEDIUM", "SOL-REENTRANCY",
                     fn.line + body[:first_gas].count("\n"),
                     "fn `%s`: .send/.transfer precedes a state write "
                     "— reorder effects before the call" % fn.name)

        # delegatecall
        for dm in DELEGATE.finditer(body):
            target = (dm.group(1) or "").split(".")[-1]
            ln = fn.line + body[:dm.start()].count("\n")
            if not target:
                emit("MEDIUM", "SOL-DELEGATECALL", ln,
                     "bare delegatecall( — verify the target is pinned "
                     "and trusted")
            elif DELEGATE_IMPL.search(target):
                emit("LOW", "SOL-DELEGATECALL", ln,
                     "delegatecall to `%s` — implementation accessor; "
                     "verify the upgrade path is gated" % target)
            elif target in fn.args:
                emit("HIGH", "SOL-DELEGATECALL", ln,
                     "delegatecall target `%s` is a caller-supplied "
                     "arg — arbitrary code executes in this contract's "
                     "context" % target)
            elif target in state_vars:
                emit("MEDIUM", "SOL-DELEGATECALL", ln,
                     "delegatecall target `%s` is a re-pointable state "
                     "variable — verify who can set it" % target)
            else:
                emit("MEDIUM", "SOL-DELEGATECALL", ln,
                     "delegatecall target `%s` — verify the callee "
                     "identity is pinned" % target)

        for sm in SELFDESTRUCT.finditer(body):
            emit("MEDIUM", "SOL-SELFDESTRUCT",
                 fn.line + body[:sm.start()].count("\n"),
                 "selfdestruct in `%s` — force-feeds balance "
                 "invariants; deprecated upstream" % fn.name)

        # tx.origin — emit once per fn at the worst severity: HIGH when
        # any occurrence sits in an auth-shaped statement, else LOW
        txo_hits = list(TXORIGIN.finditer(body))
        if txo_hits:
            ln = fn.line + body[:txo_hits[0].start()].count("\n")
            auth = any(TXO_AUTH.search(stmt_around(body, m.start()))
                       for m in txo_hits)
            emit("HIGH" if auth else "LOW", "SOL-TXORIGIN", ln,
                 "tx.origin %s in `%s` — %s" % (
                     "in an auth context" if auth else "referenced",
                     fn.name,
                     "a phished intermediate call passes the check"
                     if auth else
                     "verify it is not carrying authorization"))

        med_unc, low_unc = unchecked_calls(body, fn.line)
        for up in med_unc:
            emit("MEDIUM", "SOL-UNCHECKED-CALL",
                 fn.line + body[:up].count("\n"),
                 "low-level call result discarded in `%s` — a failed "
                 "call reads as success downstream" % fn.name)
        for up in low_unc:
            emit("LOW", "SOL-UNCHECKED-CALL",
                 fn.line + body[:up].count("\n"),
                 "call success flag captured but never read in `%s`"
                 % fn.name)

        # block-value randomness (line window)
        lines = body.splitlines()
        for idx, ln_txt in enumerate(lines):
            if not BLOCK_SRC.search(ln_txt):
                continue
            win = "".join(lines[max(0, idx - 1):idx + 2])
            if RAND_SINK.search(win):
                emit("MEDIUM", "SOL-BLOCK-RAND",
                     fn.line + idx,
                     "block-derived value feeds randomness/selection "
                     "in `%s` — miners can nudge it" % fn.name)
                break

        # spot-price oracle — reserve/balance reads on a price or swap
        # path; the fn name counts as context ("function price()").
        # AMM producer verbs (swap/mint/burn/skim/sync/flash) ARE the
        # venue — spot reads there are the mechanism, not the vuln.
        if SPOT_API.search(body) and \
                PRICE_CTX.search((fn.name or "") + "\n" + body):
            producer = bool(re.match(
                r"swap|mint|burn|skim|sync|flash", fn.name or "", re.I))
            emit("LOW" if producer else "MEDIUM", "SOL-SPOT-PRICE",
                 fn.line,
                 "fn `%s` reads a spot reserve/balance on a price "
                 "path%s — single-block reads are flash-manipulable; "
                 "prefer TWAP/median feeds" % (fn.name,
                 " (AMM producer — the read is the mechanism itself)"
                 if producer else ""))

        # unprotected initializer
        if fn.external and fn.mutating and \
                re.match(r"init|initialize", fn.name or "", re.I):
            if not authed and not any(
                    re.search(r"init", m, re.I) for m in mods_used):
                emit("MEDIUM", "SOL-INIT-UNGUARDED", fn.line,
                     "public/external `%s` has no initializer modifier "
                     "or gate — anyone can take the implementation"
                     % fn.name)

        # max approval
        for am in APPROVE.finditer(body):
            j = body.find("(", am.start())
            e = block_paren(body, j)
            if MAX_UINT.search(body[j:e]):
                emit("LOW", "SOL-MAX-APPROVE",
                     fn.line + body[:am.start()].count("\n"),
                     "approve/increaseAllowance with a max-uint "
                     "literal in `%s` — unlimited allowance" % fn.name)

        # exact-balance equality
        for bm in BALANCE_EQ.finditer(body):
            emit("MEDIUM", "SOL-BALANCE-EQ",
                 fn.line + body[:bm.start()].count("\n"),
                 "exact balance equality in `%s` — selfdestruct/"
                 "force-fed ether breaks the invariant" % fn.name)

        for em in ECRECOVER.finditer(body):
            emit("LOW", "SOL-ECRECOVER",
                 fn.line + body[:em.start()].count("\n"),
                 "ecrecover in `%s` — verify nonce/deadline/chain "
                 "binding against replay" % fn.name)

    return findings


class Finding:
    __slots__ = ("sev", "code", "where", "line", "msg")

    def __init__(self, sev, code, where, line, msg):
        self.sev = sev
        self.code = code
        self.where = where
        self.line = line
        self.msg = msg

    def as_dict(self):
        return {"severity": self.sev, "code": self.code,
                "file": self.where, "line": self.line, "msg": self.msg}


def scan_file(path, rel):
    try:
        raw = open(path, "r", encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    if not rel.endswith(".sol"):
        return []
    return scan_sol(path, rel, raw)


def find_targets(root):
    if os.path.isfile(root):
        return [root] if root.endswith(".sol") else []
    out = []
    for dp, dns, fns in os.walk(root):
        dns[:] = [d for d in dns if d not in SKIP_DIRS]
        for fn in fns:
            if fn.endswith(".sol"):
                out.append(os.path.join(dp, fn))
    return sorted(out)


def render(findings, targets):
    findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.where, f.line))
    rows = ["# solidity_prescan report", "",
            "files scanned: %d — findings: %d" % (len(targets),
                                                  len(findings)), ""]
    cur = None
    for f in findings:
        if f.sev != cur:
            cur = f.sev
            rows.append("## %s" % cur)
        rows.append("- `%s` %s:%d — %s" % (f.code, f.where, f.line,
                                           f.msg))
    if not findings:
        rows.append("no HIGH/MEDIUM/LOW signatures found — this is a "
                    "pre-scan; clean output != a safe contract")
    return "\n".join(rows)


# --- selftest ---------------------------------------------------------------
SELFTEST = [
    # --- SOL-TRANSFER-NOAUTH ---
    ("Vault.sol",
     "contract V {\n"
     "    function skim(uint amt) public {\n"
     "        payable(msg.sender).transfer(amt);\n"
     "    }\n}\n",
     {"SOL-TRANSFER-NOAUTH"}),
    ("Vault.sol",
     "contract V {\n"
     "    function skim(uint amt) public {\n"
     "        require(msg.sender == owner);\n"
     "        payable(msg.sender).transfer(amt);\n"
     "    }\n}\n", set()),
    ("Vault.sol",
     "contract V {\n"
     "    modifier onlyOwner() { require(msg.sender == owner); _; }\n"
     "    function skim(uint amt) public onlyOwner {\n"
     "        payable(msg.sender).transfer(amt);\n"
     "    }\n}\n", set()),
    ("Vault.sol",
     "contract V {\n"
     "    function pull(uint amt) public {\n"
     "        uint bal = balances[msg.sender];\n"
     "        payable(msg.sender).transfer(amt);\n"
     "    }\n}\n",
     {"SOL-TRANSFER-NOAUTH"}),   # caller-referencing, ungated -> MEDIUM
    ("Vault.sol",
     "contract V {\n"
     "    function pay(uint amt, address to) external {\n"
     "        to.call{value: amt}(\"\");\n"
     "    }\n}\n",
     {"SOL-TRANSFER-NOAUTH", "SOL-UNCHECKED-CALL"}),
    ("Vault.sol",
     "contract V {\n"
     "    function take(uint amt) public {\n"
     "        token.transferFrom(victim, msg.sender, amt);\n"
     "    }\n}\n",
     {"SOL-TRANSFER-NOAUTH"}),
    ("Vault.sol",
     "contract V {\n"
     "    function take(uint amt) public {\n"
     "        token.transferFrom(msg.sender, address(this), amt);\n"
     "    }\n}\n", set()),
    # --- SOL-MINT-NOAUTH ---
    ("T.sol",
     "contract T {\n"
     "    function mint(address to, uint amt) external {\n"
     "        _mint(to, amt);\n"
     "    }\n}\n",
     {"SOL-MINT-NOAUTH"}),
    ("T.sol",
     "contract T {\n"
     "    function mint(address to, uint amt) external onlyMinter {\n"
     "        _mint(to, amt);\n"
     "    }\n}\n", set()),
    # --- SOL-STATE-NOAUTH ---
    ("R.sol",
     "contract R {\n"
     "    uint count;\n"
     "    function bump() public {\n"
     "        count = count + 1;\n"
     "    }\n}\n",
     {"SOL-STATE-NOAUTH"}),
    ("R.sol",
     "contract R {\n"
     "    uint count;\n"
     "    mapping(address => uint) mine;\n"
     "    function bump() public {\n"
     "        mine[msg.sender] = mine[msg.sender] + 1;\n"
     "    }\n}\n",
     {"SOL-STATE-NOAUTH"}),      # caller-scoped -> LOW
    ("R.sol",
     "contract R {\n"
     "    uint count;\n"
     "    function bump() external onlyAdmin {\n"
     "        count = 1;\n"
     "    }\n}\n", set()),
    # --- SOL-REENTRANCY ---
    ("Bank.sol",
     "contract B {\n"
     "    mapping(address => uint) bal;\n"
     "    function withdraw() public {\n"
     "        uint amt = bal[msg.sender];\n"
     "        msg.sender.call{value: amt}(\"\");\n"
     "        bal[msg.sender] = 0;\n"
     "    }\n}\n",
     {"SOL-REENTRANCY", "SOL-UNCHECKED-CALL", "SOL-TRANSFER-NOAUTH"}),
    ("Bank.sol",
     "contract B {\n"
     "    mapping(address => uint) bal;\n"
     "    function withdraw() public {\n"
     "        uint amt = bal[msg.sender];\n"
     "        bal[msg.sender] = 0;\n"
     "        msg.sender.call{value: amt}(\"\");\n"
     "    }\n}\n",
     {"SOL-UNCHECKED-CALL", "SOL-TRANSFER-NOAUTH"}),
    ("Bank.sol",
     "contract B {\n"
     "    mapping(address => uint) bal;\n"
     "    function withdraw() public nonReentrant {\n"
     "        uint amt = bal[msg.sender];\n"
     "        msg.sender.call{value: amt}(\"\");\n"
     "        bal[msg.sender] = 0;\n"
     "    }\n}\n",
     {"SOL-UNCHECKED-CALL", "SOL-TRANSFER-NOAUTH"}),
    ("Bank.sol",
     "contract B {\n"
     "    mapping(address => uint) bal;\n"
     "    function withdraw() public {\n"
     "        uint amt = bal[msg.sender];\n"
     "        payable(msg.sender).transfer(amt);\n"
     "        bal[msg.sender] = 0;\n"
     "    }\n}\n",
     {"SOL-REENTRANCY", "SOL-TRANSFER-NOAUTH"}),
    # --- SOL-DELEGATECALL ---
    ("Proxy.sol",
     "contract P {\n"
     "    function run(address target, bytes memory data) public {\n"
     "        target.delegatecall(data);\n"
     "    }\n}\n",
     {"SOL-DELEGATECALL", "SOL-UNCHECKED-CALL"}),
    ("Proxy.sol",
     "contract P {\n"
     "    address impl;\n"
     "    function run(bytes memory data) public {\n"
     "        impl.delegatecall(data);\n"
     "    }\n}\n",
     {"SOL-DELEGATECALL", "SOL-UNCHECKED-CALL"}),
    ("Proxy.sol",
     "contract P {\n"
     "    function run(bytes memory data) public {\n"
     "        _implementation().delegatecall(data);\n"
     "    }\n}\n",
     {"SOL-DELEGATECALL", "SOL-UNCHECKED-CALL"}),
    # --- SOL-SELFDESTRUCT ---
    ("Boom.sol",
     "contract Z {\n"
     "    function kill() public {\n"
     "        selfdestruct(payable(msg.sender));\n"
     "    }\n}\n",
     {"SOL-SELFDESTRUCT"}),
    # --- SOL-TXORIGIN ---
    ("Auth.sol",
     "contract A {\n"
     "    function adm() public {\n"
     "        require(tx.origin == owner);\n"
     "        thing();\n"
     "    }\n}\n",
     {"SOL-TXORIGIN"}),
    ("Auth.sol",
     "contract A {\n"
     "    address origin = tx.origin;\n"
     "    function f() public view {}\n"
     "}\n", set()),
    # --- SOL-UNCHECKED-CALL ---
    ("C.sol",
     "contract C {\n"
     "    uint total;\n"
     "    function go() public {\n"
     "        (bool ok, ) = addr.call(data);\n"
     "        total = total + 1;\n"
     "    }\n}\n",
     {"SOL-UNCHECKED-CALL", "SOL-STATE-NOAUTH", "SOL-REENTRANCY"}),
    ("C.sol",
     "contract C {\n"
     "    function go() public {\n"
     "        (bool ok, ) = addr.call(data);\n"
     "        require(ok);\n"
     "    }\n}\n", set()),
    ("C.sol",
     "contract C {\n"
     "    function go() internal {\n"
     "        bool ok = addr.send(1 ether);\n"
     "        require(ok);\n"
     "    }\n}\n", set()),
    # --- SOL-BLOCK-RAND ---
    ("Lot.sol",
     "contract L {\n"
     "    function draw(uint n) public view returns (uint) {\n"
     "        return uint(keccak256(abi.encode(block.timestamp))) % n;\n"
     "    }\n}\n",
     {"SOL-BLOCK-RAND"}),
    ("Lot.sol",
     "contract L {\n"
     "    uint winner;\n"
     "    function pick(uint n) public {\n"
     "        winner = tickets[block.number % n];\n"
     "    }\n}\n",
     {"SOL-BLOCK-RAND", "SOL-STATE-NOAUTH"}),
    ("Lot.sol",
     "contract L {\n"
     "    function exp(uint h) public view returns (bool) {\n"
     "        return block.number > h;\n"
     "    }\n}\n", set()),
    # --- SOL-SPOT-PRICE ---
    ("O.sol",
     "contract O {\n"
     "    function price() public view returns (uint) {\n"
     "        (uint r0, uint r1,) = pair.getReserves();\n"
     "        return r1 * 1e18 / r0;\n"
     "    }\n}\n",
     {"SOL-SPOT-PRICE"}),
    ("O.sol",
     "contract O {\n"
     "    function reserves() public view returns (uint, uint) {\n"
     "        (uint r0, uint r1,) = pair.getReserves();\n"
     "        return (r0, r1);\n"
     "    }\n}\n", set()),
    # --- SOL-INIT-UNGUARDED ---
    ("Up.sol",
     "contract U {\n"
     "    address owner;\n"
     "    function initialize(address o) public {\n"
     "        owner = o;\n"
     "    }\n}\n",
     {"SOL-INIT-UNGUARDED", "SOL-STATE-NOAUTH"}),
    ("Up.sol",
     "contract U {\n"
     "    address owner;\n"
     "    function initialize(address o) public initializer {\n"
     "        owner = o;\n"
     "    }\n}\n", set()),
    # --- SOL-MAX-APPROVE ---
    ("A.sol",
     "contract A {\n"
     "    function trust(address s) internal {\n"
     "        token.approve(s, type(uint256).max);\n"
     "    }\n}\n",
     {"SOL-MAX-APPROVE"}),
    ("A.sol",
     "contract A {\n"
     "    function trust(address s, uint amt) internal {\n"
     "        token.approve(s, amt);\n"
     "    }\n}\n", set()),
    # --- SOL-BALANCE-EQ ---
    ("E.sol",
     "contract E {\n"
     "    function done() public view returns (bool) {\n"
     "        return address(this).balance == target;\n"
     "    }\n}\n",
     {"SOL-BALANCE-EQ"}),
    ("E.sol",
     "contract E {\n"
     "    function done() public view returns (bool) {\n"
     "        return address(this).balance >= target;\n"
     "    }\n}\n", set()),
    # --- SOL-ECRECOVER ---
    ("S.sol",
     "contract S {\n"
     "    function check(bytes32 h, uint8 v, bytes32 r, bytes32 s)"
     " public view returns (address) {\n"
     "        return ecrecover(h, v, r, s);\n"
     "    }\n}\n",
     {"SOL-ECRECOVER"}),
    # --- SOL-SECRET ---
    ("K.sol",
     "contract K {\n"
     "    bytes32 privateKey = 0x9f4a2cbe00112233aabbccddeeff0011223"
     "3445566778899001122334455;\n"
     "}\n",
     {"SOL-SECRET"}),
    ("K.sol",
     "contract K {\n"
     "    address publicKey = 0x9f4a2cbe00112233aabbccddeeff0011223"
     "3445566778899001122334455;\n"
     "}\n", set()),
    # --- interplay / FP guards ---
    ("Clean.sol",
     "// contract X { function skim() public { a.transfer(1); } }\n"
     "contract C {\n"
     "    function f() public view returns (uint) { return 1; }\n"
     "}\n", set()),
    ("Clean.sol",
     "contract C {\n"
     "    string msg = \"function skim() { a.transfer(1); }\";\n"
     "    function f() public view returns (uint) { return 1; }\n"
     "}\n", set()),
    ("Clean.sol",
     "contract C {\n"
     "    function internal_set() internal { count = 1; }\n"
     "    function peek() private { count = 2; }\n"
     "    uint count;\n"
     "}\n", set()),
    ("test/Vault.t.sol",
     "contract V {\n"
     "    function skim(uint amt) public {\n"
     "        payable(msg.sender).transfer(amt);\n"
     "    }\n}\n",
     {"SOL-TRANSFER-NOAUTH"}),   # downgrades HIGH->MEDIUM
    ("Nested.sol",
     "contract N {\n"
     "    modifier onlyOwner() { _checkOwner(); _; }\n"
     "    modifier onlyAdmin() { require(msg.sender == admin); _; }\n"
     "    modifier onlySteward() { onlyAdmin(); _; }\n"
     "    function sweep() public onlySteward {\n"
     "        payable(msg.sender).transfer(1);\n"
     "    }\n"
     "}\n", set()),
]


def run_selftest():
    import tempfile
    fails = []
    for i, (name, src, expect) in enumerate(SELFTEST):
        with tempfile.TemporaryDirectory() as td:
            p = os.path.join(td, name)
            os.makedirs(os.path.dirname(p) or td, exist_ok=True)
            open(p, "w").write(src)
            got = {f.code for f in scan_file(p, name)}
            missing = expect - got
            extra = got - expect
            if missing or extra:
                fails.append(("v%d %s" % (i, name),
                              "missing=%s extra=%s" % (missing, extra)))
    if fails:
        for f_ in fails:
            print("SELFTEST FAIL", f_)
        return 1
    print("SELFTEST PASS (%d vectors)" % len(SELFTEST))
    return 0


def main():
    args = [a for a in sys.argv[1:] if a != "--json"]
    want_json = "--json" in sys.argv
    if "--selftest" in sys.argv:
        return run_selftest()
    if not args:
        print(__doc__)
        return 2
    root = args[0]
    targets = find_targets(root)
    findings = []
    for p in targets:
        rel = os.path.relpath(p, root) if os.path.isdir(root) \
            else os.path.basename(p)
        findings += scan_file(p, rel)
    if want_json:
        print(json.dumps({"findings": [f.as_dict() for f in findings],
                          "scanned": len(targets)}, indent=1))
    else:
        print(render(findings, targets))
    return 1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
