--- name: security-smart-contracts description: >- Audit Solidity and EVM smart contracts, web3 protocols, and on-chain code. Use for smart contract review, Solidity or DeFi audit, proxy and upgradeability checks, invariant and property fuzzing design, oracle and price manipulation review, ERC-4337 or EIP-7702 account abstraction, Permit2 and signature replay, L2 and cross-chain risk, and for running Slither, Aderyn, Echidna, Medusa, Halmos, or Foundry invariants. Skip it for off-chain application, cloud, container, or dependency security, for gas golfing, and for tokenomics or economic-design review with no security question attached. --- # Smart contract security review On-chain code is immutable by default, publicly readable, and adversarially executed by anyone who can pay gas. Treat every deployed function as a hostile entry point and every external call as a re-entry into your own state. This skill is a four-phase procedure: reconnaissance, a two-pass sweep, deep validation of each candidate, then a report. The phases are ordered because each one narrows the next. Do not skip to reporting from tool output. ## The shared contract, restated The `security-audit` skill in this plugin owns the long form of the severity model, the evidence rules, and the report contract. The short version, which binds this skill: - **Every tool finding is a lead, not a finding.** Promote a lead only when a written attack path exists, with a `file:line` and named functions. Anything else is either discarded or tagged `theoretical`. - **Evidence or silence.** Never claim a component, control, role, or data flow exists without a code reference. "Probably has a check somewhere" is not a finding and not a clearance. - **Preflight, never assume.** Detect each tool before using it. If it is absent, print its install command and continue with agent-native reasoning rather than aborting the review. - **Artifacts leave the working tree.** Scan output contains source excerpts and exploit steps. Write it to a directory outside the repository and never attach it to a pull request by default. - **Exit codes are inconsistent across tools.** Handle them per tool and make every pipeline step non-fatal, so one analyzer's failure does not truncate the review. - **Per-finding record:** identifier, title, severity, vulnerability class, `file:line`, affected contracts and functions, numbered attack path, preconditions, proof of concept or an explicit `theoretical` tag, impact, recommendation, and confidence. - **Deduplicate by `(file, line, class)`** across every source, tool and human alike, and keep one merged record with the union of its corroboration. ## Phase 1: reconnaissance Build a map before looking for bugs. A finding you cannot place in the value flow is a finding you cannot rank. 1. Enumerate every `.sol` source under the project's source root and record the **pragma of each file**. Many defects are version-dependent — the arithmetic story, `PUSH0` availability, `transfer` gas semantics, and custom-error support all move with the compiler — so a per-file pragma table is not bookkeeping, it is an input to Phase 3. 2. Identify external dependencies: which library versions are vendored or installed, which are upgradeable, which are forks with local edits. A fork with edits is the highest-risk dependency shape there is; diff it. 3. Map privileged roles. For every role, record who holds it, what it can call, whether it can grant itself more, and whether it is behind a timelock. 4. Map value flows. For every asset, record how it enters, where it is accounted, who can move it, and how it leaves. Every deposit path must have a withdrawal path. 5. Record the trust assumptions the protocol states about oracles, sequencers, bridges, relayers, keepers, and governance. Phase 3 either confirms each one is enforced in code or turns it into a finding. 6. Run the Slither printers listed in `references/tooling.md` to accelerate steps 3 and 4. Treat printer output as a draft of your map, not the map. ## Phase 2: sweep Two complementary passes. Run both. Neither alone is sufficient, and the overlap between them is small. ### Pass A: syntactic Grep for known trigger patterns and record every hit with its `file:line`. This pass is deliberately dumb and deliberately over-inclusive; filtering is Phase 3's job. Trigger set: | Pattern | Suspected class | | --- | --- | | `delegatecall`, `callcode`, `assembly` | delegatecall, storage, arithmetic | | `.call{value:`, `.send(`, `.transfer(` | reentrancy, unchecked calls, DoS | | `tx.origin` | access control | | `unchecked {`, downcasts such as `uint128(` | arithmetic | | `block.timestamp`, `block.number`, `blockhash`, `prevrandao` | MEV, randomness, L2 | | `ecrecover`, `EIP712`, `DOMAIN_SEPARATOR`, `permit(` | signatures | | `initialize(`, `initializer`, `_disableInitializers` | upgradeability | | `_authorizeUpgrade`, `upgradeTo`, `ERC1967` | upgradeability | | `slot0(`, `getReserves(`, `latestRoundData(`, `getPrice` | oracles | | `selfdestruct`, `create2`, `CREATE2` | access control, metamorphic code | | `for (` over a storage or calldata array | DoS, gas griefing | | `IERC20(...).transfer`, `.approve(`, absent `SafeERC20` | ERC-20 weirdness | | `_safeMint`, `onERC721Received`, `onERC1155` | reentrancy via callbacks | | `msg.value` inside a loop or `multicall` | `msg.value` replay | | `payable(` fallback or `receive()` with logic | access control, DoS | Also run the static analyzers here — Slither and Aderyn, per `references/tooling.md`. Their output joins the same candidate list under the same rule: lead, not finding. ### Pass B: semantic Read for what does not grep. Budget the majority of Phase 2 here. - **Cross-function and cross-contract reentrancy.** A guard on one function does not protect a second function that shares the same state. Follow every external call and ask which other entry point is reachable during it. - **Missing access control on state-changing functions.** Enumerate every external and public function that writes storage, moves value, or changes a role, and name the modifier or explicit check that protects each. A blank cell is a candidate. - **Inheritance and initialization order.** C3 linearization decides which implementation wins; constructor and initializer order decides which state is set. Check that every parent is initialized exactly once and in the right order, and that no override silently drops a base-class check. - **Unchecked external calls.** A low-level call returns `(bool, bytes)` and a discarded `bool` is a silent failure. Check that the return value is used and that returndata is bounded. - **Missing `initializer` on upgradeable contracts.** An `initialize()` without the modifier, or an implementation whose constructor omits `_disableInitializers()`, is a critical candidate on sight. - **Invariants stated in comments or documentation but not enforced in code.** These convert directly into Phase 3 candidates and into the fuzzing properties in `references/invariants-and-fuzzing.md`. Merge Pass A and Pass B into **one deduplicated candidate list**, each entry carrying a `file:line` and a suspected class. Deduplicate by `(file, line, class)`. ## Phase 3: deep validation For each candidate, do the work that turns it into a finding or discards it: 1. Trace the full call chain from every external entry point that reaches it. 2. Follow the values of the relevant variables across contract boundaries, including through libraries, proxies, and callbacks. 3. Check every modifier and require on the path, and check what the path looks like when each one is satisfied by an attacker rather than a user. 4. Establish the preconditions an attacker needs, and whether they are reachable — capital is reachable via flash loan, ordering is reachable via MEV, and a "trusted" role is reachable if it is an EOA with no timelock. 5. Confirm or discard. Write the reason either way; a discarded candidate with a written reason is a durable review artifact. ### Rationalizations to reject This list is the most valuable part of this skill. Each entry is a sentence that ends analysis prematurely and is wrong often enough to be dangerous. - **"The compiler is at least 0.8.0, so overflow is impossible."** Checked arithmetic covers ordinary expressions only. `unchecked` blocks, inline assembly, and **type downcasts** all still wrap silently — `uint128(x)` on an oversized `uint256` is a truncation, not a revert, at every compiler version. - **"It uses OpenZeppelin, so it is safe."** The library is sound; the integration usually is not. The common defect is a custom function that forgot the modifier, an override that dropped a check, a hook used with the wrong assumptions, or a version whose behavior differs from the one the code was written against. - **"That function is internal, so it is not reachable."** Internal functions are reached from external entry points and execute with the caller's context. Reachability is a property of the call graph, not of the visibility keyword. Enumerate the external callers before dismissing anything. - **"No ETH is involved, so reentrancy does not apply."** ERC-721 `_safeMint` and `safeTransferFrom`, ERC-1155 single and batch safe transfers, and ERC-777 `tokensToSend`/`tokensReceived` all hand control to a receiver. And **read-only reentrancy** needs no value transfer at all: a view function read mid-callback returns state that is momentarily inconsistent. - **"It is upgradeable, so we can fix it later."** Upgradeability adds a vulnerability class rather than removing one. An `initialize()` without the `initializer` modifier is itself a critical finding, and a storage-layout collision cannot be fixed by the upgrade that causes it. - **"The tool flagged it, so it is a finding."** And its inverse, **"the tools found nothing, so the code is clean."** A tool result is a lead until it has a written attack path; the absence of tool results says nothing about the business-logic and access-control classes that now dominate real losses. ## Phase 4: report Emit one record per confirmed finding, then a severity summary. ```markdown ### [SC-01]