| name | xray-pre-audit |
| description | Use when preparing for a security audit, performing reconnaissance on a new codebase, or creating a protocol overview. Generates a structured pre-audit readiness report covering architecture overview, threat model, cross-linked protocol invariants, entry point classification and flow paths, composability analysis, test coverage gaps, git history signals, and an architecture diagram. |
X-Ray Pre-Audit Reconnaissance
Overview
X-Ray generates a structured pre-audit readiness report that gives auditors (human or AI) a fast, accurate understanding of a protocol before diving into line-by-line review. The output is an x-ray/ folder at the project root containing:
| File | Contents | Lifetime |
|---|
x-ray.md | Main readiness report — overview, threat model, docs/test analysis, git history, verdict. Under 500 lines. | Kept |
entry-points.md | Protocol flow paths + full entry point classification with call chains and parameter trust levels | Kept |
invariants.md | Complete invariant catalog with stable IDs (G-N, I-N, X-N, E-N) that x-ray.md cross-links into | Kept |
architecture.svg | Rendered architecture diagram | Kept |
architecture.json | Intermediate machine-readable architecture graph used to build the SVG | Deleted at cleanup |
Adapted for the EVM Cortex squad from the Pashov Audit Group x-ray skill (upstream: github.com/pashov/skills, skill x-ray, VERSION 2 — see the VERSION file alongside this one). The three scripts under scripts/ are upstream code carried over intact and are the source of truth for enumeration, git security analysis, and SVG generation — the prose here tells you when to call them and how to read their output, never how to reimplement them.
X-Ray is fully autonomous. It runs without user interaction, produces concrete artifacts, and never fabricates findings. When something cannot be determined from the code, it says so explicitly.
When to Invoke
- A new audit engagement begins and you need to orient quickly
- You are asked to "review", "audit", or "assess" a Solidity codebase
- The
audit-orchestrator agent kicks off a Light, Core, or Thorough audit
- A developer asks "is this codebase audit-ready?"
- You need to build a threat model or protocol overview from scratch
Relationship to Other Audit Skills
| Skill | Phase | X-Ray's Role |
|---|
audit-prep | Before audit | X-Ray validates audit-prep deliverables |
audit-recon | Phase 1 | X-Ray IS enhanced recon — superset of audit-recon |
audit-breadth-scan | Phase 2 | X-Ray feeds prioritized leads into breadth scan |
audit-depth-analysis | Phase 3 | X-Ray's threat model guides depth agent routing |
invariant-testing | Testing | X-Ray's invariants.md IDs become invariant test names |
pashov-audit-pipeline | Full review | X-Ray runs first; its 8 agents consume the threat model and invariant map |
Progress Tracking (Mandatory)
Before doing anything else, call TodoWrite with these three todos, all pending:
Phase 1: Enumerate & measure codebase
Phase 2: Read sources, classify entry points, synthesize invariants
Phase 3: Write x-ray report files
Transitions — update via TodoWrite, never batch:
- Mark Phase 1
in_progress immediately, before running enumeration.
- When Step 1's parallel batch returns, in ONE TodoWrite call mark Phase 1
completed and Phase 2 in_progress.
- When Step 2 (including all sub-steps) finishes, in ONE TodoWrite call mark Phase 2
completed and Phase 3 in_progress.
- After all Step 8 output files are written and the diagram is validated, mark Phase 3
completed.
Rule: exactly one todo is in_progress at any time. Status updates happen the moment a phase starts or ends.
Step 1: Enumerate & Measure
Before reading any code, quantify the target. Numbers ground the analysis and calibrate effort.
Project Root & Source Directory Detection
If the user specifies a path, use it as project root. Otherwise use cwd. If no .sol files or foundry.toml / hardhat.config.* at root, check one level deep.
ROOT="${1:-.}"
cd "$ROOT" || exit 1
if [ -f foundry.toml ]; then TOOLCHAIN=foundry
elif [ -f hardhat.config.js ] || [ -f hardhat.config.ts ]; then TOOLCHAIN=hardhat
else TOOLCHAIN=unknown; fi
echo "toolchain: $TOOLCHAIN"
SRC=$(sed -nE 's/^[[:space:]]*src[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' foundry.toml 2>/dev/null | head -1)
[ -z "$SRC" ] && { [ -d src ] && SRC=src; }
[ -z "$SRC" ] && { [ -d contracts ] && SRC=contracts; }
[ -d "$SRC" ] || { echo "ERROR: source directory not found"; exit 1; }
echo "src: $SRC"
mkdir -p x-ray
All shell in this skill uses POSIX ERE only — no -P / PCRE, no GNU-only escapes (\s, \w, \b). This keeps every command identical on macOS BSD grep, Linux GNU grep, and ripgrep. Use [[:space:]], [[:alnum:]_], and explicit alternation instead.
Enumeration
Run the bundled enumeration script as a single Bash call. It creates the output directory and emits labeled sections consumed by every later step.
mkdir -p {project-root}/x-ray && bash {SKILL_DIR}/scripts/enumerate.sh {project-root} {src-dir}
{SKILL_DIR} is this skill's directory. The script covers, in one pass: toolchain detection, source files with line counts, per-file and total nSLOC, NatSpec density, test presence, fuzz/formal-verification detection, docs, current commit, and git history statistics. Do not reimplement any of it inline — the script handles quoting and exclusion edge cases that ad-hoc greps get wrong.
Version Check
In the same parallel message as enumeration:
curl -sf https://raw.githubusercontent.com/pashov/skills/main/x-ray/VERSION
Compare against this skill's local VERSION file. If the remote value is higher, print ⚠️ Upstream x-ray is at version N, this skill is vendored at 2. See https://github.com/pashov/skills. If the fetch fails, skip silently — a network failure is not an audit finding.
Reading the Test Signals
Test presence comes from the file scan above. It is always reliable, even when the toolchain cannot compile — which is exactly why it is measured separately from coverage.
Multi-signal categories (echidna, medusa, halmos, certora) report as functions:configs. 5:1 means 5 test functions plus 1 config file. A config with zero functions is worth flagging on its own: the harness exists but nobody wrote properties for it.
| Signal | Reads as |
|---|
test_functions | Unit/integration breadth (Foundry function test* plus Hardhat it(...)) |
stateless_fuzz | testFuzz* — per-call property testing |
foundry_invariant | invariant_* — stateful campaigns in Foundry |
echidna / medusa | External stateful fuzzing, as functions:configs |
certora / halmos / hevm | Formal verification: specs/CVL, check_*, prove_* |
fork | Fork-test count — whether live integration state is exercised at all |
A protocol with high test_functions and zero stateful or formal signals is a specific, reportable readiness gap: the state space has never been searched. Route that to fizz for an Echidna/Medusa suite.
Coverage (Background)
Launch coverage in the background. Do not wait for it.
forge coverage 2>&1 || (echo "RETRYING_WITH_IR_MINIMUM" && forge coverage --ir-minimum 2>&1)
npx hardhat coverage 2>&1
If the toolchain is not installed (forge: command not found, missing node_modules/), this fails. That is expected and carries no information about test quality — see the test-existence rules in Step 8.
Dependency Snapshot
forge tree 2>/dev/null | head -40
grep -rE 'openzeppelin|solady|solmate' foundry.toml remappings.txt 2>/dev/null | head -10
ls lib/ 2>/dev/null
Spec / Whitepaper Detection
Glob **/{whitepaper,spec,design,protocol,architecture,overview,README}*.{pdf,md}, excluding node_modules/, lib/, x-ray/, test/. Skip user-facing docs — tutorials, API references, changelogs, contribution guides. Then apply size-aware handling:
- Path A (≤5 docs, each ≤300 lines) — read them directly as part of the Step 2 parallel message. No subagent needed.
- Path B (>5 docs, or any doc >300 lines) — launch a single
scout subagent that reads all doc files and returns a structured extraction of at most 200 lines, with these headings only: Doc-Stated Global Invariants, Actor Definitions, Trust Assumptions, Cross-System Flows, Economic Properties, Key Design Decisions. Require a source quote per claim; omit empty headings.
Extract only: doc-stated global invariants, actor definitions, cross-system flows, trust assumptions, economic properties, key design decisions. Tag every spec-derived claim in the report with (per spec) so auditors can tell code-verified from spec-stated. Doc-stated global invariants feed the NatSpec routing pass in Step 5 — they route to §2/§3/§4 of invariants.md by shape, never to §1 (which is per-call guards only).
Parallel Batch Rule
Issue enumeration, background coverage, git analysis (Step 6), the reference doc reads, and the spec glob in the same message. Proceed to Step 2 without waiting for coverage.
Step 2: Source Analysis
Scope Filtering
- Skip interfaces:
interfaces/ directories, or filenames matching I followed by an uppercase letter.
- Skip vendored libraries: copies of Uniswap
FullMath/TickMath, OpenZeppelin, Solady, Solmate.
- Skip mocks and test doubles.
- When uncertain, read the file but exclude it from the scope table.
Do NOT read test files or documentation files in this step.
Two Scan Paths
Choose by in-scope source file count.
Path A — ≤20 source files: direct reads
One Read call per file, all in a single parallel message alongside the entry point grep scan. Do not re-read README, docs, or foundry.toml — Step 1 already covered them.
Path B — >20 source files: fan out to parallel subagents
| Tier | Files | Handling |
|---|
| 1 | ≤120 lines | Batch into a single Bash cat call |
| 2 | >120 lines | Group by subsystem; launch one scout subagent per subsystem (up to 5 subagents, ~10 files each) |
Each Tier 2 subagent extracts facts only — no analysis — and returns the per-file structure below.
Per-File Extraction Contract
Both paths must produce the same facts per file:
- Type —
contract, abstract contract, library, or interface
- Inherits — full linearization (affects function resolution order)
- Imports — libraries and contracts pulled in
- Roles / access control — modifiers, role constants,
msg.sender checks
- Value-holding state — mappings and variables holding balances, collateral, stakes, reserves, debt
- External calls — every call to another contract, including token transfers
- Fund flows — deposit / withdraw / mint / burn / transfer / borrow / repay / liquidate paths
- Invariant comments — NatSpec
@invariant tags, require/assert statements
- Backwards-compatibility indicators — see Step 2c
- Key logic — one or two sentences on what the contract does
- Function-level access map (contracts only, skip libraries) — every public/external non-view non-pure function with its modifier, or
NONE — permissionless. For functions with no modifier, also list the external calls they make.
Three extractions carry the invariant synthesis in Step 5 and must be captured precisely:
Delta writes — for each non-view non-pure function, the storage variables that change and the symbolic delta applied. Format Δ(var) = +expr or Δ(var) = -expr. Same-basic-block only; report a pair only when both writes appear in the same function body with no intervening call to an unknown external contract. Do not chase writes through inherited or imported functions unless the semantic effect is unambiguous — OpenZeppelin _mint touching balanceOf and _totalSupply is fine, custom internal helpers are not. List a custom helper's deltas under that helper's own entry.
deposit(): Δ(totalSupply) = +shares, Δ(balanceOf[msg.sender]) = +shares
borrow(): Δ(totalBorrows) = +amount, Δ(underlyingBalance) = -amount
Guard predicates — every require / assert / if-revert that references a storage variable, quoted verbatim with its line number. Skip guards that reference only function parameters.
Vault.sol:206: require(_fee <= 10, "fee is capped at 0.1%")
Enum / one-shot transitions — every require(var == X); ...; var = Y pair where var is a storage enum, uint, or address. Record as X@Lx → Y@Ly. Include one-shot latches such as require(addr == address(0)); addr = concrete.
Entry Point Grep Scan
Issue both greps in the same parallel message as the source reads — they are independent.
grep -rnE 'function[[:space:]]+[[:alnum:]_]+[[:space:]]*\([^)]*\)[[:space:]]+(external|public)' "$SRC"/ --include='*.sol' \
| grep -v '/interfaces/' | grep -v '/mock/' \
| grep -Ev '(^|[^[:alnum:]_])(view|pure)([^[:alnum:]_]|$)'
grep -rnE '^[[:space:]]*\)[[:space:]]+(external|public)' "$SRC"/ --include='*.sol' -B5 \
| grep -v '/interfaces/' | grep -v '/mock/' \
| grep -Ev '(^|[^[:alnum:]_])(view|pure)([^[:alnum:]_]|$)'
Combine both result sets. The multiline grep is not optional — Solidity routinely splits parameters across lines, leaving external/public on the ) line while function name( sits several lines above. The trailing filter is the POSIX-portable substitute for \b(view|pure)\b: it drops lines where view or pure appears as a standalone identifier while preserving identifiers like view_param.
Step 2b: Entry Point Classification
Classify all entry points using the grep results plus the function bodies. Do not rely on subagent summaries alone — subagents extract facts at contract level and can misattribute which function makes which call or carries which modifier.
Exclude: view/pure functions, interface-only declarations, library internal functions (downstream calls, not entry points), mock contracts.
| Access Level | Criteria | Priority |
|---|
| Permissionless | No access-control modifier AND no caller restriction anywhere in the function body | HIGHEST |
| Role-gated | Role modifier (onlyRole, onlyOwner, onlyRouter, …) OR an internal msg.sender restriction. Record which role or address is required. | MEDIUM |
| Admin-only | DEFAULT_ADMIN_ROLE, onlyOwner pointing at protocol admin, or equivalent top-level authority | LOWER, but centralization risk |
| Initializer | initializer / reinitializer — one-time deployment entry points | Track separately |
A function without a modifier is NOT automatically permissionless. You MUST verify the body before classifying. In Path A the bodies are already in context — classify directly. In Path B, batch all candidate body reads into a SINGLE parallel message. Look for any of:
require(msg.sender == expected);
if (msg.sender != expected) revert Unauthorized();
if (msg.sender != a || cond) revert Unauthorized(); // compound
_checkCaller(); // internal check
A modifier-free function with an internal msg.sender check is role-gated. Common offenders: acceptOwnership(), acceptMsig(), confirmX() — no modifier, but restricted to a pending address.
nonReentrant alone is NOT access control.
Record per entry point:
| Field | Value |
|---|
| Contract / Function | Pool.sol — deposit(uint256 assets, address receiver) |
| Visibility | external, nonReentrant |
| Access level | Permissionless |
| Caller | User |
| Parameters | assets (user-controlled), receiver (user-controlled) |
| Call chain | → Pool._deposit() → Pool._mint() → Strategy.deploy() |
| State modified | balances[receiver], totalSupply, totalAssets |
| Value flow | in (tokens: sender → Pool) |
| Reentrancy guard | yes |
Parameter trust levels: (user-controlled) the caller picks the value freely, (user-signed) the value comes from a user's offchain signature, (keeper-provided) a keeper selects it, (protocol-derived) read from onchain state.
Call chains use concrete contract names, not interface names — write FuturesManager.addCollateral(), not ICollateralManager.addCollateral(). Interfaces describe how the caller references the target; the auditor needs to know which contract actually executes. The only exception is genuinely external third-party contracts (IERC20.safeTransfer()).
The grep scan is a hard gate: the permissionless list in the report must match the grep-verified, body-checked result. Where grep plus code reading conflicts with a subagent summary, grep wins.
Step 2b-flow: Protocol Flow Path Construction
Reorganize the Step 2b data into flow paths for entry-points.md. This is not a new analysis pass and needs no new tool calls.
For each major user-facing entry point (permissionless and role-gated functions that move value):
- Identify its
require statements and state variable checks.
- For each check, find which function WRITES that state variable — already known from other entry points' "State modified" field.
- Chain backwards: destination ← writer of its precondition ← writer of THAT precondition ← … ← deployment.
- Annotate non-function preconditions (time passage, market conditions, position health, available liquidity) with
◄──.
Output is simple arrow chains grouped by actor, 15-30 lines total. Reference earlier flows ([owner setup above]) instead of repeating them.
Step 2c: Backwards-Compatibility Code Detection
Watch for code that looks like the remnant of a removed mechanism, kept so the rest of the codebase still compiles. Signals: empty or trivial function bodies, state variables declared but never meaningfully read or written, comments containing "deprecated" / "legacy" / "backwards compat" / "no longer used", interface implementations that always return a default, storage variables preserved solely for proxy layout compatibility.
After reading ALL source files, cross-reference candidates against these mandatory checks. Batch every caller-check Grep for all candidates into a SINGLE parallel message — do not verify one at a time.
- Caller check (required) — confirm via Grep that the function or variable has NO active callers. If it IS called from an active path, it is the current design, regardless of whether it returns defaults.
- NatSpec / comment check (required) — if NatSpec or inline comments explain why it behaves this way ("simplified for X mode", "by design", "intentionally zero"), this is documented intentional design. Do not override explicit developer documentation with heuristic pattern matching.
- Interface obligation check — a function returning defaults because an interface requires it AND that is actively called is part of the current architecture, not a remnant.
Classify as backwards-compatibility only when ALL of: no active callers, no NatSpec/comments documenting the behavior as intentional, and git history shows the mechanism it belonged to was removed.
Never describe backwards-compatibility code as an active feature. Note it explicitly in Section 1 so auditors do not burn time investigating dead code. If nothing survives the checks, omit the subsection entirely.
Step 2d: Centralization & Pause Coverage Analysis
Two analyses that feed into existing report sections. Neither produces a standalone section.
Centralization analysis — for each privileged role (admin, owner, operator, keeper, service):
- List every operational action the role can take, from the function-level access map.
- For each action, note whether a timelock, multisig, or delay exists. Distinguish role transfer delays (for example
AccessControlDefaultAdminRules' 1-day delay) from operational action delays. A role transfer delay does NOT protect against a compromised holder using instant operational functions.
- Identify which actions can extract or redirect user funds (
emergencyWithdraw, setTreasury, transferFee).
Integrate into Actors (Capabilities column states what is instant versus delayed), Trust Boundaries (what each boundary actually protects versus what bypasses it), and Key Attack Surfaces (framed as "Admin operational powers" or "[Role] compromise" — the surface is the role compromise, not the individual functions).
Pause coverage analysis — for each critical state-changing function, check whether whenNotPaused or an equivalent applies; note which functions are pausable and which are not. A function that should logically be pausable but is not (for example one callable by a bounded role that operates on user funds) is a detail that worsens that role's compromise scenario — fold it into that role's attack surface rather than raising it independently.
Anti-pattern: do NOT create a standalone "Centralization Risks" section. Centralization detail belongs distributed across Actors, Trust Boundaries, Key Attack Surfaces, and Protocol-Type Concerns. A dedicated section duplicates what those already say. Same for pause coverage.
Step 2e: nSLOC
Use the exact nSLOC TOTAL from Step 1 enumeration — no ~ prefix — in the report header and scope table.
Step 3: Protocol Classification
Protocol Type Detection
Detect type from function signatures, state variables, and architectural patterns found during Step 2. A protocol may match multiple types. Rank by signal density; the type with the most matches is primary.
| Type | Detection Signals in Code |
|---|
| Lending / Borrowing | borrow(), repay(), liquidate(), liquidationBonus, healthFactor, collateralFactor, LTV, debtToken, interestRate, collateral ratio math |
| DEX / AMM | swap(), addLiquidity(), removeLiquidity(), constant-product math, stable-swap invariant, sqrtPriceX96, tick, LP mint/burn, fee tier, getAmountOut(), reserves |
| Yield Aggregator / Vault | ERC-4626 pattern (deposit/withdraw/convertToShares/convertToAssets), strategy pattern with harvest(), totalAssets(), strategyDebt, auto-compound |
| Stablecoin | Mint/burn against collateral, collateralRatio, stability fee, debtCeiling, redemption mechanism, PSM, anchor/peg/target price |
| Derivatives / Perps | openPosition(), closePosition(), increaseSize(), fundingRate, margin, leverage, PnL math, markPrice, indexPrice, position struct with size/collateral/entryPrice |
| Liquid Staking | stake() plus derivative token mint, requestWithdrawal(), exchange rate calculation, validator set management, withdrawal queue, rebasing or share-based token |
| Bridge | Cross-chain message passing, lock()/unlock() or burn()/mint(), relayer/validator set, message nonce, chain ID checks, merkle proof verification |
| Governance | propose(), vote(), execute(), queue(), quorum calculation, voting power snapshots, timelock, delegation, proposalThreshold |
| NFT / Marketplace | mint(), list(), buy(), offer(), royalty, tokenURI, ownerOf, setApprovalForAll |
Hybrid Classification
- Rank by signal count — more matches means more weight in the threat model.
- The primary type determines adversary ranking order.
- Secondary types add their unique threats, de-duplicating overlapping ones.
- State in the report: "Protocol classified as [Primary] with [Secondary] characteristics."
A protocol with swap(), addLiquidity(), borrow(), liquidate() is Primary DEX/AMM, Secondary Lending.
Temporal Phase Detection
Protocols face different dominant threats at different points in their lifecycle. Detect which phases are relevant from code signals; include only those in the threat model.
| Phase | Include When |
|---|
| Deployment & Initialization | Always — every protocol has this phase |
| Steady State | Always — this is the baseline, covered by the main threat model |
| Market Stress | Oracle integration, liquidation logic, collateral/debt tracking, or any price-dependent calculation exists |
| Governance & Upgrade Windows | Timelock, governance contract, proxy pattern (UUPS/transparent/beacon), or propose()/vote()/execute() exists |
| Deprecation & Wind-down | V2/migration in names or comments, migrate() exists, deprecated contract references, or multi-version architecture |
Step 4: Threat Model Construction
The threat model is the analytical core of X-Ray. It maps actors, trust boundaries, and attack surfaces.
How to Use the Threat Profiles
The profiles below are a threat identification library, not prose to paste into the report. Use them to know what to look for and who threatens this protocol, then translate that into the report's format.
DO-NOT-EXPLOIT RULE (critical). Attack surfaces describe the concern area, not the exploit. The auditor's value is building the attack path; yours is finding the area fast. If a bullet contains "→ attacker drains X", "→ user trapped", "→ inflated share", "double-counts W", "leads to understated N" — cut it. Replace with "Worth checking…", "Worth tracing…", "Worth confirming…". Name the asymmetry, the divergence, the unusual pattern, the cross-path bookkeeping, then stop.
The exploit-chain prose inside this Step 4 library (for example "oracle manipulation → inflated collateral → drain the pool") is intentional here — it teaches the threat. It is forbidden in the report.
Actor Enumeration
Only named roles from the code. Never "Anyone". Never "Semi-trusted" — use "Bounded (reason)".
| Actor | Trust Level | Capabilities |
|---|
| EOA User | Untrusted | All permissionless functions |
| Contract User | Untrusted, reentrancy-capable | Same as EOA, plus code execution on callbacks |
| Keeper | Bounded (can only complete CREATED swaps within constraints; not subject to whenNotPaused) | harvest, rebalance, liquidate |
| Admin (multisig) | Trusted | 11 instant setters plus pause. pause does NOT gate withdraw. |
| Timelock | Trusted, delayed | Delayed execution provides an exit window |
| Oracle | External trust | Price data consumed by protocol; manipulation is critical |
| Integrated Protocol | External trust | Protocol depends on their correct operation |
| Flashloan Attacker | Adversarial | Unlimited capital within one transaction |
Capabilities cells are a scannable reference, not a capability paragraph. For roles with many powers, summarize and enumerate only the dangerous ones inline. Aim for two lines per cell.
Then produce an Adversary Ranking ordered by threat level for this protocol type, adjusted by git evidence. Three to five entries, ONE sentence each stating WHO they are and WHY they matter here. Attack mechanics and code references belong in Key Attack Surfaces, not here.
Trust Boundary Map
┌─────────────────────────────────────────────┐
│ UNTRUSTED ZONE (public internet) │
│ Users, Flashloan attackers, MEV searchers │
├─────────────────────────────────────────────┤
│ PROTOCOL BOUNDARY │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Vault.sol │───▶│ Strategy.sol │ │
│ │ (entry point)│ │ (internal) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
├─────────┼───────────────────┼───────────────┤
│ EXTERNAL TRUST BOUNDARY │ │
│ ┌──────▼───────┐ ┌──────▼───────┐ │
│ │ Chainlink │ │ Aave V3 │ │
│ │ (oracle) │ │ (lending) │ │
│ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────┤
│ ADMIN TRUST BOUNDARY │
│ Multisig → Timelock → Parameter changes │
└─────────────────────────────────────────────┘
For each boundary state what is trusted, the damage if compromised, and whether a timelock or multisig exists. Where a delay protects only the role seat and not operational actions, say so explicitly: "1-day delay protects the admin seat, but emergencyWithdraw and setFee execute instantly." If git analysis shows boundary code was frequently modified or carries fix-scored commits, append *Git signal: N modifications, M fix-scored commits — elevated risk.*
Protocol-Type Threat Profiles
Lending / Borrowing
Adversaries, ranked: flash loan attacker (drives oracle manipulation cost to near zero) → oracle manipulator (the oracle is the sole source of truth for solvency) → liquidation MEV searcher (if extraction makes liquidation unprofitable for honest liquidators, bad debt accrues) → malicious first depositor (share-price manipulation on supply/debt tokens) → compromised admin (collateral factors, oracle address, rate model, pause).
Dominant patterns: oracle manipulation → inflated collateral → max borrow → drain pool. Flash borrow → manipulate spot → liquidate victim at wrong price. Bad debt via unliquidatable positions (oracle lag, gas spikes, illiquid collateral). Utilization manipulation to move interest rates. Recursive borrowing amplification.
Critical invariants: totalBorrows <= totalCollateral * LTV per market and per account. Every position is liquidatable before it can cause bad debt. Liquidation is profitable for liquidators. Oracle price is within acceptable deviation and freshness bounds. Interest accrual is monotonic and unmanipulable.
Look first at: the complete price path (oracle read → normalization → collateral value → health factor — every step is a manipulation point); whether one transaction can borrow, manipulate, and liquidate; liquidation math against gas plus slippage; share price when totalSupply == 0; what admin can change instantly versus through timelock.
DEX / AMM
Adversaries, ranked: MEV searcher / sandwich attacker (every swap without adequate slippage protection is a guaranteed extraction) → flash loan price manipulator → malicious first LP / empty pool attacker → liquidity manipulation attacker → compromised admin (fees, pause, routing, pool whitelist).
Dominant patterns: sandwich attacks. LP share inflation on empty pools via donation. Reentrancy through token callbacks (ERC-777, ERC-1155) while pool state is inconsistent. Spot price exploitation when other protocols read the pool as an oracle. Concentrated liquidity tick manipulation. Fee-on-transfer accounting breaks.
Critical invariants: pool invariant holds before and after every operation. LP share value is monotonically non-decreasing from fees. No tokens leave without proportional LP burn or valid swap math. Swap output matches invariant-derived calculation exactly. Tracked reserves match actual balances.
Look first at: swap math rounding direction; LP mint/burn at totalSupply == 0 and minimum liquidity enforcement; whether the pool exposes getPrice()/observe() (then manipulation has external blast radius); where slippage protection is enforced and whether it can be bypassed; state updates versus external calls in the swap path.
Yield Aggregator / Vault
Adversaries, ranked: share inflation attacker (deposit 1 wei, donate a large amount, next depositor rounds to 0 shares) → malicious or compromised strategy (strategies hold the actual funds) → reentrancy through external protocol callbacks → donation / direct-transfer attacker → compromised admin (add strategies, allocation weights, harvester, migrate funds).
Dominant patterns: ERC-4626 share inflation. Strategy reports fake gain, attacker deposits at inflated price, previous depositors diluted. Strategy retains approvals after migration. Harvest sandwich. Accounting desync when the strategy's real balance drifts from recorded allocation (rebasing, slashing, reward accrual).
Critical invariants: totalAssets() accurately reflects real underlying value. convertToShares(convertToAssets(shares)) <= shares and the reverse. Strategy cannot extract more than allocated. Share price rises only from yield.
Look first at: virtual offset or minimum deposit protecting share math; what a strategy may report and who may add or remove strategies; whether totalAssets() uses balanceOf(this) (donation-exposed) or internal accounting; reentrancy protection and state-before-call ordering; whether migration revokes old approvals.
Stablecoin
Adversaries, ranked: oracle manipulator → economic/governance attacker (collateral parameters, stability fees) → bank run attacker (strategic redemption drains the best collateral) → flash loan minter → compromised admin (collateral types, oracle, debt ceilings, pause redemptions).
Dominant patterns: manipulate collateral price, mint at inflated value, sell, leaving the protocol undercollateralized. Algorithmic death spiral. Redemption DoS draining liquid collateral. Governance-set undercollateralized ratios. Staleness arbitrage between mint and redeem.
Critical invariants: every unit is backed by at least the configured collateral ratio. Mint and redeem are inverse — round-trip preserves value. The peg mechanism converges under sell pressure rather than diverging. Liquidation can always restore individual position collateralization. Total supply is within the debt ceiling across all collateral types.
Look first at: the mint path (accepted collateral → valuation → ratio → who can change the ratio); the redeem path under stress and whether all units can redeem simultaneously; liquidation profitability; what governance can change and how fast; whether a 10% depeg self-corrects or amplifies.
Derivatives / Perps
Adversaries, ranked: oracle manipulator (leverage amplifies oracle error — 1% manipulation on 50x is a 50% PnL swing) → liquidation MEV searcher → funding rate manipulator → position size attacker (positions larger than the protocol can pay out) → compromised admin (max leverage, funding parameters, liquidation thresholds, oracle, pause liquidations).
Dominant patterns: oracle manipulation triggering cascade liquidation. Funding rate manipulation via concentrated one-sided open interest. Position size exceeding payout capacity. Stale oracle enabling a risk-free directional bet against offchain price. Cross-margin cascades within one account. ADL manipulation.
Critical invariants: sum of all PnL is zero minus fees. Available liquidity covers maximum payout under worst-case price movement. Liquidation triggers before any position causes bad debt. Funding rate converges open interest imbalance. Mark price cannot deviate from index price beyond safety bounds.
Look first at: PnL math at leverage limits and in both directions; the margin between liquidation trigger and insolvency; how mark price is derived and whether it moves within a block; whether open interest and position size limits are enforced; the maximum funding rate and how fast it can drain margin.
Liquid Staking
Adversaries, ranked: exchange rate manipulator → validator set attacker → withdrawal queue attacker → oracle/rate arbitrageur (exit at stale rate before a slashing report lands) → compromised admin (validator set, fees, oracle, pause withdrawals).
Dominant patterns: rewards or slashing reporting manipulation. Withdrawal queue griefing with spam requests. Rebasing integration bugs in downstream protocols. Validator collusion suppressing rewards. Share price manipulation via direct transfer to the contract.
Critical invariants: exchange rate reflects true underlying value (staked plus rewards minus slashing). Total derivative supply times exchange rate is at most total underlying staked. The withdrawal queue processes in fair order. Slashing is reflected in the rate before any user can exit at the stale rate.
Look first at: who reports rewards and slashing, how often, and whether it is manipulable; the withdrawal queue delay and griefability; who selects validators; whether the derivative rebases or uses shares; how a massive slashing event is socialized.
Bridge
Adversaries, ranked: validator/relayer set attacker (the single largest bridge exploit vector by value lost) → message replay attacker → race condition exploiter (source/destination finality gaps, reorgs) → fake message crafter → compromised admin (validator set, pause, upgrade to drain locked funds).
Dominant patterns: validator key compromise forging messages to mint unbacked tokens. Message replay from a missing or overflowing nonce. Proof verification bypass. Chain ID confusion. Reorg exploitation after destination minting.
Critical invariants: locked tokens on source equal minted tokens on destination. Every message is processed exactly once. Messages cannot be forged without validator threshold consensus. Accounting is consistent across chains.
Look first at: validator count, threshold, and how the set changes; nonce presence, checking, and overflow; proof and signature verification edge cases; finality assumptions on the source chain; whether admin can drain locked funds.
Governance
Adversaries, ranked: flash loan governance attacker (trivial when voting power reads current balance instead of a snapshot) → governance capture attacker (patient accumulation) → proposal spam / griefing attacker → timelock exploitation attacker → compromised guardian (cancel, pause, emergency bypass).
Dominant patterns: flash loan → vote → return. Bribe attacks. Proposal obfuscation hiding malicious calldata. Timelock front-running to position before parameter changes execute. Guardian emergency powers used for non-emergencies.
Critical invariants: voting power is snapshotted at proposal creation. Quorum prevents minority capture. Timelock delay is long enough for users to exit. No single role bypasses governance for non-emergency actions. Proposal calldata matches its description.
Look first at: whether voting power is a snapshot or a current balance; quorum and threshold against actual token distribution; timelock duration; every parameter governance controls; who holds emergency powers and what they can do.
Cross-Cutting Threats (apply to every protocol)
| Threat | Vector | Impact |
|---|
| Reentrancy | External call → callback → re-enter state-changing function | Double-spend, state corruption |
| Access control bypass | Missing modifier, wrong role check, initializer re-call | Unauthorized privileged operations |
| Rounding exploitation | Systematic rounding in the attacker's favor across many operations | Slow value drain |
| Griefing / DoS | Revert on transfer, gas griefing, storage bloat | Protocol unusable |
| Upgrade hijack | Unprotected initializer, storage collision, proxy admin takeover | Complete compromise |
| Centralization | Admin key compromise or malicious admin action | Total loss of funds |
Temporal Threat Phases
Include a "Temporal Risk Profile" subsection covering only the phases that add NEW information beyond Actors, Attack Surfaces, and Upgrade Architecture. Skip Steady State — the main threat model covers it. One to three bullets per phase, each citing a code location and a mitigation status (mitigated / partially mitigated / unmitigated).
Deployment & Initialization — the most dangerous 24-48 hours.
| Threat | What to look for |
|---|
| Initialization front-running | initialize()/init() without access control or the initializer modifier; proxies where initialize runs in a separate transaction from deployment; permissionless pool or market creation |
| Parameter misconfiguration | Test values still active — zero delays, max-uint fees, known test addresses; constructor/initializer params without validation; insecure defaults |
| Ownership not transferred | Ownable with no transferOwnership() in the deploy script; two-step transfer not yet accepted; roles not granted to intended addresses |
| Empty-state exploitation | if (totalSupply == 0) branches; attacker-chosen initial prices or ratios; deposits when totalAssets == 0; missing minimum initial deposit |
| Deployment ordering bugs | Multi-transaction deploy scripts; circular contract references; approval and role-grant chains that must happen in a specific order |
Market Stress — where the largest DeFi losses have come from.
| Threat | What to look for |
|---|
| Oracle latency under volatility | The staleness threshold on latestRoundData() and whether it fits the asset's volatility; deviation checks; behavior when updatedAt is zero or in the future |
| Liquidation cascade | Does liquidation sell collateral on-market (price impact)? Circuit breaker? Throttling? Can it absorb a 30%+ drop in one block? |
| Liquidity evaporation | Liquidation profitability assumptions when depth is thin; assumed swap paths; minimum liquidity requirements |
| Correlated asset depeg | Hardcoded 1:1 price equivalences; using an underlying's oracle for a derivative asset; collateral factors that ignore depeg risk |
| Gas price spikes | Keeper-dependent flows; liquidation incentive versus gas cost; operations bounded by a time window; keeper-failure fallbacks |
| Withdrawal stampede | Withdrawal queues and rate limits; liquid versus deployed share of TVL; how fast strategies unwind; stress-scaling withdrawal fees |
Governance & Upgrade Windows — the transition between old and new state.
| Threat | What to look for |
|---|
| Timelock exploitation window | Whether the delay is long enough to exit; parameters exploitable once their pending value is public |
| Upgrade storage collision | _authorizeUpgrade protection; storage gap usage; whether upgrades are tested against the real layout |
| Flash loan governance | balanceOf(msg.sender) (vulnerable) versus snapshot at proposal creation (immune); borrowability of the governance token |
| Governance capture | Voting power concentration, quorum, cost to buy a passing stake, guardian veto |
| Migration window | V1→V2 transfer mechanisms; whether V1 retains fund access; deadlines; front-runnability |
Deprecation & Wind-down — include only with version-transition evidence.
| Threat | What to look for |
|---|
| Residual funds in deprecated contracts | Are old versions reachable? Do they still hold funds? Is migration forced? |
| Abandoned approval chains | Unlimited approve() versus permit(); any revocation mechanism during migration |
| Dependent protocol breakage | Does this protocol serve as an oracle or data source? Is there a deprecation flag integrators can read? |
| Frozen state exploitation | What breaks if no governance proposal passes for six months? Parameters requiring periodic updates? Automated fallback? |
Composability & Dependency Analysis
Every external call extracted in Step 2 gets classified into the composability taxonomy. For each call determine:
- Target type — oracle, DEX/AMM, lending pool, yield protocol, token, governance, bridge, other
- Assumptions about the return value — correct price, exact token amount, success, specific format
- Validation present — bounds check, staleness check, zero check, success check
- Mutability of external behavior — can it change without this protocol's consent (upgradeable proxy, governed parameters)?
- Fallback on failure — revert, silent failure, fallback value, try/catch that fails open
Report each significant dependency as a blockquote block:
Chainlink ETH/USD — via Oracle.getPrice()
- Assumes: fresh, positive, 8-decimal price
- Validates:
answeredInRound only — NO staleness or deviation check
- Mutability: feed address settable by admin, no timelock
- On failure: reverts
Layer 1 — direct dependency risks. Oracle dependency chains (protocol → oracle → underlying sources; if any source is manipulable within the trust assumptions, the oracle is manipulable). Yield strategy dependencies (the external protocol holds the actual funds; check upgradeability, pausability, emergency withdrawal, loss socialization, retained approvals after migration). Callback reentrancy (guards that are per-function do not stop cross-function reentrancy; ERC-777 tokensReceived, ERC-1155 onERC1155Received, flash loan callbacks, swap callbacks).
Token behavior assumptions. Report only the ones the code does not validate.
| Assumption | Violating Tokens | Impact if Violated |
|---|
| Transfer sends the exact amount | Fee-on-transfer (PAXG, USDT with fee enabled) | Internal accounting exceeds real balance; protocol becomes insolvent |
| Balance changes only on transfer | Rebasing (stETH, AMPL, aTokens) | Accounting drift, share price manipulation |
| Transfer reverts on failure | USDT (returns false without reverting) | Silent transfer failure, lost funds |
| No callback on transfer | ERC-777, ERC-1155 | Reentrancy through the transfer hook |
| 18 decimals | USDC (6), USDT (6), WBTC (8), GUSD (2) | Math errors, massive over/under-valuation |
| No address blocking | USDC, USDT blacklists | Withdrawals blocked, funds trapped |
| Cannot be paused | USDC, USDT | All protocol operations halt |
| Immutable implementation | Upgradeable tokens behind proxies | Behavior changes post-deployment without consent |
Check whether the code uses the balanceOf before/after pattern (handles fee-on-transfer), uses SafeERC20 (handles non-reverting tokens), reads decimals() dynamically, handles rebasing, and whether arbitrary tokens can be used or a whitelist exists.
Layer 2 — shared state risks. Liquidity coupling (two protocols using the same pool; a large action here moves prices there within a block). Oracle sharing (the same feed triggering correlated liquidations, with feedback loops back into this protocol). Approval chain exposure (unlimited approvals to an upgradeable contract mean a future upgrade could drain them; deprecated contracts still holding approvals).
Layer 3 — temporal composability risks. Governance-induced behavior change in a dependency (no contract interaction changed, but economic assumptions broke — for example a lending market lowering a collateral factor under a strategy assuming the old one). Upgrade-induced interface change (same signature, different behavior, revert conditions, or gas). Deprecation without notification (a feed returns stale data silently, or starts reverting into a fail-open try/catch). Dependency-of-dependency upgrades — map the chain two to three levels deep and flag chains deeper than two levels, noting for each level whether it is upgradeable and governed.
Step 5: Invariant Synthesis
Invariants are properties that must hold across ALL state transitions. This step is a reasoning pass over the Step 2 extractions — delta writes, guard predicates, transitions, invariant comments. It needs no new tool calls except the batched write-site Greps in the guard-lift pass.
The output is invariants.md, a catalog of blocks with stable IDs that x-ray.md cross-links into.
Terminology: Guards Are Not Invariants
A guard is a per-call precondition enforced at a single callsite — require(amount >= MIN). It is not falsifiable: the code guarantees it at that callsite. It feeds §1 of invariants.md (Enforced Guards reference) only.
An invariant is a property that must hold globally across any sequence of calls — "every active position is at least MIN". Invariants that are lifted from guards, or stated in NatSpec, feed §2/§3/§4.
ID Scheme
| ID | Section | Meaning |
|---|
G-N | §1 Enforced Guards | Verbatim per-call precondition with location and purpose |
I-N | §2 Inferred, Single-Contract | Conservation / Bound / Ratio / StateMachine / Temporal |
X-N | §3 Inferred, Cross-Contract | Caller assumption paired with callee write sites |
E-N | §4 Economic | Higher-order property derived from specific I-N / X-N |
IDs are stable within a run and are written as #### G-1, #### I-17, #### X-4 H4 headings — never as table rows. H4 headings produce slug anchors (#g-1, #i-17) that cross-file markdown links resolve reliably in VS Code and on GitHub. Inline <a id> anchors inside table cells do NOT work cross-file, which is why referenced IDs must never live in tables.
NatSpec Routing (run before the structural walk)
For each NatSpec @invariant tag or inline comment asserting a global property — "totalSupply always equals Σ balances", "fee never exceeds MAX_BP", "only one active epoch at a time" — route DIRECTLY to §2, or §3/§4 if the property spans contracts or derives from multiple primitives, classified by shape. Source tag: NatSpec: Contract.sol:LN. Do NOT place developer-stated global invariants in §1 — §1 is per-call guards only.
Doc-stated global invariants from Step 1's spec extraction route the same way.
After routing, still run the structural scans. They confirm (Onchain: Yes) or contradict (Onchain: No) the stated claim.
Walk Order
Each step uses the raw extraction data, not prior-step conclusions.
1. Conservation scan. For each function, find delta-write pairs where Δ(A) = +expr and Δ(B) = -expr (or Δ(B) = +expr for a mapping counterpart) in the same body. Each matched pair is a conservation candidate: A + B = const or A == Σ B[key].
- Mapping writes paired with a scalar (
mapping[key] += e plus scalar += e) imply scalar == Σ mapping[key]. Verify the pattern across ALL functions writing either variable. If any function writes one without the other, note it as partial conservation and split into separate Yes/No blocks.
- Transfer patterns (
mapping[from] -= e, mapping[to] += e, no scalar change) confirm the mapping sum is self-conserving.
- Negative conservation matters: a function that ought to track a flow (flashloan pull/push, receive/forward) but has zero storage Δ is a Conservation-negative finding. Absence of Δ is itself an invariant observation.
2. Guard extraction and lift. Two passes over every require / assert / if-revert.
Pass A — extract verbatim. Every guard becomes a G-N block in §1, quoted verbatim with its source location and a purpose line. This is a mechanical dump of per-call preconditions — not falsifiable, not fuzzed. Skip guards that reference only function parameters with no storage tie-back AND carry no global implication.
Pass B — lift, then check all write sites. For each guard ask: does this imply a property that must hold across any sequence of calls, not just at this callsite?
- No (the guard constrains a transient parameter consumed by the function, with no tie to persistent storage) → leave it in §1. Do not promote.
- Yes (
require(amount >= MIN) at deposit implies "every active position ≥ MIN"; require(_fee <= 10) at a setter implies "fee ∈ [0, 10]") → rewrite it as a global property, then locate ALL write sites of the constrained storage variable with Grep on the variable name across scope files. Batch every write-site Grep for every lifted guard into a SINGLE parallel message.
- ALL write sites enforce an equivalent guard → promote to §2 as a Bound invariant,
Onchain: Yes. Derivation cites the guard plus the confirmed write-site enumeration.
- ANY write site writes the variable without an equivalent guard → promote to §2 as a Bound invariant,
Onchain: No, citing the unguarded write site as the gap. This is the high-signal output — the gap is simultaneously an invariant and a potential bug.
Include setter-level bounds where a setter writes a storage variable constrained by its own parameter check. Run the same all-write-site check: if several setters write the same variable but only some enforce the bound, the property is Onchain: No.
3. Ratio scan. For each storage write of the form A = B * C / D where B, C, D are storage variables or function-scoped snapshots of storage, record the ratio. Note whether the snapshot is taken before or after other state changes in the same function — ordering matters (totalSupply snapshotted before _burn versus after).
4. State machine / one-shot scan. For each enum/uint/address in a require(var == X); ... var = Y pattern, record the transition and classify:
- One-shot latch —
require(var == default); var = concrete with no path back (setStrategy, setLeverager). Keep.
- Togglable flag —
require(var == false); var = true but another function flips it back (freeze/unFreeze). NOT a state machine invariant — drop.
- Cyclic state —
false → true → false driven by timing or condition. Record as a cycle invariant.
5. Temporal scan. For each block.timestamp or block.number comparison involving a storage variable (deadline, lastUpdate, lockPeriod, interval), extract the constraint. Note whether it is checked-then-updated (safe) or updated-then-checked (potential stale read).
6. Cross-contract scan. For each external call whose return value feeds arithmetic or a storage write, record what the caller assumes. Then find the callee's write sites for that state. If the callee can change it independently through another function, the assumption is unvalidated — record as X-N with Onchain: No. Include a block ONLY when BOTH sides are inside the scope files; never speculate about out-of-scope contracts.
Also include setter-versus-invariant mismatches — an admin setter writing a storage value without checking that existing invariants still hold (setReserveCapacity without checking current liquidity). These are cross-contract in the sense that the setter sits in one place and the invariant is enforced elsewhere.
7. Economic derivation. After steps 1-6, check whether any combination of single-contract and cross-contract invariants implies a higher-order property. Each E-N must cite the specific I-N / X-N IDs it derives from. If any source invariant in the chain is Onchain: No, the economic invariant is Onchain: No too.
Verification Gate (mandatory before including any inferred invariant)
| Type | Gate |
|---|
| Conservation | The Δ-pair exists at the cited lines, in the same function body |
| Guard (§1) | The require/assert/if-revert is verbatim from code |
| Guard lift (§2) | The lifted property references persistent storage, not a restated transient parameter. All write sites of the constrained variable are enumerated via Grep, and the Yes/No verdict matches the enumeration. A Yes with an unguarded write site is invalid. |
| NatSpec | The tag or comment exists verbatim at the cited location AND asserts a global property. A per-call note is dropped, not routed to §2. |
| Ratio | The formula is exact and snapshot ordering is noted |
| StateMachine | Both sides of the edge exist AND no reverse path exists. A reverse path means it is a togglable flag — drop. |
| Temporal | The comparison involves a storage variable, not just block.timestamp versus a parameter |
| Cross-contract | Both the caller usage and the callee write site exist in scope |
| Economic | Every referenced I-N / X-N is itself verified |
If you cannot verify, drop the block. "Could not verify" is not a valid block.
Cross-Linking Invariants to the Report
This is what makes the invariant map usable rather than decorative. When writing Key Attack Surfaces in x-ray.md Section 2, cross-reference each surface against the invariants.md blocks. If the surface's cited file:line falls within the Location / Derivation / Caller side / Callee side window of any G-N / I-N / X-N / E-N block, append the matching IDs as bracketed markdown links immediately after the surface title, using lowercase slug fragments:
- **`withdrawFromInvestment` unchecked subtraction** [[X-4](invariants.md#x-4), [I-17](invariants.md#i-17)] — ...
Surfaces that are purely access-control or upgradeability concerns may be left unlinked — that is a healthy signal, not a gap. On a non-trivial protocol, expect at least 70% of surfaces to link to at least one invariant. A much lower rate means the invariant walk was too shallow.
The same IDs give downstream agents stable handles: invariant-tester names Foundry invariant tests after them, poc-writer references the Onchain: No blocks it is trying to falsify.
Invariant Categories
| Category | Shape |
|---|
| Conservation | Two or more storage variables change by equal-and-opposite amounts in the same body: Δ(A) = +x, Δ(B) = -x → A + B = const |
| Bound | A guard on a storage variable lifted to a global property and enforced at every write site: require(x <= MAX) at every writer → x ∈ [0, MAX] globally |
| Ratio | A storage variable defined as a formula of others: withdrawAmount = totalBalance * shares / totalSupply |
| StateMachine | A storage variable transitions through discrete values with guards preventing reversal |
| Temporal | A condition depends on block.timestamp, block.number, or a duration/deadline variable |
Step 6: Git History Analysis
Git history reveals what the team worries about, where complexity lives, and what has been fixed.
Branch Scoping (critical)
The analysis is scoped to the current branch only (HEAD). Every git signal — fix candidates, hotspots, dangerous areas, late changes — reflects ONLY commits reachable from HEAD, not other branches.
- State the analyzed branch in the report header or git section: "Analyzed branch:
[branch] at [commit]".
- When describing fix commits or code changes, describe them as what the current branch code does — not what a fix "changed", unless you can see the before and after on this branch.
- Never describe code state from another branch. The source files read in Step 2 are the current branch's files; git history describes how those files evolved on this branch only.
- If the repo is a squashed import (one source-touching commit), there is no meaningful evolution. State that and skip fix and hotspot analysis.
Do NOT pass --all to any of these commands. --all silently pulls in other branches and breaks every claim above.
Collection
Run the bundled analyzer. It is dependency-free and emits structured JSON, which is far more reliable to reason over than a pile of ad-hoc git log output. Issue this in the Step 1 parallel batch:
cd {project-root} && python3 {SKILL_DIR}/scripts/analyze_git_security.py \
--repo . --src-dir {src-dir} --json x-ray/git-security-analysis.json 2>&1 \
&& cat x-ray/git-security-analysis.json
The JSON has eight sections:
| Section | Contents |
|---|
meta | Analyzed repo, source dir, branch, and commit — quote these in the report header |
repo_shape | Commit counts, age, contributor line attribution, squashed-import detection |
fix_candidates | Commits whose subjects suggest a bug or security fix, for scoring below |
dangerous_area_changes | Commits touching security-sensitive areas |
late_changes | Recent source changes — code the team has had least time to review |
forked_deps | Vendored or forked dependencies that diverge from upstream |
tech_debt | TODO / FIXME / HACK / XXX / BUG markers in scope |
dev_patterns | Bus factor, review patterns, merge behavior |
The script scopes itself to HEAD. Do not add --all or substitute hand-written git log commands — both break the branch-scoping guarantees above.
Enumeration (Step 1) already emits overlapping git statistics (git_hotspots, git_large_diffs, git_recent_30d, git_contributors). Use those for the coarse picture and this JSON for the security-specific reasoning; do not run a third set of commands to re-derive either.
Repo Shape
| Classification | Detection | Consequence |
|---|
squashed_import | ≤1 commit touches source files, or ≤3 source commits within 7 days | No development history — skip fix and hotspot analysis, state this explicitly |
normal_dev with bulk import | One commit contributes >85% of all source lines added, but real development follows | Analyze only the post-import commits |
normal_dev | Neither of the above | Full analysis applies |
Fix-Candidate Scoring
Score each source-touching commit. The score is a weighted sum, floored at zero. 10 or higher warrants reading the diff manually. Report commits scoring 5 or higher.
Intent from the commit subject — first match wins, sets the base:
| Signal | Base |
|---|
| security, vulnerab, exploit, attack, CVE-N, reentran, overflow, underflow, front-run, malleab | +8 |
| hotfix, emergency, critical | +6 |
| fix / fixes / fixed, bug, patch, broken | +4 |
| harden, mitigat, protect, restrict, sanitiz, validat | +2 |
| leading feat / add / implement / introduce / support | −1 |
| leading docs: / chore: / ci: / test: / style: / build:, or readme, typo, format, lint, rename, refactor, cleanup, comment | −3 |
Topic tags — checked independently, +2 each, applied only when the base is non-negative: oracle / price / liquidat / slippage / MEV; reentran / overflow / underflow / front-run; ecrecover / permit / signature / nonce.
Structural diff signals — read the diff and count added versus removed:
| Signal | Score |
|---|
Net addition of require/revert/assert | +3 |
Net removal of require/revert/assert | +3 |
| Equal-count rewrite of guards | +2 |
Net addition of onlyOwner/onlyRole/nonReentrant/whenNotPaused/initializer (tightening) | +3 |
| Net removal of the same (loosening) | +3 |
| Equal-count rewrite of access control | +2 |
Touches safeTransfer*/.transfer(/transferFrom/.call{value | +2 |
Touches ecrecover/permit/ECDSA/EIP-712/nonce | +2 |
Touches balance*/totalSupply/exchangeRate/index/reserve | +1 |
Both adding and removing a guard score equally. Adding one may fix a vulnerability; removing one may introduce it. Report the direction so the auditor knows which to expect.
Domain overlap: the commit's files span ≥2 security areas → +3; exactly 1 → +1.
Shape modifiers: 1-3 source files touched (focused) → +2; net code removal → +1; includes test changes → +1; source churn >500 lines → −2; >2000 lines → −4.
Security Areas
Classify each source file by grepping its content, then use the classification for both domain-overlap scoring and the Dangerous Area Evolution table.
| Area | Patterns |
|---|
access_control | onlyOwner, onlyRole, modifier only, AccessControl, Ownable2Step, require(msg.sender, hasRole, _checkRole |
fund_flows | .deposit(, .withdraw(, .transfer(, .mint(, .burn(, collateral, safeTransfer, balanceOf, allowance, approve |
oracle_price | oracle, price, feed, TWAP, markPrice, indexPrice, latestRoundData, getPrice, EMA |
liquidation | liquidat, backstop, ADL, deleverage, insurance, insolvenc, badDebt, isLiquidatable |
signatures | ecrecover, permit, signature, EIP-712, ECDSA, nonce, digest, _hashTypedData |
state_machines | status =, state =, Phase, Stage, lifecycle, transition, paused, frozen, isActive, whenNotPaused |
Areas with high commit counts warrant deeper review — frequent changes to security-critical code correlate with higher defect density.
Forked Dependencies
Check lib/ and vendored directories against known upstreams (OpenZeppelin, Solady, Uniswap V2/V3, Aave, Chainlink, Permit2). Skip forge-std and ds-test.
A library present as a git submodule at a tagged upstream commit is normal. A library internalized into the repo — copied in, no submodule — is hidden attack surface: the team may have introduced bugs while adapting it, and upstream security fixes will not propagate. Compare pragmas against the expected upstream range; a pragma mismatch is strong evidence of modification.
Interpreting Signals
| Signal | Meaning | Action |
|---|
| File changed 20+ times in 3 months | Unstable or complex — high bug density | Prioritize for depth analysis |
| Fix commits concentrated in one file | Known bug area | Check whether the fixes are complete |
| Single author on a critical contract | Bus factor 1, less review | Extra scrutiny |
| Zero merge commits | Likely no peer review process | Flag in Review Signals |
| Low test co-change rate | Source changed without test files changing | Flag as co-modification risk, NOT as a coverage claim |
| Reverted commits | Something went wrong | Investigate what and why |
| Large commit touching many files | Refactor or rushed feature | Check for regressions |
| Burst of commits right before audit | Late unreviewed changes | Highest-priority review targets |
| TODO/FIXME in a security-critical path | Acknowledged incomplete work | Cross-reference against attack surfaces |
The test co-change rate measures file co-modification within commits. It is NOT a coverage metric. Never claim "commits without tests" on the basis of a coverage tool failure.
If any git command fails, fall back to plain git log statistics. Never block on git analysis.
Step 7: Architecture Graph & Diagram
Build a machine-readable graph, then render and validate a diagram from it.
architecture.json Format
{
"title": "[Protocol] Architecture",
"nodes": [
{"id": "vault", "label": "Vault", "subtitle": "Coordinator", "type": "protocol", "row": 1}
],
"edges": [
{"from": "user", "to": "vault", "label": "deposit assets"}
],
"groups": [
{"label": "Vault Layer", "nodes": ["vault", "strategy"]}
]
}
Node types: actor for users and roles, protocol for in-scope contracts, external for out-of-scope dependencies. subtitle is a short role line; for composite nodes list the individual contracts there.
Assign row to minimize edge distance, not by node type. Place each node on the row adjacent to its primary caller. Actors usually land at the top and leaf dependencies at the bottom, but an external node called only from row 1 belongs on row 2, not on a distant "externals" row.
Group containment rule. Every node must be either inside exactly one group, or on a row that has no group box at all. A group box spans all rows containing its nodes, so an ungrouped node sharing a row with a group will visually escape or overlap it. Fix by adding it to the right group or moving it to another row. Classify a node's group by its primary caller — an ACL contract called by the coordinator belongs in the coordinator's group, not a downstream infrastructure group.
Budgets
| In-scope contracts | Max nodes | Max edges | Max per row |
|---|
| ≤10 | 12 | 14 | 4 |
| 11-20 | 16 | 18 | 4 |
| 21-35 | 20 | 22 | 5 |
| 36+ | 24 | 26 | 5 |
Prioritize completeness over compression. Every contract that holds funds, gates access, or sits on a critical call path must be visible — as its own node or clearly named in a composite node's subtitle.
Compositing Rules
Apply in order; use the first tier that fits the budget.
- Always composite — same subsystem, identical caller AND callee. Label with the subsystem name, list contracts in the subtitle.
- When budget requires — same primary caller OR callee. Helper and satellite contracts fold into their parent node.
- Last resort — same subsystem, same trust level, different callers and callees.
Never composite across trust levels. Merging permissionless and admin-only nodes hides trust boundaries. Combine actors only when they share both trust level and capabilities. External dependencies that are the sole data source for critical logic (oracles, price feeds) get their own node.
Layout Rules
- At most 2 same-row arcs per node. If 3 or more are needed, move one target to an adjacent row.
- Balance directions: with 2 same-row arcs from one node, send one left and one right.
- A node with 3+ same-row connections is a hub — position it centrally among its same-row targets in the
nodes ordering so arcs fan out cleanly.
- Route a long same-row arc below the row when it would otherwise cross intermediate boxes. Stagger multiple below-arcs at different depths.
- Every edge label is unique — never repeat a label. Two to three words max.
- No row-skipping edges. Every edge connects adjacent rows or the same row. If an edge would span two or more rows, move the target to an adjacent row.
- Show primary interaction flows only, not every internal call.
Render and Validate
Do NOT hand-author the SVG. Write architecture.json, then generate the SVG with the bundled script:
python3 {SKILL_DIR}/scripts/generate_svg.py x-ray/architecture.json x-ray/architecture.svg
The generator is dependency-free and handles all styling and geometry: rounded node boxes, an accent stripe by node type (protocol, external, and pill-shaped actor), labeled arrows, and group enclosure rects. It also auto-assigns layers via longest-path when row is omitted, so hand-tuning rows is optional rather than required.
Because layout is deterministic, the failure modes that remain are content problems in the JSON, not rendering problems. Optionally rasterize for a sanity check — use the first renderer that works:
convert -density 300 x-ray/architecture.svg /tmp/architecture-preview.png
rsvg-convert x-ray/architecture.svg -o /tmp/architecture-preview.png
python3 -c "import cairosvg; cairosvg.svg2png(url='x-ray/architecture.svg', write_to='/tmp/architecture-preview.png', scale=3)"
If no renderer is available, skip the visual check — the SVG is still valid. Note the skip in the report.
Content checklist — fix in architecture.json and regenerate, max 2 iterations:
- Budgets respected — node, edge, and per-row counts within the table above.
- No row-skipping edges — every edge connects adjacent rows or the same row.
- Group containment — every node is in exactly one group, or on a row with no group box.
- Trust levels intact — no composite merges a permissionless node with an admin-only one.
- Critical contracts visible — everything that holds funds, gates access, or sits on a critical path appears as a node or is named in a composite's subtitle.
- Edge labels unique — two to three words, never repeated.
Never edit the generated SVG directly. It is a build artifact; the next regeneration discards the edit.
Cleanup
rm -f x-ray/architecture.json /tmp/architecture-preview.png
Step 8: Report Generation
Test Existence vs Coverage Execution (critical)
These are independent signals and conflating them produces false claims.
Test presence comes from Step 1 enumeration — test_files, test_functions, stateless_fuzz, foundry_invariant, echidna, medusa, hardhat_fuzz, fork, certora, halmos, hevm. These are file-scan results and are ALWAYS reliable, regardless of whether the toolchain can compile or run.
Coverage metrics (line and branch percentages) come from forge coverage or hardhat coverage, which need installed dependencies, successful compilation, and passing tests. Coverage fails for reasons unrelated to test quality: dependencies not installed, stack-too-deep, compiler version mismatch, missing RPC or fork config.
Rules:
- Use
test_files / test_functions from enumeration for ALL test existence claims. Never infer "no tests" from a coverage tool failure.
- Never report a coverage number that was not actually produced. If coverage fails, write
"[N] test files with [M] test functions detected; coverage metrics unavailable — [failure reason]". If it is still running, write Pending. Do not estimate, interpolate, or carry over a number from a previous run.
- In the Gaps subsection, flag only missing test categories (
stateless_fuzz = 0, foundry_invariant = 0, and so on). Never claim "no tests" when enumeration found test files. Prioritize by audit impact: missing stateful fuzz and formal verification on math-heavy financial logic outranks missing fork tests.
- In git Security Observations, never claim "commits without tests" from a coverage failure.
test_co_change_rate measures file co-modification in commits — qualify it as such.
- Do not let a coverage failure cascade into the threat model or risk assessment.
Check the background coverage job's status once: include results if done, the failure reason if failed, Pending if still running. Do NOT wait.
Write All Output Files in Parallel
Write x-ray.md, entry-points.md, invariants.md, and architecture.json in a SINGLE message so they are created concurrently.
x-ray.md Template
# X-Ray Pre-Audit Report
> [Protocol] | [nSLOC] nSLOC | [short-hash] (`[branch]`) | [framework] | [DD/MM/YY]
---
## 1. Protocol Overview
**What it does:** [One sentence — the core mechanism.]
- **Users**: [Who interacts and why]
- **Core flow**: [The main user-facing operation]
- **Key mechanism**: [AMM type, vault model, oracle design]
- **Token model**: [What tokens exist and their roles]
- **Admin model**: [Who controls what]
For a visual overview see the [architecture diagram](architecture.svg).
### Contracts in Scope
[Group by subsystem — one row per subsystem, not per file.]
| Subsystem | Key Contracts | nSLOC | Role |
|-----------|--------------|------:|------|
[Only protocol-authored contracts and libraries. No interfaces, no vendored libs.]
### Backwards-Compatibility Code
[Only if remnants survived the Step 2c verification checks. Omit the subsection entirely otherwise.]
- `[contract:function]` — [what it belonged to, why retained, that it is not active functionality]
### How It Fits Together
[Start with "The core trick:" — one sentence on the fundamental mechanism. Then 3-5 key
flows as annotated call chains with tree branching, italic annotations on critical steps
(state changes, callback windows, payment verification). Use concrete contract names, not
interface names. Skip governance/admin/oracle flows — Section 2 covers those.]
---
## 2. Threat & Trust Model
### Protocol Threat Profile
> Protocol classified as **[Primary]** with **[Secondary]** characteristics
[1-2 sentences on why, from code signals.]
### Actors & Adversary Model
| Actor | Trust Level | Capabilities |
|-------|-------------|-------------|
[Only named roles from code. No "Anyone". Never "Semi-trusted" — use "Bounded (reason)".
Capabilities must distinguish instant from timelocked actions and note pause coverage gaps.]
**Adversary Ranking** (by threat level for this protocol type, adjusted by git evidence):
1. **[Adversary]** — [ONE sentence: who they are, why relevant here.]
[3-5 entries. No attack mechanics, no code refs — those live in Key Attack Surfaces.]
See [entry-points.md](entry-points.md) for the full permissionless entry point map.
### Trust Boundaries
- **[Boundary]** — [protection status + the single worst instant action it leaves open + code ref. Max 2 lines.]
### Key Attack Surfaces
[The single authoritative location for surface detail. Sorted by priority score: protocol-type
relevance + git hotspot + fix history + late changes + dangerous area churn. Not alphabetical.
No RISK labels. No mitigation analysis. Blank line between bullets. Hard cap 2 lines each.]
- **[Surface name]** [[X-N](invariants.md#x-n)] — [code ref + the concern + what to trace to confirm or dismiss it.]
### Upgrade Architecture Concerns
[Only if upgradeable contracts exist. Uninitialized implementations, storage gap consistency,
missing upgrade timelock, blast radius of shared contracts, placeholder proxy windows.]
### Protocol-Type Concerns
**As a [Primary type]:**
- [Code ref + technical concern: math precision, curve edge case, share rounding direction.]
[2-3 bullets per type. Skip anything already a Key Attack Surface. Every bullet cites a
specific contract and function — no generic protocol-type advice.]
### Temporal Risk Profile
[Only phases adding NEW information beyond Actors, Attack Surfaces, and Upgrade Architecture.
Skip Steady State. 1-3 bullets per phase, each with a code location and mitigation status.]
### Composability & Dependency Risks
> **[External Name]** — via `[contract:function]`
> - Assumes: [assumptions about return value and behavior]
> - Validates: [checks present, or NONE]
> - Mutability: [Immutable / Upgradeable by X / Governed by X]
> - On failure: [revert / fallback / fail-open]
**Token Assumptions** *(unvalidated only)*:
- [Token type]: assumes [unvalidated assumption] — impact if violated: [consequence]
**Shared State Exposure** *(if applicable)*:
- [Shared pools or oracles, who else shares them, whether this protocol's actions affect others]
---
## 3. Invariants
> ### Full invariant map: **[invariants.md](invariants.md)**
>
> - **[N] Enforced Guards** (`G-1` … `G-N`) — per-call preconditions with check, location, purpose
> - **[N] Single-Contract Invariants** (`I-1` … `I-N`) — Conservation, Bound, Ratio, StateMachine, Temporal
> - **[N] Cross-Contract Invariants** (`X-1` … `X-N`) — caller/callee pairs crossing scope boundaries
> - **[N] Economic Invariants** (`E-1` … `E-N`) — higher-order properties from `I-N` + `X-N`
>
> Every inferred block cites a concrete Δ-pair, guard-lift with write sites, state edge,
> temporal predicate, or NatSpec quote. The **Onchain: No** blocks are the high-signal ones —
> each is simultaneously an invariant and a potential bug. Attack surfaces above cross-link
> directly into the relevant blocks.
[Section 3 is a POINTER, not a catalog. Do NOT duplicate the invariants here.]
---
## 4. Documentation Quality
| Aspect | Status | Notes |
|--------|--------|-------|
| README | [Present/Missing] | |
| NatSpec | [~N annotations] | [Coverage notes] |
| Spec/Whitepaper | [Present/Missing] | |
| Inline Comments | [Sparse/Adequate/Thorough] | [Notable gaps] |
[Tag spec-derived claims `(per spec)` versus `(per code)`.]
---
## 5. Test Analysis
| Metric | Value | Source |
|--------|-------|--------|
| Test files | [N] | File scan (always reliable) |
| Test functions | [N] | File scan (always reliable) |
| Line coverage | [N% / Pending / Unavailable — reason] | Coverage tool (requires compilation) |
| Branch coverage | [N% / Pending / Unavailable — reason] | Coverage tool (requires compilation) |
### Test Depth
| Category | Count | Contracts Covered |
|----------|-------|-------------------|
| Unit | | |
| Integration | | |
| Fork | | |
| Stateless Fuzz | | |
| Stateful Fuzz (Foundry) | | |
| Stateful Fuzz (Echidna / Medusa) | | |
| Formal Verification (Certora / Halmos / HEVM) | | |
[Always include Unit, Stateless Fuzz, at least one Stateful Fuzz row, and at least one Formal
Verification row even at 0 — their absence is audit-relevant. Consolidate other zero rows into Gaps.]
### Gaps
[Missing categories only, prioritized by audit impact. Never claim "no tests" when files exist.
Reference specific untested functions, not just percentages.]
---
## 6. Developer & Git History
> Analyzed branch: `[branch]` at `[commit]`. Repo shape: [normal_dev / squashed_import] — [one sentence].
### Contributors
| Author | Commits | Source Lines (+/-) | % of Source Changes |
|--------|--------:|--------------------|--------------------:|
[Flag single-developer dominance (>90%), ghost contributors, uneven distribution.]
### Review & Process Signals
| Signal | Value | Assessment |