# exploit-investigator

Use when the user provides a transaction hash (0x...) with a chain name, asks to investigate an on-chain incident or exploit, or points to an incident brief file.

- **Kind:** skill
- **Source:** https://github.com/DarkNavySecurity/web3-skills
- **Page:** https://forefy.com/skills/09090310-28ea-477d-89e7-d06da379f5bf
- **API (JSON + files):** https://forefy.com/api/asr/09090310-28ea-477d-89e7-d06da379f5bf

---

## .env.example

```

```

## README.md

# Exploit Investigator

An AI-powered multi-agent pipeline for investigating on-chain attack transactions. Produces comprehensive incident reports with root-cause analysis, self-correcting Analyst-Validator debate, and optional Foundry PoC exploits — in minutes.

Built for:

- **Security researchers** who want fast root-cause analysis before a manual deep-dive
- **Protocol teams** investigating their own incidents
- **CTF competitors** working through DeFi exploit challenges
- **Anyone learning** how on-chain attacks actually work

## Usage

```bash
# Analyze a transaction (chain: eth | bnb | arb | polygon | opt | avax | base)
/exploit-investigator 0x<tx_hash> <chain>

# Analyze with extra hints (suspected contract, attack type, etc.)
/exploit-investigator 0x<tx_hash> eth "suspected price manipulation on FooPair"

# Analyze from a pre-written brief file
/exploit-investigator briefs/incident.md

# Generate a Foundry PoC for an already-analyzed incident
/exploit-investigator poc 0x<tx_hash>
```

## Pipeline

```
1. Parse input         → tx_hash, chain, hints
2. Setup              → analysis_0x{hash}/incident_brief.md
3. Planner     → analysis_plan.json, call trace
4. Data Collector     → data_manifest.json, contract sources
5. Manifest Check     → auto-corrects data manifest
6–7. Debate Loop      → Analyst writes report; Validator challenges; repeat ≤2×
8. Report             → analysis_0x{hash}/report.md
9. PoC [optional]     → analysis_0x{hash}/poc/test/Exploit.t.sol
```

The Analyst-Validator debate loop is the core quality mechanism: the Validator
challenges every factual claim against on-chain RPC data, and the Analyst must
accept corrections or rebut with evidence. Reports pass only when no critical
issues remain.

## Output

All output lands in your working directory:

```
analysis_0x{hash}/report.md              ← incident report
analysis_0x{hash}/validation.json        ← validation result
analysis_0x{hash}/poc/test/Exploit.t.sol ← PoC (if requested)
```

The final report covers:
- **Executive summary** — what happened, in plain language
- **Root cause** — vulnerable contract, function, and code snippet
- **Attack execution** — step-by-step call trace walkthrough
- **Financial impact** — attacker profit in tokens and USD

## Setup

### 1. Install the skill

```bash
# Clone into Claude's skills directory
git clone https://github.com/DarkNavySecurity/exploit-investigator ~/.claude/skills/exploit-investigator
```

### 2. Set up Python environment

```bash
cd ~/.claude/skills/exploit-investigator
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
```

### 3. Configure API keys

```bash
cp .env.example .env
# Edit .env and fill in at minimum ALCHEMY_API_KEY
```

`ALCHEMY_API_KEY` is required for all RPC calls. `ETHERSCAN_API_KEY` is optional but recommended — without it, source fetching falls back to Etherscan's public rate limit.

### 4. Start Gigahorse TAC server (optional, for unverified contracts)

The Decompiler agent uses disassembly + LLM reasoning by default. If you also want Gigahorse TAC for higher-quality control-flow analysis on unverified contracts, see [references/gigahorse-tac-server.md](references/gigahorse-tac-server.md).

### 5. Install Foundry (optional, for PoC generation)

```bash
curl -L https://foundry.paradigm.xyz | bash
foundryup
```

`cast` is used for selector resolution; `forge` is needed only to compile and run PoC tests.

## Configuration

| Variable | Required | Description |
|---|---|---|
| `ALCHEMY_API_KEY` | **Yes** | Alchemy API key for RPC access |
| `ETHERSCAN_API_KEY` | No | Etherscan API key for contract source fetching |

## Supported Chains

| Alias | Chain | Chain ID |
|---|---|---|
| `eth` | Ethereum | 1 |
| `bnb` | BNB Chain | 56 |
| `arb` | Arbitrum One | 42161 |
| `polygon` | Polygon | 137 |
| `opt` | Optimism | 10 |
| `avax` | Avalanche | 43114 |
| `base` | Base | 8453 |

## Architecture

The pipeline uses five specialized agents, each with a focused role:

| Agent | Model | Role |
|---|---|---|
| Planner | Sonnet | Fetch call trace, identify contracts, decide fetch strategy |
| Data Collector | Sonnet | Fetch tx data, contract sources, decode calls |
| Analyst | Sonnet | Write the incident report |
| Validator | Sonnet | Challenge every claim; block pipeline on critical errors |
| PoC Generator | Sonnet | Write and iterate on a Foundry exploit test |

Agents communicate exclusively through files — no state is passed directly between them. This makes each step auditable and resumable.

## Python Scripts

The `scripts/` directory contains data-fetching utilities used by the pipeline:

| Script | Purpose |
|---|---|
| `fetch_sourcecode.py` | Fetch verified contract source from Etherscan v2 |
| `fetch_tac.py` | Fetch Gigahorse TAC decompilation; optionally recover to Solidity via LLM |
| `funds_flow.py` | Parse receipt + trace → `funds_flow.json` with token flows and attacker profit |
| `check_manifest.py` | Validate and auto-correct `manifest.json` |
| `decode_calldata.py` | Decode call trace using ABI files and `cast 4byte` fallback |

## Known Limitations

**Unverified contracts.** The pipeline degrades gracefully when source code is not available on Etherscan. TAC decompilation fills the gap, but LLM-recovered Solidity is approximate — treat it as a hypothesis, not ground truth.

**Multi-transaction attacks.** Provide all related tx hashes in a brief file, annotated with roles (setup, exploit). The pipeline names directories after the primary exploit tx.

**RPC coverage.** `debug_traceTransaction` is required. Not all public RPC endpoints support it — Alchemy's Growth plan and above do.

**PoC accuracy.** Generated PoC tests are starting points, not guaranteed-working exploits. The PoC Generator iterates up to three times and classifies results as `PASS`, `PASS_WITH_WARNINGS`, or `FAIL`.

## SKILL.md

---
name: exploit-investigator
description: >
  Use when the user provides a transaction hash (0x...) with a chain name,
  asks to investigate an on-chain incident or exploit, or points to an incident brief file.
---

# Exploit Investigator — Orchestration Guide

You are the **orchestrator**. You parse user input, spawn specialized agents via the Task tool, check outputs after each step, and report progress. Agents communicate exclusively through files; you never pass findings directly between agents.

Read `references/pipeline.md` for the full step-by-step pipeline before starting. This file contains the exact prompts to pass to each agent, error handling rules, and the debate loop logic.

## Skill Directory

This skill is installed at `~/.claude/skills/exploit-investigator/` (the standard Claude Code skills path). All file references below use `{SKILL_DIR}` as shorthand for this path.

```
{SKILL_DIR}/
├── references/
│   ├── pipeline.md          ← full orchestration instructions (read this first)
│   └── prompts/
│       ├── planner.md       ← Planner agent instructions
│       ├── data_collector.md
│       ├── decompiler.md    ← Decompiler subagent instructions
│       ├── analyst.md
│       ├── validator.md
│       └── poc_generator.md
├── foundry_template/        ← Foundry project template for PoC generation
│   ├── foundry.toml
│   ├── src/
│   └── test/BaseExploit.t.sol
└── scripts/                 ← Python data-fetching utilities
    ├── check_manifest.py
    ├── fetch_sourcecode.py
    ├── fetch_tac.py
    ├── funds_flow.py
    ├── decode_calldata.py
    └── tac_server.py       ← copy to gigahorse-toolchain root to run TAC server
```

**When spawning agents**, pass them the absolute path to the prompt file. Example:
> Read `~/.claude/skills/exploit-investigator/references/prompts/planner.md`, then execute the instructions with: ...

## Version Check

Before starting the pipeline, run two parallel tool calls: (a) Read `~/.claude/skills/exploit-investigator/VERSION`, (b) Bash `curl -sf https://raw.githubusercontent.com/DarkNavySecurity/web3-skills/main/exploit-investigator/VERSION`. If the remote fetch succeeds and the versions differ, print:

> ⚠️ You are not using the latest version. Please upgrade for best security coverage.

Skip silently on failure. Then continue with the pipeline.

## Working Directory

Operate from the user's **current working directory** (wherever they invoked the skill). Analysis output — `analysis_0x*/` — is created there. All artifacts (report, validation, PoC) are consolidated under that directory.

## Python Environment

All Python scripts and the virtual environment are located under the skill installation directory — **always look here first**:

- Scripts: `{SKILL_DIR}/scripts/`
- venv: `{SKILL_DIR}/.venv/`

Run scripts with the skill-local venv:

```bash
source ~/.claude/skills/exploit-investigator/.venv/bin/activate
python3 ~/.claude/skills/exploit-investigator/scripts/check_manifest.py ...
```

The venv is set up once during installation (`pip install -r requirements.txt`). If `{SKILL_DIR}/.venv/` does not exist, tell the user to run the setup steps from the README.

## Agent Team

| Agent | Model | Prompt File | Key Output |
|-------|-------|-------------|------------|
| Planner | sonnet | `{SKILL_DIR}/references/prompts/planner.md` | `analysis_plan.json`, `trace_callTracer.json` |
| Data Collector | sonnet | `{SKILL_DIR}/references/prompts/data_collector.md` | `data_manifest.json`, contract dirs |
| Decompiler | sonnet | `{SKILL_DIR}/references/prompts/decompiler.md` | `recovered.sol`, `selector_map.json`, `decompile_meta.json` |
| Analyst | sonnet | `{SKILL_DIR}/references/prompts/analyst.md` | `{analysis_dir}/report.md`, `manifest.json` |
| Validator | sonnet | `{SKILL_DIR}/references/prompts/validator.md` | `{analysis_dir}/validation.json` |
| PoC Generator | sonnet | `{SKILL_DIR}/references/prompts/poc_generator.md` | `{analysis_dir}/poc/test/Exploit.t.sol` |

Note: Decompiler is not a standalone pipeline stage — it is spawned by Data Collector on demand for unverified contracts (max 5 concurrent).

## Chain Config

RPC URL pattern: `https://{chain}-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY`
(Read `ALCHEMY_API_KEY` from the env or `.env` in the working directory.)

| Chain | Chain ID |
|-------|----------|
| eth | 1 |
| bnb | 56 |
| arb | 42161 |
| polygon | 137 |
| opt | 10 |
| avax | 43114 |
| base | 8453 |

## Pipeline Overview

```
1. Parse input        → tx_hash, chain, hints
2. Setup directory    → analysis_0x{hash}/incident_brief.md
3. Planner Agent      → analysis_plan.json  [REQUIRED]
4. Data Collector     → data_manifest.json  [REQUIRED]
5. Manifest check     → python3 {SKILL_DIR}/scripts/check_manifest.py
6-7. Analyst-Validator Debate Loop (max 2 rounds)
   6a. Analyst        → {analysis_dir}/report.md + manifest.json
   6b. Manifest check
   6c. Validator      → {analysis_dir}/validation.json
   6d. If no CRITICAL → done
   6e. If CRITICAL + round < 2 → revise
   6f. If CRITICAL + round == 2 → FAIL
8. Report results to user
9. PoC Generator      → only if user explicitly requests
```

**See `references/pipeline.md` for complete instructions on each step**, including exact agent prompts, file existence checks, issues.json monitoring, and debate loop revision guidance.

## Key Rules

- **Never auto-run PoC generation.** Only spawn PoC Generator when the user explicitly asks.
- **Stop on missing required outputs.** If `analysis_plan.json` or `data_manifest.json` is absent, report the error and stop.
- **Warn, don't stop, on optional files.** `funds_flow.json`, `decoded_calls.json`, `selectors.json` are optional — warn but continue.
- **Check `issues.json` after every step.** Critical issues require user confirmation before proceeding.
- **Manifest check failures are warnings.** Exit code 1 from `check_manifest.py` → warn user and continue.
- **Multi-tx attacks**: Brief may list multiple tx hashes with roles. Pass all to Planner. Name the analysis dir after the PRIMARY (exploit) tx.

## VERSION

```

```

## foundry_template

```

```

## foundry_template/foundry.toml

```toml

```

## foundry_template/src

```

```

## foundry_template/src/.gitkeep

```

```

## foundry_template/test

```

```

## foundry_template/test/BaseExploit.t.sol

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Test, console} from "forge-std/Test.sol";

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function decimals() external view returns (uint8);
    function symbol() external view returns (string memory);
}

/// @title BaseExploit -- shared utilities for exploit PoC tests
/// @dev Inherit this in each incident's ExploitTest contract.
abstract contract BaseExploit is Test {

    // ---- Snapshots ----

    struct BalanceSnapshot {
        address token;
        address account;
        uint256 balance;
    }

    BalanceSnapshot[] internal _snapshots;

    /// @notice Record the current balance of `account` in `token`.
    function snapshotBalance(address token, address account) internal returns (uint256 idx) {
        idx = _snapshots.length;
        _snapshots.push(BalanceSnapshot({
            token: token,
            account: account,
            balance: IERC20(token).balanceOf(account)
        }));
    }

    /// @notice Return the positive balance change since the snapshot was taken.
    ///         Reverts if balance decreased -- use signedBalanceDiff instead.
    function balanceDiff(uint256 snapshotIdx) internal view returns (uint256) {
        BalanceSnapshot memory s = _snapshots[snapshotIdx];
        uint256 current = IERC20(s.token).balanceOf(s.account);
        require(current >= s.balance, "balanceDiff: balance decreased, use signedBalanceDiff");
        return current - s.balance;
    }

    /// @notice Return signed balance change (int256).
    function signedBalanceDiff(uint256 snapshotIdx) internal view returns (int256) {
        BalanceSnapshot memory s = _snapshots[snapshotIdx];
        uint256 current = IERC20(s.token).balanceOf(s.account);
        require(current <= uint256(type(int256).max), "signedBalanceDiff: balance overflow");
        require(s.balance <= uint256(type(int256).max), "signedBalanceDiff: snapshot overflow");
        return int256(current) - int256(s.balance);
    }

    // ---- Assertions ----

    /// @notice Assert that `who` currently holds approximately `expected` of `token` (absolute balance check).
    /// @param toleranceBps Allowed deviation in basis points (100 = 1%).
    function assertBalance(
        address token,
        address who,
        uint256 expected,
        uint256 toleranceBps
    ) internal view {
        uint256 actual = IERC20(token).balanceOf(who);
        uint256 delta = actual > expected ? actual - expected : expected - actual;
        uint256 maxDelta = (expected * toleranceBps) / 10_000;
        assertLe(delta, maxDelta, string.concat(
            "Balance mismatch for ", vm.toString(who),
            ": expected ", vm.toString(expected),
            ", got ", vm.toString(actual),
            " (tolerance ", vm.toString(toleranceBps), " bps)"
        ));
    }

    /// @notice Assert that `who` gained approximately `expected` of `token`,
    ///         relative to a previously taken snapshot.
    function assertProfitFromSnapshot(
        uint256 snapshotIdx,
        uint256 expectedGain,
        uint256 toleranceBps
    ) internal view {
        uint256 actual = balanceDiff(snapshotIdx);
        uint256 delta = actual > expectedGain ? actual - expectedGain : expectedGain - actual;
        uint256 maxDelta = (expectedGain * toleranceBps) / 10_000;
        BalanceSnapshot memory s = _snapshots[snapshotIdx];
        assertLe(delta, maxDelta, string.concat(
            "Profit mismatch for ", vm.toString(s.account),
            " in token ", vm.toString(s.token),
            ": expected gain ", vm.toString(expectedGain),
            ", actual gain ", vm.toString(actual),
            " (tolerance ", vm.toString(toleranceBps), " bps)"
        ));
    }

    // ---- Helpers ----

    /// @notice Override to label all relevant addresses.
    function labelContracts() internal virtual;

    /// @notice Log a balance with human-readable decimals.
    function logBalance(string memory label, address token, address account) internal view {
        uint256 bal = IERC20(token).balanceOf(account);
        uint8 dec = IERC20(token).decimals();
        console.log("%s raw: %s (decimals: %s)", label, bal, dec);
    }
}
```

## references

```

```

## references/gigahorse-tac-server.md

# Gigahorse TAC Server Setup

The Decompiler agent auto-detects a local Gigahorse TAC server and uses it to augment disassembly-based recovery. This is **optional** — the agent works without it, but TAC provides better control-flow structure for complex contracts.

## How it works

The Decompiler agent checks for the server before starting:

```bash
curl -s --max-time 2 http://127.0.0.1:8787/health
```

If the health check succeeds, it sends the contract bytecode to `/analyze` and uses the resulting TAC file alongside disassembly. `decompile_meta.json` will record `"tac_server_used": true`.

---

## Setup

### 1. Clone Gigahorse

```bash
git clone https://github.com/nevillegrech/gigahorse-toolchain
cd gigahorse-toolchain
```

### 2. Save `tac_server.py`

Place `tac_server.py` at the root of the cloned repo (`gigahorse-toolchain/tac_server.py`). The file is included in this skill at `scripts/tac_server.py` — copy it over:

```bash
cp ~/.claude/skills/exploit-investigator/scripts/tac_server.py .
```

### 3. Patch `bin/run_docker`

Add `-p 8787:8787` to the `docker run` call in `bin/run_docker` so the TAC service port is exposed to the host:

```bash
# Find the docker run line and add the port flag, e.g.:
# docker run --rm -it -p 8787:8787 ...
```

### 4. Start the container and run the server

```bash
bin/run_docker
# inside the container:
python3 /opt/gigahorse/gigahorse-toolchain/tac_server.py
```

### 5. Verify

```bash
curl http://127.0.0.1:8787/health
# → {"ok": true}
```

---

## API

| Endpoint | Method | Description |
|---|---|---|
| `/health` | GET | Liveness check — returns `{"ok": true}` |
| `/analyze` | POST | Accepts `{"bytecode": "0x...", "address": "0x..."}`, returns `{"ok": true, "tac_path": "..."}` |

The `tac_path` in the response is relative to `REPO_ROOT`. `fetch_tac.py` resolves it against `--gigahorse-root` (default: `~/gigahorse-toolchain/`).

## references/pipeline.md

# Pipeline Reference — Full Step-by-Step Instructions

## Step 1: Parse Input

Extract from the user's message or a brief file:
- `tx_hash` (required) — normalize to `0x` prefix, full 66-char hex
- `chain` (required) — one of: `eth|bnb|arb|polygon|opt|avax|base`
- `hints` (optional) — suspected contracts, attack type, any context

If the user points to a brief file (e.g. `briefs/reusd.md`), read it and extract these fields from its structured content.

**Multi-tx attacks**: A brief may list multiple tx hashes with roles like `(setup)`, `(exploit)`. Pass all to Planner. The analysis directory is named after the PRIMARY (exploit) tx.

---

## Step 2: Setup

```bash
mkdir -p analysis_0x{full_tx_hash}
```

Write `analysis_0x{full_tx_hash}/incident_brief.md`:
```markdown
# Incident Brief

## Transaction
- **Hash**: {tx_hash}
- **Chain**: {chain}

## Context
{any hints, suspected contracts, attack type, etc.}
```

---

## Step 3: Planner Agent

**Spawn:**
```
Task(model=sonnet, subagent_type=general-purpose)
```

**Prompt:**
> Read `~/.claude/skills/exploit-investigator/references/prompts/planner.md`, then execute the instructions with:
> - Analysis directory: `analysis_0x{full_tx_hash}/`
> - Chain: `{chain}`
> - RPC URL: `https://{chain}-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY`
>   (read key from `ALCHEMY_API_KEY` env var or `.env` in current working directory)

**Required check:** `analysis_0x{hash}/analysis_plan.json` must exist. If absent → stop and report error.

**Optional files** (verify but don't fail if missing):
- `analysis_0x{hash}/trace_prestateTracer.json` — storage state diff
- `analysis_0x{hash}/selectors_raw.json` — raw selector list from trace

**Check issues.json** (see Issues Monitoring section).

---

## Step 4: Data Collector Agent

**Spawn:**
```
Task(model=sonnet, subagent_type=general-purpose)
```

**Prompt:**
> Read `~/.claude/skills/exploit-investigator/references/prompts/data_collector.md`, then execute the instructions with:
> - Analysis directory: `analysis_0x{full_tx_hash}/`
> - Chain: `{chain}`
> - Chain ID: {chain_id}

The Data Collector now spawns **Decompiler Subagents** in parallel for unverified contracts (max 5 concurrent, 5-minute timeout each). It classifies contracts as `known_standard` when applicable and skips unnecessary decompilation.

**Required check:** `analysis_0x{hash}/data_manifest.json` must exist. If absent → stop and report error.

**Optional files** (warn but continue if missing):
- `analysis_0x{hash}/funds_flow.json` — token movement summary (critical for accurate profit figures)
- `analysis_0x{hash}/decoded_calls.json` — decoded call tree
- `analysis_0x{hash}/selectors.json` — resolved selector map

**Check issues.json.**

---

## Step 5: Manifest Check

```bash
source ~/.claude/skills/exploit-investigator/.venv/bin/activate && python3 ~/.claude/skills/exploit-investigator/scripts/check_manifest.py analysis_0x{full_tx_hash}/ --chain {chain} --tx-hash {tx_hash}
```

Exit code 1 → warn user and continue (the manifest auto-corrector may have fixed issues).

---

## Steps 6–7: Analyst-Validator Debate Loop

Run for **max 2 rounds**. In round 1, Analyst produces the initial report. If Validator finds CRITICAL issues, the critique is fed back to the Analyst for revision in round 2.

```
for round in 1..2:
    7a. Spawn Analyst
    7b. Run manifest check
    7c. Spawn Validator
    7d. If validation.json["pipeline_halt"] == false → pipeline complete
    7e. If pipeline_halt == true AND round < 2 → tell user "Validator found CRITICAL issues, feeding critique back to Analyst"
    7f. If pipeline_halt == true AND round == 2 → FAIL (report debate log to user)
```

### Step 7a: Analyst Agent

**Round 1 prompt:**
> Read `~/.claude/skills/exploit-investigator/references/prompts/analyst.md`, then execute the instructions with:
> - Analysis directory: `analysis_0x{full_tx_hash}/`
> - Chain: `{chain}`
> - Incident brief: `analysis_0x{full_tx_hash}/incident_brief.md`
> - Debate round: 1

**Round 2+ prompt (Revision Mode):**
> Read `~/.claude/skills/exploit-investigator/references/prompts/analyst.md`, then execute the instructions with:
> - Analysis directory: `analysis_0x{full_tx_hash}/`
> - Chain: `{chain}`
> - Incident brief: `analysis_0x{full_tx_hash}/incident_brief.md`
> - Debate round: {round}
> - Validation critique: `analysis_0x{full_tx_hash}/validation.json`
>
> You are in Revision Mode. The Validator challenged your previous report with CRITICAL issues.
> Read the `revision_guidance` array in `validation.json` and address every point:
> accept and fix, or rebut with specific on-chain evidence. Then rewrite the report.

**Required checks:**
- `analysis_0x{hash}/report.md` exists
- `analysis_0x{hash}/manifest.json` exists

**Check issues.json.**

### Step 7b: Manifest Validation

```bash
source ~/.claude/skills/exploit-investigator/.venv/bin/activate && python3 ~/.claude/skills/exploit-investigator/scripts/check_manifest.py analysis_0x{full_tx_hash}/ --chain {chain} --tx-hash {tx_hash}
```

Exit code 1 → warn user and continue.

### Step 7c: Validator Agent

**Spawn:**
```
Task(model=sonnet, subagent_type=general-purpose)
```

**Prompt:**
> Read `~/.claude/skills/exploit-investigator/references/prompts/validator.md`, then execute the instructions with:
> - Analysis directory: `analysis_0x{full_tx_hash}/`
> - Debate round: {round}

**Required check:** Read `analysis_0x{full_tx_hash}/validation.json`.

**Check issues.json.**

### Step 7d–f: Evaluate Debate Outcome

Read `analysis_0x{full_tx_hash}/validation.json`:
- `"pipeline_halt": false` → debate resolved, proceed to Step 8
- `"pipeline_halt": true` AND round < 2 → tell user "Validator found CRITICAL issues in round {round}, feeding critique back to Analyst for revision." Continue loop with round+1.
- `"pipeline_halt": true` AND round == 2 → FAIL. Report to user with `revision_guidance` content and `analysis_0x{hash}/debate_log.json`

---

## Step 8: Report Results

Print a summary:
- **Overall result**: `VALIDATED` / `VALIDATED_WITH_WARNINGS` / `FAILED`
- **Debate rounds completed**: 1 = first-pass accepted, 2 = revised after challenge
- **Report path**: `analysis_0x{full_tx_hash}/report.md`
- **Validation path**: `analysis_0x{full_tx_hash}/validation.json`
- **Warnings**: any non-critical issues encountered
- **If round > 1**: summarize what the Validator challenged and how the Analyst responded (from `debate_log.json`)
- Inform user: "PoC exploit test can be generated on request."

---

## Step 9: PoC Generator (OPTIONAL — user request only)

**Do NOT auto-run.** Only spawn when the user explicitly asks (e.g., "generate PoC", "write exploit test", "create the Foundry test").

**Spawn:**
```
Task(model=sonnet, subagent_type=general-purpose)
```

**Prompt:**
> Read `~/.claude/skills/exploit-investigator/references/prompts/poc_generator.md`, then execute the instructions with:
> - Analysis directory: `analysis_0x{full_tx_hash}/`

**Check:** Read `analysis_0x{full_tx_hash}/poc/validation_result.json`.

If PoC Generator fails 3 times, report FAILED with the best attempt.

---

## Issues Monitoring

After each pipeline step (Steps 3–9, including each debate round), check for critical issues:

```bash
python3 -c "
import json, sys
from pathlib import Path
issues_file = Path('{analysis_dir}/issues.json')
if issues_file.exists():
    issues = json.loads(issues_file.read_text())
    critical = [i for i in issues if i.get('severity') == 'critical']
    if critical:
        for i in critical: print(f'CRITICAL: {i[\"issue\"]}\n  Suggested: {i[\"suggested_action\"]}')
        sys.exit(1)
sys.exit(0)
"
```

Exit code 1 → report critical issues to user and ask if they want to apply the suggested action or continue anyway.

Any agent can append to `analysis_0x{hash}/issues.json` in this format:
```json
[
  {
    "agent": "data_collector",
    "severity": "critical|warning",
    "issue": "Failed to fetch source for vulnerable contract 0x...",
    "suggested_action": "Spawn Decompiler Subagent for 0x... with trace selectors from trace_callTracer.json",
    "timestamp": "2026-02-19T10:00:00Z"
  }
]
```

---

## Inter-Agent File Map

```
analysis_0x{hash}/
├── incident_brief.md         ← orchestrator writes
├── analysis_plan.json        ← planner output
├── trace_callTracer.json     ← planner output
├── trace_prestateTracer.json ← planner output (optional)
├── selectors_raw.json        ← planner output (optional)
├── data_manifest.json        ← data collector output
├── funds_flow.json           ← data collector output (optional)
├── decoded_calls.json        ← data collector output (optional)
├── selectors.json            ← data collector output (optional)
├── manifest.json             ← analyst output
├── debate_log.json           ← analyst output (round 2+)
├── report.md                   ← analyst output
├── validation.json             ← validator output
├── poc/
│   ├── test/
│   │   └── Exploit.t.sol       ← PoC generator output
│   └── validation_result.json  ← PoC generator output
├── issues.json               ← any agent appends critical/warning issues
└── {contract_addr}/          ← data collector output (one dir per contract)
    ├── *.sol                 ← verified source OR recovered.sol
    ├── abi.json              ← from Etherscan (verified only)
    ├── selector_map.json     ← per-contract selector mapping (decompiler subagent)
    ├── decompile_meta.json   ← decompilation metadata (decompiler subagent)
    └── init_bytecode.hex     ← for CREATE contracts (planner)
```

---

## Python Scripts Reference

All scripts require activating the skill venv first:
```bash
source ~/.claude/skills/exploit-investigator/.venv/bin/activate
```
Scripts live at `~/.claude/skills/exploit-investigator/scripts/`.

- **`fetch_sourcecode.py`** — fetch verified contract source from Etherscan. Outputs `.sol` files, `abi.json`, and `info.json` (contract metadata: name, compiler version, proxy info).

- **`fetch_tac.py`** — optional TAC decompilation (requires local Gigahorse server) and trace context utility:
  - `--tac-server --address 0x... --chain {chain} --dir path/` — fetch TAC from local Gigahorse server (optional, enhances decompiler subagent output)
  - `--bytecode-file path/to/init_bytecode.hex --address 0x... --dir path/` — decompile locally-saved bytecode via TAC server
  - `--trace-context --trace-file path/trace.json --address 0x...` — print trace context summary for a contract
  - Note: LLM-assisted Solidity recovery is now handled by Decompiler Subagents, not this script

- **`check_manifest.py`** — validate and auto-correct `manifest.json`.

- **`funds_flow.py`** — parse receipt + trace to produce `funds_flow.json` with token flows, net balance changes, and attacker profit (including ETH gains).

- **`decode_calldata.py`** — decode call trace using ABI files, planner selectors, and `cast 4byte` fallback. Produces `decoded_calls.json` and `selectors.json`.

---

## Chain ID Map

| Chain | Chain ID |
|-------|----------|
| eth | 1 |
| bnb | 56 |
| arb | 42161 |
| polygon | 137 |
| opt | 10 |
| avax | 43114 |
| base | 8453 |

## references/prompts

```

```

## references/prompts/analyst.md

# Analyst Agent

You are an expert Security Researcher and Blockchain Auditor. Produce a comprehensive Incident Report for the specified transaction. All on-chain data and contract sources have already been collected by previous pipeline stages.

Your output must be concise, technically precise, and backed by on-chain evidence.

## Inputs

You receive:
- **Analysis directory**: path to `analysis_0x{hash}/`
- **Chain**: chain name
- **Incident brief**: path to `analysis_0x{hash}/incident_brief.md`
- **Debate round**: integer (1 = initial analysis, 2+ = revision after Validator critique)
- **Validation critique** (round 2+ only): path to `{analysis_dir}/validation.json`

Start by reading these files:
1. `analysis_0x{hash}/incident_brief.md` -- the user's incident description and hints
2. `analysis_0x{hash}/analysis_plan.json` -- investigation priorities and contract info from the Planner
3. `analysis_0x{hash}/data_manifest.json` -- what data was fetched, any errors or warnings

All on-chain data files are already in the analysis directory:
- `tx.json`, `receipt.json`, `block.json`, `trace_callTracer.json`
- Contract source directories: `{address}/` with `.sol`, `abi.json`, `.tac` files

Also read these pre-computed structured files if they exist:
- `analysis_0x{hash}/funds_flow.json` — decoded ERC-20 Transfer events, net balance changes per address, attacker profit summary. Use as **primary evidence** for financial impact — do NOT manually parse raw receipt logs if this file exists.
- `analysis_0x{hash}/decoded_calls.json` — call trace with ABI-decoded function names and arguments.
- `analysis_0x{hash}/selectors.json` — selector → function name map. Use for call flow resolution instead of ad-hoc `cast 4byte` calls.
- `analysis_0x{hash}/trace_prestateTracer.json` — storage slot and balance state diff (before/after values for every changed slot during the transaction).

## Evidence Hierarchy (MANDATORY)

When sources conflict, follow this strict priority order:

1. **On-chain trace** (`trace_callTracer.json`) — absolute ground truth for what happened
2. **Verified source code** (`.sol` from Etherscan, `source_type: "verified"`) — ground truth for code logic
3. **TAC decompilation** (`.tac` files) — reliable for control flow structure
4. **Agent-recovered Solidity** (`recovered.sol` from Decompiler Subagent) — **approximation only**, never authoritative. Check `decompile_meta.json` for `confidence` level — if `"low"`, treat all claims from recovered code as tentative.

Rules:
- **Never contradict the trace based on recovered code.** If the trace shows a function called N times, say N. If it shows `sync()` called, do not say "sync is never called" because recovered code lacks it.
- Derive the call flow **entirely from `trace_callTracer.json`**. Then cross-reference source code to explain *why* each call happens.
- When citing recovered code, always note it as `[recovered — approximation]`. Check `decompile_meta.json` for confidence level. Function names in recovered code may be hallucinated; prefer selector-derived names when uncertain. If `decompile_meta.json` shows `fallback_only: true`, the contract uses fallback-based routing.
- If verified source and recovered code disagree, trust verified source. If the trace and any code disagree, trust the trace.
- **Root cause from recovered source (medium/low confidence)**: When the vulnerable contract has `source_type: "recovered"` or `confidence` is not `"high"`, derive the root cause mechanism **from the trace first**, not from the recovered source:
  1. Observe what the trace actually shows — amounts transferred, call order, which addresses sent/received tokens.
  2. State the root cause in terms of what the trace proves.
  3. Use the recovered source **only to explain why** the trace shows that behavior — as supporting context, not as the primary evidence.
  4. If the recovered source suggests a different mechanism than what the trace directly shows, explicitly note the conflict and favor the trace interpretation. Do not build the root cause narrative around recovered code that has not been validated against the trace.

### Known Standard Contracts

Contracts marked `source_type: "known_standard"` in `data_manifest.json` were identified as canonical implementations (e.g., WETH9, standard ERC20 tokens, Uniswap pairs). For these contracts:
- Assume standard interface behavior (e.g., standard ERC20 `transfer` semantics)
- Do NOT attempt to fetch or decompile these contracts
- If a `known_standard` contract's behavior turns out to be relevant to the vulnerability mechanism (e.g., a "standard" token that actually has a non-standard hook), escalate via `issues.json` with severity `critical`

## Revision Mode (Debate Round 2+)

When `debate_round >= 2`, you are revising a previous report that was challenged by the Validator. This is a structured debate — the Validator found CRITICAL issues with your analysis and you must respond to each one.

### Procedure

1. **Read the critique**: Load `{analysis_dir}/validation.json` and extract the `revision_guidance` array.

2. **For each critique point**, do one of the following:

   a. **Accept and fix**: If the Validator is correct, revise the report. Update the root cause, vulnerable contract/function, call flow, financial figures, or whatever was wrong. Clearly note what changed.

   b. **Rebut with evidence**: If the Validator is wrong, provide a specific, evidence-backed rebuttal. You MUST cite:
      - Exact trace entries (caller, callee, selector, depth) from `trace_callTracer.json`
      - Exact source code lines from verified `.sol` files
      - Exact log entries or amounts from `receipt.json` or `funds_flow.json`
      - A vague "I believe my analysis is correct" is NOT a valid rebuttal.

   c. **Partially accept**: If the Validator raised a valid concern but proposed the wrong alternative, explain what's actually happening with evidence.

3. **Re-examine from scratch**: Do NOT just patch the old report. Re-read the trace and source code for the specific areas the Validator challenged. Fresh eyes may reveal something you missed the first time.

4. **Write the revised report**: Write the corrected version to `{analysis_dir}/report.md`. Also update `manifest.json` if the root cause, vulnerable contract, or attack vector changed.

5. **Write a debate log**: Write `analysis_0x{hash}/debate_log.json`:
```json
{
  "round": 2,
  "critique_points_received": 3,
  "accepted": ["C1", "C3"],
  "rebutted": ["C2"],
  "rebuttal_evidence": {
    "C2": "Trace at depth 4 shows selector 0xabcd calling contract 0x1234, confirming the original call flow. See trace_callTracer.json calls[0].calls[2].calls[0]."
  },
  "changes_made": ["Updated vulnerable function from withdraw() to redeem()", "Fixed financial impact from 25k to 18k USDC"]
}
```

### Rules

- **The trace is the ultimate arbiter.** If you and the Validator disagree about what happened, the trace is the tiebreaker.
- **Do not be defensive.** If the Validator caught a real error, fix it. The goal is a correct report, not winning the debate.
- **Do not introduce new errors.** When revising, re-verify every claim you change against the trace and source code.
- **Maintain the evidence hierarchy.** On-chain trace > verified source > TAC > recovered Solidity. Never downgrade evidence quality during revision.

## Operational Guidelines

- **File Management**: Always use paths relative to the project root when writing files: `analysis_0x{hash}/manifest.json`, `analysis_0x{hash}/report.md`, etc. Never `cd` into the analysis directory.
- Use the `analysis_focus` questions from the plan to guide your investigation.
- Check the `data_manifest.json` for any fetch errors -- if a critical contract's source is missing, use the fallback tools below to fetch it.

## Artifact Format

### Canonical File Names

For **single-transaction** analysis:
- `tx.json` -- `eth_getTransactionByHash` response
- `receipt.json` -- `eth_getTransactionReceipt` response
- `block.json` -- `eth_getBlockByNumber` response
- `trace_callTracer.json` -- `debug_traceTransaction` with `{"tracer":"callTracer"}` response

For **multi-transaction** analysis, prefix each file with the first 8 hex chars of the tx hash:
- `{first8hex}_tx.json` (e.g., `ee2b216b_tx.json`)
- `{first8hex}_receipt.json`
- `{first8hex}_trace_callTracer.json`

### Contract Sources

Source code is stored in subdirectories named by contract address:
- `{address}/` -- verified source (contains `.sol` files, `abi.json`, `settings.json`)
- `contract.tac` or `{address}.tac` -- TAC decompilation
- `{address}/recovered.sol` or `{address}.sol` -- recovered Solidity from TAC

### `manifest.json`

At the **end of analysis**, write a `manifest.json` file in the analysis directory that records what was fetched, where files are, and key findings. This enables the validation pipeline to load all context without re-fetching.

```json
{
  "version": 1,
  "chain": "eth",
  "chain_id": 1,
  "rpc_url": "https://eth-mainnet.g.alchemy.com/v2/...",
  "created_at": "2026-02-05T16:12:00Z",
  "transactions": {
    "0xee2b...": {
      "tx_hash": "0xee2b216b7d649513dc8ba102e130d3d86d189b393a0d5f387e479be3dbda799d",
      "block_number": 24383881,
      "from": "0x5369...",
      "to": null,
      "files": {
        "tx": "tx.json",
        "receipt": "receipt.json",
        "block": "block.json",
        "trace_callTracer": "trace_callTracer.json"
      }
    }
  },
  "contracts": {
    "0x169a...": {
      "name": "SingleAdapterRouter",
      "source_type": "verified",
      "source_dir": "0x169a5effcae91ab33bc9e97f49b513b81008c453/",
      "is_proxy": false
    },
    "0xe5b2...": {
      "name": "UnknownContract",
      "source_type": "recovered",
      "tac_file": "contract.tac",
      "source_dir": "0xe5b2fabf3b2000eb6b03bb4ebea80fabc6159cf0/",
      "is_proxy": false
    }
  },
  "report": {
    "path": "analysis_0x{hash}/report.md",
    "incident_name": "reusd",
    "vulnerable_contract": "0x...",
    "attack_vector": "logic/authorization flaw",
    "financial_impact_usd": "~25,774 USDC"
  }
}
```

Note: Replace `{hash}` with the actual tx hash prefix, e.g. `"analysis_0xabc123.../report.md"`.

**Fields**:
- `version`: always `1` for now.
- `chain` / `chain_id`: chain short name and numeric ID.
- `rpc_url`: the RPC endpoint used during analysis.
- `created_at`: ISO 8601 timestamp.
- `transactions`: map of tx hash -> metadata + relative file paths. Include only files that actually exist. Use keys matching the canonical names (`tx`, `receipt`, `block`, `trace_callTracer`).
- `contracts`: map of contract address -> name, source type, source paths, proxy status. Always include `source_dir` if `.sol` files exist. Source types:
  - `verified`: has `source_dir` pointing to fetched verified source.
  - `tac`: has `tac_file` pointing to raw TAC decompilation only. Use **only** when no recovered `.sol` exists.
  - `recovered`: has both `tac_file` and `source_dir`. Use whenever TAC was recovered to Solidity.
- `report`: path to the generated report and key findings. The `incident_name` must be a concrete slug string (e.g., `"reusd"`, `"gyro-ccip-escrow"`), not a template placeholder. Use the `name` field (not `label`) for contracts, and include `is_proxy` for each contract entry.

## Vulnerability Classification (Required First Step)

Before writing the report, classify the attack by examining the trace and funds flow. Pick one **primary** category that identifies the root cause. If the exploit combines multiple techniques (e.g., flash loan to fund a price manipulation, or reentrancy that exploits a logic error), also record a **secondary** category.

| Category | Key Indicators |
|---|---|
| `reentrancy` | External CALL before SSTORE update; same function called recursively in trace |
| `price_manipulation` | AMM reserves queried then manipulated via swap in same tx; spot price used as oracle |
| `access_control` | Function called by unexpected address; missing `msg.sender` check; role not validated |
| `flash_loan_abuse` | flashLoan callback contains the exploit; borrowed funds used to manipulate state |
| `logic_error` | Parameter not validated; unit mismatch; incorrect arithmetic; wrong variable used |
| `oracle_manipulation` | External price feed read after price was artificially moved in same tx |

Record your primary classification in the manifest under `report.attack_vector`. If there is a secondary category, record it under `report.attack_vector_secondary`.

**Choosing primary vs secondary**: The primary category is the root cause — the code flaw that made the exploit possible. The secondary is the technique — how the attacker leveraged the flaw. Example: a `price_manipulation` root cause exploited via `flash_loan_abuse` as the funding mechanism. If only one category applies, omit `attack_vector_secondary`.

### Class-Specific Checklists

After classifying, follow the corresponding checklist before writing the Flaw Description:

#### Reentrancy Checklist
1. Identify the vulnerable function and the external CALL (or TRANSFER/SEND) within it.
2. List ALL state variable writes (SSTOREs) in that function — which occur BEFORE the external call, which AFTER?
3. Confirm: does the trace show the vulnerable function being entered again while the first call is still executing? (Look for the same selector appearing at greater depth in the trace.)
4. Is there a reentrancy guard (`_status == _ENTERED`, `nonReentrant` modifier)? If yes, how was it bypassed?
5. What is the attacker-controlled callback target? How does the attacker control execution during the callback?

#### Price Manipulation Checklist
1. Identify the price source: AMM `getReserves()`, oracle `latestRoundData()`, or spot calculation.
2. In the trace, find where the price is READ. Find where the attacker MANIPULATES the price (swap, flashloan). Which comes first?
3. Use `trace_prestateTracer.json` (if available): what were the reserve/oracle values before the tx? What were they at the moment the price was read?
4. Compute the price distortion: (manipulated_price − fair_price) / fair_price × 100%.
5. What protocol action did the attacker trigger using the manipulated price?

#### Access Control Checklist
1. Identify the vulnerable function. List ALL `require`/`revert` conditions at function entry.
2. What is `msg.sender` at the time of the vulnerable call? (Check the trace `from` field at that call depth.)
3. Are there modifier guards (`onlyOwner`, `onlyRole`, `onlyAuthorized`)? If yes, why did they not block the attacker?
4. If the call goes through DELEGATECALL: does the authorization use `msg.sender` (preserved) or `address(this)` (changes)?
5. Are there any admin functions that should be restricted but aren't? Was the contract initialized correctly?

#### Flash Loan Abuse Checklist
1. Identify the flash loan provider and callback function name (from trace).
2. What assets were borrowed and in what amounts? (Check `decoded_calls.json` for flashLoan parameters.)
3. Inside the callback: what state manipulation happens with the borrowed funds?
4. Is the repayment check valid? Could an attacker avoid repayment?
5. Trace the borrowed funds: borrowed → [what operations] → repaid. What remains as profit?

#### Logic Error Checklist
1. Identify the function where incorrect behavior occurs.
2. Check for unit mismatches: are amounts in wei vs tokens, shares vs amounts, price vs reserves?
3. Check for operator precedence: does `a + b * c` compute as intended?
4. What is the exact parameter value the attacker used? (Check `decoded_calls.json` for call arguments.)
5. What is the EXPECTED behavior for normal inputs? What is the ACTUAL behavior for the attacker's crafted input?
6. Is there integer overflow/underflow (Solidity <0.8 or `unchecked` blocks)?

## Report Structure (MANDATORY)

The report has a strict section order. Every section is required unless marked optional. The goal is: a reader understands the high-level attack in 30 seconds from the summary, and can drill into the root cause code in 60 seconds.

### Section 1: Executive Summary

3-5 sentences. Written for someone who has NOT read the trace or code. Must answer:
- What protocol/project was attacked, on which chain, when?
- What type of vulnerability (one phrase: "access control bypass", "reentrancy", etc.)?
- How much was lost (tokens + USD)?
- One sentence on how: "The attacker exploited [X] to [Y]."

Format: plain paragraph under the H1 title, no sub-heading. This IS the opening of the report.

### Section 2: Root Cause

This is the **most important section**. It must make the vulnerability immediately obvious to a Solidity developer.

**`## Root Cause`**

Structure (use these exact sub-headings):

**`### Vulnerable Contract`**
- Contract name, address, proxy status.
- If proxy: implementation address + how resolved.
- Source type: verified / recovered [approximation].

**`### Vulnerable Function`**
- Function name, full signature, selector.
- Which contract file it lives in (e.g., `Vault_reUSD.sol`).

**`### Vulnerable Code`**
- The exact code snippet from the source file. Include enough context (the full function, or the relevant section with surrounding lines).
- Use a Solidity code block. If from recovered source, add a comment `// [recovered — approximation]` at the top.
- **Annotate the vulnerable lines** with `// <-- VULNERABILITY` inline comments pointing to the exact lines where the flaw exists.

**`### Why It's Vulnerable`**
- Explain the flaw using a **"Expected vs Actual"** contrast:
  - **Expected behavior**: "The function SHOULD verify that `lpAmount <= userShares[msg.sender]` before redeeming LP tokens."
  - **Actual behavior**: "The function only checks `amountUnderlying <= userBalance[msg.sender]` — the `lpAmount` parameter is never validated and is passed directly to `adapter.withdrawWithCalldataGeneric()`."
- Then explain **why this matters**: what an attacker can do by exploiting the gap between expected and actual.
- Cite the specific check that is missing, wrong, or bypassable.
- For complex vulnerabilities: include a **"Normal flow vs Attack flow"** comparison showing how the same function behaves differently under normal use vs attacker-crafted inputs.

### Section 3: Attack Execution

**`## Attack Execution`**

**`### High-Level Flow`**
A numbered list (5-10 steps max) describing what the attacker did in plain language. Each step = one logical action. No selectors, no addresses — just concepts:
1. "Attacker deploys helper contract"
2. "Helper takes flash loan of 100K USDC from Aave"
3. "Helper deposits 10 USDC into the vault"
4. "Helper calls withdraw with inflated lpAmount, draining vault LP"
5. "Helper repays flash loan, keeps profit"

**`### Detailed Call Trace`**
The full technical call flow derived **exclusively from `trace_callTracer.json`**:
- Walk the trace tree top-down.
- For each call: caller → callee, function name (selector), call type (CALL/STATICCALL/DELEGATECALL), ETH value if nonzero.
- Resolve selectors using `selectors.json` (preferred) or `cast 4byte {selector}` as fallback.
- **Verify every selector** with `cast sig "functionName(type1,type2)"` before writing it.
- Use indentation or a nested list to show call depth.
- The trace is ground truth. If recovered code suggests a different flow, note the discrepancy and trust the trace.

### Section 4: Financial Impact

**`## Financial Impact`**

- Total loss in tokens (with correct decimals) and USD equivalent.
- **Use `funds_flow.json` as primary evidence** if it exists: use `attacker_gains` for profit figures and `net_changes` for address-level balance deltas. Do NOT manually parse raw receipt logs when this file is available.
- Who lost funds: LPs, protocol treasury, users, or a combination.
- Attacker profit after costs (flash loan fees, gas).
- Protocol solvency impact: is the protocol still functional or drained?

### Section 5: Evidence (optional — include if it strengthens the report)

**`## Evidence`**

Cite specific on-chain artifacts that support the analysis:
- Log topics and event signatures for key events (Transfer, Approval, custom events).
- Storage slot values (before/after) if relevant.
- Receipt status confirmation.
- Selector verification results.

This section is for validators — it provides checkable facts.

## Content Guidelines

When investigating and writing, follow these analytical rules:

- **Forensic Tracing**: Follow the funds to determine vulnerability type:
  - Funds → Attacker directly? Suspect Access Control or Logic Error.
  - Funds → Pool/Third Party? Suspect Price Manipulation or Slippage.
- **Logic & Semantics**: Check for token/unit mismatches, incorrect operator precedence, semantic confusion.
- **External Dependencies**: Verify assumptions about external calls (return values, reentrancy).
- **Market Interactions**: Look for hardcoded parameters, spot price reliance, manipulable oracles.
- **EVM specifics** (if relevant): Storage collision, uninitialized memory, DELEGATECALL context.

## Output Format

- Write the report to `{analysis_dir}/report.md`
- Start with `# {Incident Title}` (single H1), then the Executive Summary paragraph immediately below.
- Use H2 (`##`) for major sections, H3 (`###`) for sub-sections. Do not use H4 or deeper.
- Use inline code for function names, selectors, and addresses.
- Keep paragraphs short (3-5 sentences max).
- Optional: `## Related URLs` list at the end with one item per line.

## Fallback Tools

If the data collector missed a contract or you discover additional contracts during analysis, use these tools to fetch data:

1. **RPC Endpoint**: `https://{chain}-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY`
   - Use this to check balances, logs, storage slots, and trace calls.

2. **Code Retrieval** (activate venv first: `source ~/.claude/skills/exploit-investigator/.venv/bin/activate`):
   - **Sourcecode**: `python3 fetch_sourcecode.py --caddress {contract_address} --dir analysis_0x{hash}/ --chainid {chain_id}`
     (Note: the script creates the `{address}/` subdirectory internally — always pass the analysis root as `--dir`)
     - Chain IDs: `1` (eth), `56` (bnb), `42161` (arb), `137` (polygon), `10` (opt), `43114` (avax), `8453` (base).
   - **TAC** (optional, requires local Gigahorse server): `python3 fetch_tac.py --tac-server --address {contract_address} --chain {chain} --dir analysis_0x{hash}/{address}/`
     - Note: Solidity recovery is handled by Decompiler Subagents during data collection. If recovered code is missing for a contract, flag the gap in `issues.json` rather than attempting recovery yourself.
   - **Important**: Recovered Solidity is an approximation. Before citing recovered code in the report, cross-verify against the actual call trace. If the trace shows a code path the recovered Solidity doesn't explain, note the discrepancy and prefer the trace as source of truth.

3. **Selector Verification**: `cast sig "functionName(type1,type2)"` -- verify every selector before writing it in the report.

## references/prompts/data_collector.md

# Data Collector Agent

You are a deterministic data fetcher for on-chain attack analysis. Your job is to read the analysis plan and fetch all required on-chain data and contract sources. You do NOT analyze anything -- only collect data.

## Inputs

You receive:
- **Analysis directory**: path to `analysis_0x{hash}/`
- **Chain**: chain name
- **Chain ID**: numeric chain ID

Read `analysis_0x{hash}/analysis_plan.json` for the full plan including:
- `tx_hash`, `chain`, `chain_id`, `rpc_url`, `block_number`
- `contracts` list with fetch strategies
- `incident_name` for naming

## Steps

### 1. Fetch Transaction Data

Fetch and save each to the analysis directory:

**Transaction** (`tx.json`):
```bash
curl -s -X POST {rpc_url} \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"eth_getTransactionByHash","params":["{tx_hash}"],"id":1}' \
  | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result'], indent=2))" \
  > analysis_0x{hash}/tx.json
```

**Receipt** (`receipt.json`):
```bash
curl -s -X POST {rpc_url} \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"eth_getTransactionReceipt","params":["{tx_hash}"],"id":1}' \
  | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result'], indent=2))" \
  > analysis_0x{hash}/receipt.json
```

**Block** (`block.json`):
Extract the block number from `tx.json` (field `blockNumber` in hex), then:
```bash
curl -s -X POST {rpc_url} \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["{block_number_hex}", false],"id":1}' \
  | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result'], indent=2))" \
  > analysis_0x{hash}/block.json
```

### 2. Verify Trace Exists

The Planner should have already fetched `trace_callTracer.json`. Verify it exists:
```bash
ls -la analysis_0x{hash}/trace_callTracer.json
```

If missing, fetch it:
```bash
curl -s -X POST {rpc_url} \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"debug_traceTransaction","params":["{tx_hash}",{"tracer":"callTracer"}],"id":1}' \
  | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result'], indent=2))" \
  > analysis_0x{hash}/trace_callTracer.json
```

### 2b. Detect CREATE/CREATE2 Contracts

Before fetching sources, scan the trace for contracts deployed during the transaction:

```bash
python3 -c "
import json
data = json.load(open('analysis_0x{hash}/trace_callTracer.json'))
if 'result' in data: data = data['result']
def walk(n):
    if not isinstance(n, dict): return
    if n.get('type') in ('CREATE', 'CREATE2'):
        print(n.get('type'), n.get('from','')[:42], '->', n.get('to','')[:42])
    for c in n.get('calls', []): walk(c)
walk(data)
"
```

Add any newly-discovered addresses to your fetch list (they may not be in `analysis_plan.json`).
Label them as `attacker_contract` if they are deployed by the attacker EOA or a known attacker contract.
Note: these contracts may be ephemeral (self-destructed). If `eth_getCode` at `latest` returns `0x`, their bytecode was available at the transaction block only — still try `fetch_sourcecode.py` and TAC fetch with `--chain`.

### 2c. Compute Funds Flow

After `receipt.json` is fetched, run the funds flow parser to produce a structured token movement summary:

```bash
source ~/.claude/skills/exploit-investigator/.venv/bin/activate && python3 funds_flow.py --analysis-dir analysis_0x{hash}/ --rpc-url {rpc_url}
```

This produces `analysis_0x{hash}/funds_flow.json` with decoded ERC-20 Transfer/Approval events, net balance changes per address, and an attacker profit summary. If it fails, log a warning in `data_manifest.json` and continue.

### 2d. Verify State Diff

Verify the prestateTracer output was written by the Planner:
```bash
ls -la analysis_0x{hash}/trace_prestateTracer.json 2>/dev/null && echo "state diff present" || echo "state diff missing (optional)"
```

If missing and RPC access is available, attempt to fetch it:
```bash
curl -s -X POST {rpc_url} \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"debug_traceTransaction","params":["{tx_hash}",{"tracer":"prestateTracer","tracerConfig":{"diffMode":true}}],"id":1}' \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d.get('result', d), indent=2))" \
  > analysis_0x{hash}/trace_prestateTracer.json
```

### 3. Fetch Contract Sources

Activate the Python venv first:
```bash
source ~/.claude/skills/exploit-investigator/.venv/bin/activate
```

**Source-first strategy with smart skipping**: For every contract, classify it before fetching.

For each contract in `analysis_plan.json`:

**Step 3a — Known standard check:**
If the contract's `role` is NOT `vulnerable_target` or `attacker_contract`, AND the Planner identifies it as a standard contract (e.g., token, dex_pair, dex_router with all standard selectors), mark it as `source_type: "known_standard"` in the manifest and skip both source fetching and decompilation.

**Step 3b — Proxy check:**
If the contract has `is_proxy: true` in the analysis plan, fetch the **implementation** address instead. Skip the proxy address itself — its bytecode is a forwarding stub.

**Step 3c — Try verified source:**
```bash
python3 fetch_sourcecode.py --caddress {address} --dir analysis_0x{hash}/ --chainid {chain_id}
```
Note: `fetch_sourcecode.py` creates a subdirectory `analysis_0x{hash}/{address}/` internally — always pass the **analysis root** as `--dir`, not the address subdirectory.

Check the result:
```bash
ls analysis_0x{hash}/{address}/*.sol 2>/dev/null | head -1
```

If `.sol` files found: mark `source_type: "verified"`. **Do NOT decompile** — verified source is sufficient.

If the contract is a proxy (per the plan), also fetch the implementation:
```bash
python3 fetch_sourcecode.py --caddress {implementation_address} --dir analysis_0x{hash}/ --chainid {chain_id}
```

**Step 3d — If NO `.sol` files found**: add to the "needs decompilation" list.

**Step 3e — Verify what was written for verified/known_standard contracts:**
```bash
ls -la analysis_0x{hash}/{address}/
```

### 3f. Parallel Decompilation via Subagents

For all contracts in the "needs decompilation" list, spawn **Decompiler Subagents** in parallel (max 5 concurrent).

For each contract, prepare the subagent inputs:
1. Extract `trace_selectors` — the function selectors called on this contract, from `trace_callTracer.json`:
   ```python
   # Walk the trace, collect selectors where "to" == contract_address
   # Each selector is the first 10 chars of "input" (e.g., "0x1234abcd")
   ```
2. Extract `trace_context` — a summary of calls to/from this contract. You can use:
   ```bash
   source ~/.claude/skills/exploit-investigator/.venv/bin/activate && python3 fetch_tac.py --trace-context --trace-file analysis_0x{hash}/trace_callTracer.json --address {address}
   ```
3. Check for `init_bytecode.hex`:
   ```bash
   ls analysis_0x{hash}/{address}/init_bytecode.hex 2>/dev/null
   ```
4. Read `selectors.json` or `selectors_raw.json` if available for pre-resolved signatures.

**Spawn each subagent** with `subagent_type=general-purpose`, `model=sonnet`:

Prompt template:
```
Read ~/.claude/skills/exploit-investigator/references/prompts/decompiler.md, then execute the instructions with:
- address: {address}
- chain: {chain}
- rpc_url: {rpc_url}
- trace_selectors: {trace_selectors_json_array}
- trace_context: |
    {trace_context_text}
- role: {role}
- selectors_resolved: {selectors_resolved_json_object}
- init_bytecode_path: {path_or_null}
- output_dir: analysis_0x{hash}/{address}/
```

**After all subagents complete** (timeout: 5 minutes each):
- For each contract, read `analysis_0x{hash}/{address}/decompile_meta.json`
- If `coverage < 1.0`: append a warning to `analysis_0x{hash}/issues.json`
- If subagent timed out or meta file missing: record `source_type: "failed"` with error note

**Step 3g — Set source_type based on files actually on disk:**
```bash
ls -la analysis_0x{hash}/{address}/
```
- `"verified"` → `.sol` files from Etherscan exist
- `"recovered"` → `recovered.sol` exists (from decompiler subagent), no Etherscan `.sol`
- `"tac"` → only `.tac` file, no `.sol` (TAC server was used but recovery failed)
- `"known_standard"` → skipped, standard contract
- `"failed"` → directory empty or fetch entirely failed

### 3h. Resolve Selectors and Decode Calls

After all contract sources are fetched (Step 3 complete), run the calldata decoder to build a unified selector map and decode all calls in the trace:

```bash
source ~/.claude/skills/exploit-investigator/.venv/bin/activate && python3 decode_calldata.py --analysis-dir analysis_0x{hash}/
```

This produces:
- `analysis_0x{hash}/decoded_calls.json` — all calls in the trace with decoded function names from ABI files
- `analysis_0x{hash}/selectors.json` — unique selector → function name map (combining ABI resolution with trace selectors)

If this step fails, log a warning and continue.

### 4. Write data_manifest.json

After all fetching is complete, write `analysis_0x{hash}/data_manifest.json`:

```json
{
  "tx_hash": "0x...",
  "chain": "eth",
  "chain_id": 1,
  "rpc_url": "https://...",
  "block_number": 12345678,
  "files": {
    "tx": "tx.json",
    "receipt": "receipt.json",
    "block": "block.json",
    "trace_callTracer": "trace_callTracer.json",
    "trace_prestateTracer": "trace_prestateTracer.json",
    "funds_flow": "funds_flow.json",
    "decoded_calls": "decoded_calls.json",
    "selectors": "selectors.json"
  },
  "contracts": {
    "0xaddr1": {
      "label": "ContractName",
      "source_type": "verified",
      "source_dir": "0xaddr1/",
      "files_found": ["Contract.sol", "abi.json", "settings.json"],
      "tac_file": null,
      "error": null
    },
    "0xaddr2": {
      "label": "AttackerContract",
      "source_type": "recovered",
      "source_dir": "0xaddr2/",
      "files_found": ["recovered.sol", "selector_map.json", "decompile_meta.json"],
      "tac_file": null,
      "error": null,
      "decompile_method": "agent-native",
      "decompile_confidence": "medium"
    },
    "0xWETH": {
      "label": "WETH9",
      "source_type": "known_standard",
      "source_dir": null,
      "files_found": [],
      "tac_file": null,
      "error": null,
      "standard": "WETH9"
    }
  },
  "errors": [],
  "warnings": []
}
```

Field definitions:
- `source_type`: What was **actually on disk** after fetching:
  - `verified`: `.sol` files from Etherscan exist in the directory
  - `recovered`: `recovered.sol` from decompiler subagent exists; no Etherscan `.sol`
  - `tac`: only `.tac` file, no `.sol`
  - `known_standard`: standard contract (WETH, USDC, etc.) — skipped
  - `failed`: directory empty or fetch entirely failed
- `decompile_method` (recovered only): `"agent-native"` or `"agent-native+tac"`
- `decompile_confidence` (recovered only): `"high"` | `"medium"` | `"low"` | `"none"` — from `decompile_meta.json`
- `standard` (known_standard only): standard name (e.g., `"WETH9"`, `"ERC20"`)
- `files_found`: list of files in the contract's directory (from `ls`)
- `error`: error message if fetch failed, null otherwise
- `errors`: list of critical errors (contracts that completely failed to fetch)
- `warnings`: non-critical issues, e.g. `"No verified source for 0x..., spawned decompiler subagent"` or `"CREATE2 contract 0x... not in analysis_plan"`

## Rules

- Always activate the venv before running Python scripts: `source ~/.claude/skills/exploit-investigator/.venv/bin/activate`
- Use paths relative to the project root (e.g., `analysis_0x{hash}/tx.json`), not absolute paths
- Do NOT `cd` into the analysis directory
- Do NOT analyze the data -- only collect it
- Always try verified source first for every contract
- For unverified contracts, spawn Decompiler Subagents (max 5 concurrent)
- Never skip decompilation for vulnerable_target or attacker_contract roles
- known_standard contracts can be skipped if their role is NOT vulnerable_target/attacker_contract
- Pass `--dir analysis_0x{hash}/` (the analysis root) to `fetch_sourcecode.py`, NOT the address subdir
- If a fetch fails, log the error in the manifest and continue with the next contract
- Set `source_type` based on files actually present on disk after fetching, not on intent
- Always run `ls -la analysis_0x{hash}/{address}/` after each fetch to verify what was written
- Scan trace for CREATE/CREATE2 and include those contracts even if not in the plan
- Run `funds_flow.py` after fetching `receipt.json`; log a warning if it fails but do not stop
- Run `decode_calldata.py` after all contract sources are fetched (Step 3h)
- Record new optional files (`trace_prestateTracer`, `funds_flow`, `decoded_calls`, `selectors`) in the `files` section of `data_manifest.json` only if they actually exist on disk after each step

## references/prompts/decompiler.md

# Decompiler Agent

You are an expert EVM reverse engineer. Your task is to recover readable Solidity pseudocode from unverified contract bytecode using disassembly analysis and LLM reasoning.

## Inputs

You receive these values in your spawn prompt:
- **address**: target contract address (0x...)
- **chain**: chain name (eth, bnb, arb, etc.)
- **rpc_url**: RPC endpoint for `cast` commands
- **trace_selectors**: list of function selectors called on this contract in the transaction
- **trace_context**: text summary of calls to/from this contract (caller, callee, selector, value)
- **role**: contract role from analysis plan (`vulnerable_target`, `attacker_contract`, `dex_pair`, `token`, etc.)
- **selectors_resolved**: pre-resolved selector → signature mapping (may be partial or empty)
- **init_bytecode_path**: path to `init_bytecode.hex` if contract was CREATE-deployed or self-destructed (optional)
- **output_dir**: directory to write output files (e.g., `analysis_0x.../0xContractAddr/`)

## Workflow

### Step 1: Fetch Bytecode

```bash
cast code {address} --rpc-url {rpc_url}
```

If the result is `0x` or empty (self-destructed contract):
- If `init_bytecode_path` was provided and the file exists, use it as the bytecode source
- If not available, write a minimal `decompile_meta.json` with `confidence: "none"` and `notes: "bytecode unavailable — contract may be self-destructed"`, then STOP

Save the bytecode to `{output_dir}/bytecode.hex`.

### Step 2: Disassemble

Pipe bytecode to `cast disassemble` to avoid ARG_MAX limits:
```bash
cast code {address} --rpc-url {rpc_url} | cast disassemble
```

For init bytecode:
```bash
cat {init_bytecode_path} | cast disassemble
```

Save the disassembly output for analysis. Count the number of opcode lines to determine contract size.

### Step 3: Selector Identification

Scan the disassembly for the dispatcher pattern:
- Look for sequences of `PUSH4 <selector> ... EQ ... JUMPI` in the early part of the bytecode
- These map function selectors to their code entry points

For each selector found and each selector in `trace_selectors`:
1. First check `selectors_resolved` for a pre-resolved signature
2. If not found, try: `cast 4byte {selector}`
3. If `cast 4byte` fails or returns no match, use `func_{selector}` as the name (e.g., `func_0x1234abcd`)

Build the complete selector → function signature mapping.

**Special case — fallback-only contracts:** If no dispatcher pattern is found (no PUSH4+EQ+JUMPI sequences), this contract routes all calls through `fallback()` or `receive()`. Set strategy to `"full"` and recover the entire contract logic.

### Step 4: Strategy Selection

Based on the opcode count from Step 2:

| Contract Size | Opcodes | Strategy | Description |
|---|---|---|---|
| Small | < 2000 | `full` | Recover all functions from complete disassembly |
| Medium | 2000–8000 | `selective` | Focus on functions matching `trace_selectors` |
| Large | > 8000 | `trace-guided` | Only recover trace-called functions + their internal calls |

**Role-based effort adjustment:**
- `vulnerable_target` / `attacker_contract`: thorough recovery with detailed comments
- `dex_pair` / `token` / other: skeleton recovery (function signatures + key external calls + storage operations)

**Optional TAC enhancement:** Check if a Gigahorse TAC server is available:
```bash
curl -s --max-time 2 http://127.0.0.1:8787/health
```
If available, also fetch TAC for higher-quality input:
```bash
source ~/.claude/skills/exploit-investigator/.venv/bin/activate && python3 ~/.claude/skills/exploit-investigator/scripts/fetch_tac.py --tac-server --address {address} --chain {chain} --dir {output_dir}
```
Use TAC alongside disassembly — TAC provides better structure for control flow, while disassembly preserves raw opcode details.

### Step 5: Solidity Recovery

Using the disassembly (and optionally TAC), selector mappings, and trace context, recover readable Solidity pseudocode. Apply these rules:

**MUST preserve:**
- All external calls (CALL, STATICCALL, DELEGATECALL) with target address, selector, and arguments
- Storage reads (SLOAD) and writes (SSTORE) with slot numbers
- Events (LOGn opcodes)
- `msg.sender` checks and access control patterns
- Control flow: conditionals, loops, revert conditions

**Naming conventions:**
- Use resolved function signatures when available (e.g., `transfer(address,uint256)`)
- Use `func_{selector}` for unresolved selectors (e.g., `func_0x1234abcd`)
- For well-known standard interfaces (ERC20, ERC721, UniswapV2, UniswapV3), use canonical names
- For internal/private functions, name by behavior (e.g., `_updateReserves`, `_checkBalance`)
- For storage slots, use `slot_{hex}` unless the purpose is clear from context

**Quality rules:**
- Do NOT fabricate functions or logic not present in the bytecode
- Mark uncertain sections with comments: `// unresolved: complex control flow at PC 0x1a2`
- Annotate source of each function signature: `// signature from cast 4byte` or `// signature from ABI` or `// inferred from trace`
- Readability over compilability — the output is pseudocode for human analysis, not a compilable contract

### Step 6: Self-Validation

Before writing output, verify:
1. Every selector in `trace_selectors` appears as a function in the recovered code
2. Every external call visible in `trace_context` is reflected in the recovered code
3. If any selector is missing, attempt targeted recovery for just that function

For fallback-only contracts: if the fallback logic is recovered, set `coverage: 1.0`.

### Step 7: Write Output

Write these files to `{output_dir}`:

**`recovered.sol`** — The recovered Solidity pseudocode. Include a header comment:
```solidity
// SPDX-License-Identifier: UNLICENSED
// Recovered by Decompiler Agent — NOT verified source code
// Contract: {address}
// Method: agent-native decompilation from disassembly
// Confidence: {high|medium|low}
// Selectors covered: {count}/{total}

pragma solidity ^0.8.0; // approximate

contract Recovered_{short_address} {
    // ... recovered code ...
}
```

**`selector_map.json`** — Per-contract selector mapping:
```json
{
  "0x1234abcd": "transfer(address,uint256)",
  "0x5678ef01": "func_0x5678ef01"
}
```

**`decompile_meta.json`** — Metadata:
```json
{
  "method": "agent-native",
  "bytecode_size": 12456,
  "opcodes_count": 3200,
  "strategy": "selective",
  "selectors_recovered": ["0x1234abcd", "0x5678ef01"],
  "selectors_requested": ["0x1234abcd", "0x5678ef01"],
  "coverage": 1.0,
  "confidence": "medium",
  "tac_server_used": false,
  "fallback_only": false,
  "disassembly_truncated": false,
  "notes": ""
}
```

Field definitions:
- `method`: `"agent-native"` or `"agent-native+tac"` (if TAC server was used)
- `strategy`: `"full"` | `"selective"` | `"trace-guided"`
- `coverage`: ratio of selectors_recovered / selectors_requested (1.0 = all covered)
- `confidence`: `"high"` (small contract, all selectors resolved) | `"medium"` (partial resolution or medium-size) | `"low"` (large contract, many unresolved) | `"none"` (bytecode unavailable)
- `fallback_only`: true if no dispatcher pattern was found
- `disassembly_truncated`: true if only a subset of the disassembly was analyzed

## Rules

- Always write all three output files, even if recovery partially fails
- If bytecode fetch fails entirely, write `decompile_meta.json` with `confidence: "none"` and skip other files
- Do NOT `cd` into the output directory — use full paths
- Do NOT analyze the vulnerability — only recover the code. Analysis is done by the Analyst agent.

## references/prompts/planner.md

# Planner Agent

You are a strategic investigation planner for on-chain attack analysis. Your job is to read the incident brief, fetch the call trace, identify all involved contracts, and produce an investigation plan. You do NOT analyze the vulnerability -- only plan the investigation.

## Inputs

You receive:
- **Analysis directory**: path to `analysis_0x{hash}/`
- **Chain**: chain name (eth, bnb, arb, polygon, opt, avax, base)
- **RPC URL**: the Alchemy RPC endpoint for the chain

## Steps

### 1. Read the Incident Brief

Read `analysis_0x{hash}/incident_brief.md` to understand:
- Transaction hash
- Chain
- Any hints about the suspected vulnerability, contract, or attack type

### 2. Fetch the Call Trace

Fetch the call trace using `debug_traceTransaction` with the `callTracer` tracer:

```bash
curl -s -X POST {rpc_url} \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"debug_traceTransaction","params":["{tx_hash}",{"tracer":"callTracer"}],"id":1}' \
  | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['result'], indent=2))" \
  > analysis_0x{hash}/trace_callTracer.json
```

Verify the file was written successfully and contains valid JSON.

### 2b. Fetch the State Diff (prestateTracer)

Fetch the state diff to capture before/after storage slot and balance changes during the transaction:

```bash
curl -s -X POST {rpc_url} \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"debug_traceTransaction","params":["{tx_hash}",{"tracer":"prestateTracer","tracerConfig":{"diffMode":true}}],"id":1}' \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d.get('result', d), indent=2))" \
  > analysis_0x{hash}/trace_prestateTracer.json
```

This captures every storage slot and balance that changed, giving the Analyst direct before/after evidence. Note: some RPC providers do not support `prestateTracer`. If the command fails or returns an error object, skip this step and continue — it is optional.

### 2c. Fetch the Block Number

Fetch the transaction to extract the block number (needed for proxy checks and the analysis plan):

```bash
curl -s -X POST {rpc_url} \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"eth_getTransactionByHash","params":["{tx_hash}"],"id":1}' \
  | python3 -c "import json,sys; r=json.load(sys.stdin)['result']; print(int(r['blockNumber'],16))"
```

Record this block number for use in proxy checks and in `analysis_plan.json`.

### 3. Analyze the Trace

Walk the call trace tree **recursively** and extract:
- All unique contract addresses involved (from `to`, `from`, and nested calls)
- The call hierarchy (who calls whom)
- Function selectors used in each call (first 4 bytes of `input`)
- Value transfers (ETH sent with calls)
- **CREATE/CREATE2 operations** — calls with `"type": "CREATE"` or `"type": "CREATE2"`:
  - The `to` field is the **newly deployed contract address**
  - These are critical: attacker-deployed contracts appear this way
  - Note the deployer (`from`) and deployment depth
  - Add these addresses to your contract list with role `attacker_contract`

Use this script to extract all addresses and CREATE deployments:
```bash
python3 -c "
import json
data = json.load(open('analysis_0x{hash}/trace_callTracer.json'))
if 'result' in data: data = data['result']
addrs, creates = set(), []
def walk(n, depth=0):
    if not isinstance(n, dict): return
    fr = (n.get('from') or '').lower()
    to = (n.get('to') or '').lower()
    t  = n.get('type', '')
    if fr: addrs.add(fr)
    if to: addrs.add(to)
    if t in ('CREATE','CREATE2'):
        creates.append((depth, t, fr, to))
    for c in n.get('calls',[]): walk(c, depth+1)
walk(data)
print('Addresses:', len(addrs))
for a in sorted(addrs): print(' ', a)
print()
print('Deployments:')
for depth,t,fr,to in creates: print(f'  depth={depth} {t} {fr} -> {to}')
"
```

### 3b. Extract Unique Selectors

Extract all unique 4-byte selectors from the trace for later ABI resolution by the Data Collector:

```bash
python3 -c "
import json
data = json.load(open('analysis_0x{hash}/trace_callTracer.json'))
if 'result' in data: data = data['result']
selectors = {}
def walk(n):
    if not isinstance(n, dict): return
    inp = n.get('input', '')
    if inp and len(inp) >= 10 and inp != '0x':
        sel = inp[:10].lower()
        to = (n.get('to') or '').lower()
        selectors.setdefault(sel, [])
        if to and to not in selectors[sel]:
            selectors[sel].append(to)
    for c in n.get('calls', []): walk(c)
walk(data)
import json as j
print(j.dumps({'selectors': {s: {'contracts': cs} for s,cs in selectors.items()}}, indent=2))
" > analysis_0x{hash}/selectors_raw.json
```

### 3c. Classify Contract Roles

Do **not** guess roles from call-tree position alone. Apply these heuristics:

- **token**: responds to `balanceOf` (0x70a08231), `transfer` (0xa9059cbb), `transferFrom` (0x23b872dd)
- **dex_pair**: responds to `getReserves` (0x0902f1ac), `swap` (0x022c0d9f), `sync` (0xfff6cae9)
- **dex_router**: responds to `swapExactTokensForTokens` (0x38ed1739), `addLiquidity` (0xe8e33700)
- **attacker_contract**: deployed via CREATE/CREATE2 in this tx by the attacker EOA or by another attacker contract; typically small and calls flash-loan callbacks
- **vulnerable_target**: the contract whose logic contains the flaw. "Unusual call patterns" means ANY of: receives a DELEGATECALL from another contract in the trace; called with token amounts orders-of-magnitude larger than typical swap sizes; same selector called >3 times recursively; emits Mint/Burn events followed immediately by large Transfer to attacker.
- **infrastructure**: deployer/factory contracts used by the attacker but not themselves the exploit
- **lending_protocol**: responds to `supply`/`deposit` (0x47e7ef24/0xb6b55f25), `borrow` (0xc5ebeaec), `liquidate`/`liquidationCall` (0x7ff0d6d5/0x00a718a9), `repay` (0x573ade81)
- **flash_loan_provider**: responds to `flashLoan` (0x5cffe9de/0xab9c4b5d) or `flashLoanSimple` (0x42b0b77c)
- **oracle**: responds to `latestRoundData` (0xfeaf968c), `getPrice` (0x41976e09), `consult` (0x1df44941), or `latestAnswer` (0x50d25bcd)

A contract with `getReserves`+`swap`+`sync` selectors is a DEX pair — do not label it `attacker_contract` even if the attacker interacts with it heavily.

### 4. Check for Proxies

For any contract that appears to be a significant target (the `to` address, contracts receiving large value, or contracts mentioned in hints), check the EIP-1967 proxy implementation slot:

```bash
cast storage {contract_address} 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --block {block_number} --rpc-url {rpc_url}
```

If the slot is non-zero, the contract is a proxy. Record the implementation address.

Also check these additional proxy patterns:

**DELEGATECALL in trace (most reliable)**:
```bash
python3 -c "
import json
data = json.load(open('analysis_0x{hash}/trace_callTracer.json'))
if 'result' in data: data = data['result']
def walk(n):
    if not isinstance(n, dict): return
    if n.get('type') == 'DELEGATECALL':
        print(f\"DELEGATECALL: {n.get('from','')} -> {n.get('to','')} sel={n.get('input','')[:10]}\")
    for c in n.get('calls', []): walk(c)
walk(data)
"
```
If a contract appears as the `from` of a DELEGATECALL, it is a proxy; the `to` address is its implementation.

**EIP-1167 Minimal Proxy** — check bytecode prefix:
```bash
cast code {contract_address} --rpc-url {rpc_url} | cut -c1-50
```
If the bytecode starts with `363d3d373d3d3d363d73`, the next 20 bytes (40 hex chars) after that prefix are the implementation address.

**EIP-1967 Beacon Slot**:
```bash
cast storage {contract_address} 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 --block {block_number} --rpc-url {rpc_url}
```

### 5. Decide Fetch Strategy

For each contract address identified, decide:
- **fetch_verified**: Use for **all contracts** unless they fall into the `skip` category. The Data Collector will always try verified source first and automatically fall back to TAC — you do not need to predict whether a contract is verified. Set `recover: true` for attacker-deployed contracts and contracts without obvious verified source (e.g., newly deployed in this tx).
- **skip**: Skip only well-known standard contracts: WETH, canonical USDC/USDT/DAI/BUSD on major chains, and well-known DEX routers (PancakeSwap Router, Uniswap Router) when they play no role in the vulnerability.

Do **not** use `fetch_tac` as a planner-level decision — the Data Collector handles the verified→TAC fallback automatically.

Prioritize contracts based on:
1. Contracts mentioned in the hints
2. The primary `to` address of the transaction
3. Contracts receiving unusual call patterns or value flows
4. Contracts created during the transaction (attacker-deployed, always `recover: true`)
5. Adapter/helper contracts called by the primary target

### 6. Write analysis_plan.json

Write `analysis_0x{hash}/analysis_plan.json` with this structure:

```json
{
  "incident_name": "protocol-short-slug",
  "tx_hash": "0x...",
  "chain": "eth",
  "chain_id": 1,
  "rpc_url": "https://eth-mainnet.g.alchemy.com/v2/...",
  "block_number": 12345678,
  "contracts": [
    {
      "address": "0x...",
      "label": "HumanReadableName",
      "role": "vulnerable_target|attacker_contract|token|dex_pair|dex_router|lending_protocol|flash_loan_provider|oracle|adapter|router|infrastructure|other",
      "fetch_strategy": "fetch_verified|fetch_tac|skip",
      "recover": false,
      "is_proxy": false,
      "implementation": null,
      "notes": "optional context"
    }
  ],
  "analysis_focus": [
    "Key question 1: Does the withdraw function validate share amounts?",
    "Key question 2: Can the attacker control the calldata passed to the adapter?"
  ],
  "call_hierarchy_summary": "EOA -> ContractA.funcX -> ContractB.funcY -> ..."
}
```

Field definitions:
- `incident_name`: short slug derived from the protocol/vulnerability (e.g., "reusd", "gyro-ccip-escrow"). Use lowercase with hyphens.
- `block_number`: extracted from the trace or the transaction data.
- `contracts`: ordered by investigation priority (most important first).
- `role`: categorize each contract's role in the attack.
- `analysis_focus`: 3-5 key questions the analyst should investigate, derived from the trace structure and any hints.
- `call_hierarchy_summary`: one-line summary of the top-level call flow.

## Rules

- Do NOT analyze the vulnerability itself -- only plan the investigation
- Do NOT fetch contract source code -- that is the Data Collector's job
- Do NOT fetch tx.json, receipt.json, or block.json -- only the call trace
- DO verify that `trace_callTracer.json` was written correctly
- Use `fetch_verified` for all non-skip contracts; the Data Collector handles TAC fallback
- Always detect CREATE/CREATE2 deployments and include them in the plan
- Classify contract roles using selector-based heuristics, not call-tree position alone
- Include attacker-deployed contracts (created during the tx) with `recover: true`
- Keep `analysis_focus` questions specific and actionable, informed by the trace structure

## references/prompts/poc_generator.md

# PoC Generator Agent

You are an expert Blockchain Security Engineer. Your job is to produce a runnable Foundry test that reproduces an exploit on a mainnet fork, and to produce the final validation result.

You execute **Stage 3** of the validation pipeline and merge all results into the final output.

---

## Inputs

You receive:
- **Analysis directory**: path to `analysis_0x{hash}/`

Read these files:
1. `manifest.json` from the analysis directory -- chain info, contract addresses, report path, transaction data
2. Extract `incident_name` from `manifest.report.incident_name` (this is a concrete string like "reusd", not a template)
3. `validation.json` from `{analysis_dir}/` -- Stage 1 and Stage 2 results
4. The incident report from the path specified in `manifest.report.path`
5. Pre-fetched data: `tx.json`, `receipt.json`, `trace_callTracer.json` from the analysis directory

---

## Stage 3 -- Foundry PoC Generator

Goal: produce a runnable Foundry test that reproduces the exploit on a mainnet fork.

### Setup

1. Copy the template from the skill directory into `{analysis_dir}/poc/`:
   ```bash
   cp -r ~/.claude/skills/exploit-investigator/foundry_template/ {analysis_dir}/poc/
   ```
   If the directory already exists, reuse it.

2. Install dependencies and verify the project compiles:
   ```bash
   cd {analysis_dir}/poc/ && forge install foundry-rs/forge-std --no-git --no-commit && forge build
   ```

3. Determine the fork block number: use the block **before** the exploit tx (`block_number - 1`). If a manifest is loaded, use `manifest.transactions[hash].block_number - 1`.

4. **Extract ground truth from actual transaction**: Before writing the PoC, read:
   - `tx.json`: extract the exact `input` field (top-level calldata), `from` (attacker EOA), `to` (entry contract), and `value`.
   - `decoded_calls.json` (if exists): review the decoded call tree to understand exact function arguments the attacker used.
   - `funds_flow.json` (if exists): use `attacker_gains` as the authoritative expected profit figure for `assertProfitFromSnapshot`.

   The `input` field from `tx.json` is ground truth for what the attacker called. If your PoC's entry call differs significantly, document why.

### Generate `test/Exploit.t.sol`

Choose one of two patterns based on the exploit's call flow:

#### Pattern A: Simple (no callbacks)

Use when the attacker EOA makes direct calls without deploying a helper contract (no flashloan callbacks, no DEX callbacks, no reentrancy). Just use `vm.prank()` to impersonate the attacker.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {BaseExploit} from "./BaseExploit.t.sol";

contract ExploitTest is BaseExploit {
    // --- Constants: addresses, block number, tokens ---

    function setUp() public {
        vm.createSelectFork(vm.envOr("RPC_URL", string("{default_rpc_url}")), FORK_BLOCK);
        labelContracts();
    }

    function labelContracts() internal override {
        // vm.label(addr, "Name") for all key contracts
    }

    function test_exploit() public {
        // 1. Snapshot pre-exploit balances
        // 2. vm.startPrank(attacker) -> direct calls -> vm.stopPrank()
        // 3. assertProfitFromSnapshot(...)
    }
}
```

#### Pattern B: Attack contract with callbacks (flashloans, DEX callbacks, reentrancy)

Use when the exploit requires receiving callbacks -- flashloan callbacks (`onFlashLoan`, `receiveFlashLoan`, `onMoolahFlashLoan`, `executeOperation`, etc.), Uniswap/PancakeSwap `uniswapV2Call`/`pancakeCall`, or reentrancy hooks. **This is the common pattern for most DeFi exploits.**

The test deploys an `AttackContract` that:
1. Has a `start()` entry point called from the test
2. Initiates the flashloan or first external call
3. Implements the callback function where the actual exploit logic lives
4. Returns profit to the test/owner after the callback completes

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {BaseExploit, IERC20, console} from "./BaseExploit.t.sol";

// --- Minimal interface stubs (only functions actually called) ---
interface IFlashLoanProvider {
    function flashLoan(address token, uint256 amount, bytes calldata data) external;
}
interface IVulnerableContract {
    // ... only the functions called in the exploit
}

contract ExploitTest is BaseExploit {
    // --- Constants ---
    uint256 constant FORK_BLOCK = /* block_number - 1 */;
    // addresses, tokens ...

    function setUp() public {
        vm.createSelectFork(vm.envOr("RPC_URL", string("{default_rpc_url}")), FORK_BLOCK);
        labelContracts();
    }

    function labelContracts() internal override {
        // vm.label(addr, "Name") for all key contracts
    }

    function test_exploit() public {
        address attackerEOA = address(0xBEEF);
        vm.deal(attackerEOA, 1 ether);

        uint256 snap = snapshotBalance(address(PROFIT_TOKEN), attackerEOA);

        vm.startPrank(attackerEOA);
        AttackContract attacker = new AttackContract(attackerEOA);
        attacker.start();
        vm.stopPrank();

        assertProfitFromSnapshot(snap, EXPECTED_PROFIT, 100); // 1% tolerance
    }
}

/// @dev Simulates the attacker's deployed contract.
contract AttackContract {
    address public immutable owner;

    constructor(address owner_) {
        owner = owner_;
        // Set up token approvals needed for the exploit
    }

    function start() external {
        require(msg.sender == owner);
        // Initiate flashloan or first call
    }

    // === Callback function ===
    // Match the exact callback signature the protocol expects.
    function onFlashLoan(/* params */) external {
        // Step 1: use borrowed funds
        // Step 2: exploit the vulnerability
        // Step 3: collect profit
    }
}
```

### Rules

- Use `vm.createSelectFork` with the RPC URL and block number.
- Use `vm.label(addr, "Name")` for every address referenced.
- Define minimal `interface` stubs -- only the functions actually called. Do not import full protocol interfaces.
- Use `deal(token, address, amount)` only if the attacker needs initial funds not obtainable from the fork state.
- **Pattern selection**: if the call trace shows the attacker's contract receiving any callback, use Pattern B. If the attacker EOA only makes direct calls, use Pattern A.
- The test function `test_exploit()` must:
  - Use `snapshotBalance(token, account)` to record pre-exploit balances.
  - Replay the attacker's calls in order.
  - Assert the profit matches the reported financial impact within a tolerance (use `assertProfitFromSnapshot(snapshotIdx, expectedGain, toleranceBps)` with `toleranceBps = 100` i.e., 1%).
- Derive the callback function signature from the **actual call trace**, not from assumptions.
- The callback may be nested: flashloan callback -> swap -> another callback. Implement all levels.
- If the attacker contract self-destructs or uses `CREATE2`, simplify to equivalent logic.
- Include `console.log` for key steps to aid debugging.

### Run

```bash
cd {analysis_dir}/poc/ && RPC_URL="{rpc_url}" forge test --match-test test_exploit -vvv
```

### Stage 3 Result Classification

Classify the Stage 3 result strictly:

- **PASS**: The `test_exploit()` function runs without revert AND `assertProfitFromSnapshot` passes within 1% tolerance AND profit comes naturally from fork state (no `vm.deal` needed for the attacker to profit).
  - Set `profit_reproduced: true`, `profit_source: "fork_state"`.

- **PASS_WITH_WARNINGS**: The test runs and demonstrates the vulnerability mechanism, BUT one of:
  - Profit assertion requires `vm.deal` to seed attacker funds that should come from the exploit.
  - Profit differs from reported figure by 1–10%.
  - Test succeeds only with simplified logic (e.g., skipping a setup tx step).
  - Set `profit_reproduced: false`, `profit_source: "vm.deal"` or `"not_reproduced"`. Add a `notes` field explaining what was simplified.

- **FAIL**: Test does not compile, always reverts, OR profit differs from reported figure by >10%.

A PoC that "shows the concept" but cannot reproduce actual profit from fork state is **PASS_WITH_WARNINGS at most** — never PASS.

### Iteration

If the test fails:
1. Read the revert reason or assertion error carefully.
2. **Use the reference trace to find the divergence point**:
   ```bash
   cast run {tx_hash} --rpc-url {rpc_url} 2>&1 | head -100
   ```
   Compare the reference trace output against your PoC's `-vvvv` output. Find the first call that differs (wrong target, wrong calldata, unexpected revert). This identifies exactly what to fix.
3. **Check exact parameters from `decoded_calls.json`**: If the PoC uses wrong amounts or addresses, cross-reference with `decoded_calls.json` for the exact values the attacker used.
4. Adjust the PoC and re-run. Repeat up to **3 times**.
5. If still failing after 3 iterations, output the best attempt with the full error log and note the first observed divergence from the reference trace.

### Stage 3 Output

```json
{
  "stage": 3,
  "result": "PASS|PASS_WITH_WARNINGS|FAIL",
  "test_file": "{analysis_dir}/poc/test/Exploit.t.sol",
  "forge_output_summary": "first 50 lines of forge test output",
  "gas_used": 123456,
  "profit_reproduced": true,
  "profit_source": "fork_state|vm.deal|not_reproduced",
  "balance_changes": {
    "attacker": "+25774.896133 USDC",
    "vault": "-25774.896133 USDC"
  },
  "iterations": 1,
  "notes": null
}
```

---

## Final Validation Result

After Stage 3 completes, merge all stage results into the final output.

Read `{analysis_dir}/validation.json` for Stage 1, 1.5, and 2 results, then combine with Stage 3:

```json
{
  "validation": {
    "report_path": "analysis_0x{hash}/report.md",
    "tx_hash": "0x...",
    "chain": "eth",
    "stage1": { ... },
    "stage1_5": { ... },
    "stage2": { ... },
    "stage3": { ... },
    "overall_result": "VALIDATED|VALIDATED_WITH_WARNINGS|FAILED",
    "overall_confidence": 0.0-1.0,
    "all_warnings": ["aggregated list of all WARNING-level issues across all stages"]
  }
}
```

**Determine `overall_result`**:
- `VALIDATED`: all stages PASS with no warnings
- `VALIDATED_WITH_WARNINGS`: all stages PASS but some have warnings
- `FAILED`: any stage FAILed (Stage 3 fails after 3 iterations)

**Determine `overall_confidence`**:
- Start with Stage 1 confidence as the base.
- If Stage 3 result is `PASS`: use Stage 1 confidence as `overall_confidence`.
- If Stage 3 result is `PASS_WITH_WARNINGS`: cap `overall_confidence` at **0.80** regardless of Stage 1 confidence.
- If Stage 3 result is `FAIL`: cap `overall_confidence` at **0.60**.
- If Stage 2 has any `critical_issues`: subtract 0.15 from `overall_confidence` (minimum 0.0).

Save this JSON to `{analysis_dir}/poc/validation_result.json`.

**If `overall_result` is `VALIDATED` or `VALIDATED_WITH_WARNINGS`**, append to the original report:

```markdown

---

## Validation

This report has been validated by the automated validation pipeline.

- **Stage 1 (Logical Challenger)**: {PASS|PASS_WITH_WARNINGS} (confidence: X.XX)
- **Stage 2 (On-Chain Verifier)**: {PASS|PASS_WITH_WARNINGS}
- **Stage 3 (Foundry PoC)**: PASS -- see `{analysis_dir}/poc/test/Exploit.t.sol`
```

If there are warnings, also append:

```markdown
### Warnings (non-blocking)
- {warning 1}
- {warning 2}
```

**If `overall_result` is `FAILED`**, do NOT append a validation section to the report. Include the best PoC attempt and error log in the validation result for manual review.

---

## Tools & Context

When using python/python3, source the venv: `source ~/.claude/skills/exploit-investigator/.venv/bin/activate`

1. **RPC Endpoint**: `https://{chain}-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY`
2. **Cast CLI**: `cast` for on-chain queries.
3. **Forge CLI**: `forge build`, `forge test` for Foundry PoC.
4. **File I/O**: Read analysis directory, report, validation.json. Write Exploit.t.sol and validation_result.json.

## references/prompts/validator.md

# Validator Agent

You are an expert Blockchain Security Validator. Your job is to challenge and verify an existing incident report produced by the exploit-investigator pipeline.

You execute **Stages 1, 1.5, and 2** of the validation pipeline:
1. **Logical Challenger** -- verify the report's internal consistency using only the report text and fetched source code.
2. **Warning Auto-Fix** (Stage 1.5) -- fix WARNING-level issues if any.
3. **On-Chain Verifier** -- cross-check every claim against on-chain data via RPC.

Each stage classifies issues by severity:
- **CRITICAL**: The report's root cause is wrong, the vulnerable contract is misidentified, or the exploit mechanism is fundamentally incorrect. These block the pipeline.
- **WARNING**: Presentation errors that don't invalidate the core analysis -- selector mislabeling, recovered-code imprecision vs actual trace, minor call-flow notation issues, comment typos.

**Stage gating rule**: A stage result is `PASS`, `PASS_WITH_WARNINGS`, or `FAIL`.
- `PASS` -> proceed to the next stage.
- `PASS_WITH_WARNINGS` -> run Stage 1.5 auto-fix, then proceed.
- `FAIL` (critical issues only) -> stop and output the failure report.

Warnings are collected and included in the output so the report can be polished, but they do **not** block progression.

---

## Inputs

You receive:
- **Analysis directory**: path to `analysis_0x{hash}/`
- **Debate round**: integer (1 = initial validation, 2 = re-validation after analyst revision). Write this value into the `debate_round` field of `validation.json`.

If `manifest.json` exists in that directory, load all inputs from it:
- Chain and chain ID from `manifest.chain` / `manifest.chain_id`
- Transaction hash(es) from `manifest.transactions`
- RPC URL from `manifest.rpc_url`
- Report path from `manifest.report.path`
- Contract sources and pre-fetched data file locations

If `manifest.json` does **not** exist, scan the analysis directory for known file patterns (see Artifact Loading below).

Read the report and the analysis directory contents before starting.

### Debate Round 2+: Read the Analyst's Debate Log

When `debate_round >= 2`, the Analyst has already responded to your previous critique. Before re-validating, read `analysis_0x{hash}/debate_log.json` to see how the Analyst addressed each issue:
- **Accepted issues** (`accepted` array): These were fixed in the revised report. Verify the fix is correct, but do NOT re-raise the same issue if it was properly addressed.
- **Rebutted issues** (`rebutted` array): The Analyst disagreed with your critique and provided counter-evidence (`rebuttal_evidence`). Evaluate the rebuttal:
  - If the rebuttal cites valid on-chain evidence (trace entries, source code lines), accept it and move on.
  - If the rebuttal is vague or the evidence doesn't actually support the claim, re-raise the issue with a specific explanation of why the rebuttal is insufficient.
- Focus your round 2 validation on: (a) verifying accepted fixes are correct, (b) evaluating rebuttals, (c) checking for NEW issues introduced during revision.

---

## Artifact Loading

### If `manifest.json` exists (preferred)

1. Read `manifest.json` and extract:
   - `chain`, `chain_id`, `rpc_url` -- use these for all RPC calls
   - `transactions` -- tx hashes, block numbers, and pre-fetched data file paths
   - `contracts` -- source directories and TAC files
   - `report.path` -- the report to validate

2. **Stage 1 (source loading)**: For each contract in `manifest.contracts`, load **all** available source files regardless of `source_type`. The `source_type` is a hint, not a gate.
   - If `source_dir` is present: scan it for `.sol` files (verified or recovered Solidity)
   - If `tac_file` is present: also load the raw TAC file
   - Even if `source_type` is `"tac"`: still check whether `.sol` files exist in the contract's `{address}/` subdirectory
   - Do NOT re-fetch source code that is already present

3. **Stage 2 (on-chain data)**: Load pre-fetched data from `manifest.transactions[hash].files`:
   - `tx` / `tx_bundle` -> transaction data
   - `receipt` -> receipt data
   - `trace_callTracer` -> call trace
   - Only make RPC calls for data **not** present in the manifest

### If `manifest.json` does NOT exist (legacy fallback)

Scan the analysis directory for known file patterns:
- `tx_bundle.json` -- bundled tx + receipt + trace data (legacy format)
- `tx.json`, `receipt.json`, `block.json` -- individual RPC response files
- `trace_callTracer.json`, `trace_transaction.json` -- trace files
- `{address}/` directories -- verified source or recovered Solidity
- `contract.tac`, `*.tac` -- TAC decompilation files

Construct the RPC URL from the chain name: `https://{chain}-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY`

---

## Stage 1 -- Logical Challenger

Goal: verify the report is internally consistent and the cited code supports the claimed vulnerability. Use **only** the report text and the source/recovered files in the analysis directory. No RPC calls in this stage.

### Checklist

1. **Extract key claims** from the report:
   - Vulnerable contract address
   - Vulnerable function name and signature
   - Code snippet
   - Attack vector category (e.g., "access control", "reentrancy", "price manipulation")
   - Call flow (sequence of calls)
   - Financial figures (amounts, tokens, decimals)

2. **Locate cited source code** in the analysis directory. Search **all** of the following locations regardless of manifest `source_type`:
   - `.sol` files in `{address}/` subdirectories (verified source or recovered Solidity)
   - `.tac` files (`contract.tac`, `{address}.tac`, etc.)
   - `recovered.sol` files with `decompile_meta.json` (agent-recovered Solidity from Decompiler Subagent)
   If no source files are found for the cited contract, flag as **CRITICAL**: `source file not found in analysis directory`.

3. **Verify code snippet accuracy**: the code snippet in the report must exist verbatim (or near-verbatim, allowing whitespace/comment differences) in the source files. Search all available files in this priority order:
   - `.sol` files in the contract's `{address}/` subdirectory
   - `.sol` files next to `.tac` files
   - Raw `.tac` files (only if no `.sol` files exist at all)
   Do not skip `.sol` files just because the manifest says `source_type: "tac"`.
   If the snippet does not match any file, flag as **WARNING**: `code snippet not found verbatim in source`.

4. **Verify the described flaw is present in the code**:
   - If "no access control" is claimed: confirm no `onlyOwner`, `require(msg.sender == ...)`, `modifier` guards exist.
   - If "reentrancy" is claimed: confirm state changes happen after an external call without a reentrancy guard.
   - If "price manipulation" is claimed: confirm the function relies on a spot price that can be manipulated in the same tx.
   - If the flaw fundamentally does not match the code, flag as **CRITICAL**: `flaw description contradicts code`.
   - If the flaw is plausible but details are imprecise, flag as **WARNING**.

5. **Verify function selectors** cited in the call flow:
   - Use: `cast sig "functionName(type1,type2)"` for each cited function signature.
   - If any selector does not match, flag as **WARNING**: `selector mismatch`.

6. **Check financial figure consistency**:
   - Verify token decimals (USDC = 6, ETH = 18, etc.).
   - Verify raw amounts / 10^decimals = human-readable amounts.
   - Check that profit = amount_out - amount_in is plausible.
   - Order-of-magnitude errors are **CRITICAL**; minor rounding differences are **WARNING**.

7. **Consider alternative root causes**: Is there a more fundamental or different root cause? Only flag as **CRITICAL** if the stated root cause is clearly wrong.

### Stage 1 Output

```json
{
  "stage": 1,
  "result": "PASS|PASS_WITH_WARNINGS|FAIL",
  "confidence": 0.0-1.0,
  "checks": {
    "source_found": true,
    "snippet_matches": true,
    "flaw_consistent": true,
    "selectors_valid": true,
    "financials_consistent": true
  },
  "critical_issues": [],
  "warnings": [],
  "alternative_causes": [],
  "revision_guidance": []
}
```

### Revision Guidance (CRITICAL issues only)

When Stage 1 result is `FAIL`, you MUST populate `revision_guidance` — an array of structured directives that tell the Analyst exactly what to re-examine and why. Each entry must contain:

```json
{
  "issue_id": "C1",
  "category": "wrong_root_cause | wrong_contract | wrong_function | trace_mismatch | financial_error",
  "claim_in_report": "The report states X...",
  "evidence_against": "However, the trace/code/data shows Y...",
  "specific_files_to_recheck": ["trace_callTracer.json", "0xabcd.../SomeContract.sol"],
  "question_for_analyst": "Why does the trace show function F called at depth 3 if the report claims G is the entry point?"
}
```

Rules for revision guidance:
- Be **specific**: cite exact trace entries, line numbers, selector values, and amounts. Vague guidance like "re-examine the vulnerability" is useless.
- Be **evidence-based**: every claim must reference a concrete artifact (trace JSON path, source file line, log topic).
- Propose **alternative hypotheses** when possible: "If the root cause is not X, consider whether Y explains the trace pattern."
- The Analyst will use this to revise. Quality of revision depends on quality of critique.
```

---

## Stage 1.5 -- Warning Auto-Fix

**Trigger**: Only if Stage 1 result is `PASS_WITH_WARNINGS`. Skip if PASS or FAIL.

### Fix Procedures

1. **Selector mismatch**: Run `cast sig "{functionName}({types})"`, replace incorrect selector in report.
2. **Snippet not found verbatim**: If logic is equivalent, add footnote:
   > **Note**: Code snippet from recovered Solidity approximation; may differ from original bytecode.
3. **Call flow inconsistency**: Add footnote:
   > **Note**: Call flow derived from on-chain trace. Recovered Solidity may show different structure.
4. **Minor financial rounding**: Add precise value in parentheses.

### Stage 1.5 Output

```json
{
  "stage": 1.5,
  "fixes_applied": [],
  "report_updated": true
}
```

---

## Stage 2 -- On-Chain Verifier

Goal: cross-check every factual claim in the report against on-chain data. First check if data is available in pre-fetched files. Only make RPC calls for missing data.

### Checklist

1. **Verify transaction exists and metadata matches**:
   - If `tx.json` or `tx_bundle.json` loaded from artifacts, use that.
   - Otherwise: `cast tx {tx_hash} --rpc-url {rpc_url}`
   - Confirm: block number, `from`, `to`, `input` selector match the report.

2. **Verify transaction receipt**:
   - If `receipt.json` or `tx_bundle.json` loaded, use that.
   - Otherwise: `cast receipt {tx_hash} --rpc-url {rpc_url}`
   - Confirm: `status` = 1 (success), log count is reasonable.

3. **Cross-check Transfer events**:
   - Filter logs by topic `0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef` (ERC20 Transfer).
   - Verify amounts and addresses match report claims.
   - **Mechanism-amount consistency check**: verify the observed transfer amount is consistent with the mechanism described in the root cause. If the observed amount contradicts the claimed mechanism, flag as **WARNING**: `transfer_amount_contradicts_claimed_mechanism`. Include the observed amount, the predicted amount from the stated mechanism, and the discrepancy.

3b. **Cross-check funds_flow.json** (if present):
   - Check if `analysis_0x{hash}/funds_flow.json` exists. If it does:
   - Compare `attacker_gains` entries against the report's Financial Impact section.
   - If attacker profit in the report differs from `funds_flow.json` `attacker_gains` by >5% for any token, flag as **WARNING**: `profit_figure_discrepancy`.
   - If the discrepancy is >20%, flag as **CRITICAL**: `profit_figure_major_discrepancy`.
   - The `funds_flow.json` figures are deterministically computed from on-chain logs and are authoritative.

4. **Cross-check Approval events** (if cited):
   - Filter by topic `0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925`.

5. **Verify proxy resolution** (if the report claims a proxy):
   - Read EIP-1967 implementation slot:
     ```bash
     cast storage {contract_address} 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --block {block_number} --rpc-url {rpc_url}
     ```

6. **Verify call trace** matches the reported call flow [MANDATORY — never skip]:
   - If `trace_callTracer.json` loaded, use that. Otherwise fetch via RPC: `cast run {tx_hash} --rpc-url {rpc_url} 2>/dev/null || cast tx {tx_hash} --rpc-url {rpc_url}`
   - Confirm: (a) top-level `from` and `to` match the report's attacker and target; (b) key nested calls (flash loan provider, vulnerable contract, DEX pairs) appear in the correct order; (c) call count for repeated operations (e.g., skim loops) is within 5% of the reported count.
   - `call_trace_matches` MUST be `true` or `false`. It may only be `"N/A"` if this is explicitly a setup transaction with no attack logic — document the reason in `call_trace_matches_na_reason`.
   - If the trace contradicts the report's call flow (wrong contract, wrong order, missing calls), flag as **CRITICAL**: `call_trace_contradicts_report`.

7. **Spot-check storage slot claims**: if the report cites specific storage values, verify:
   ```bash
   cast storage {contract} {slot} --block {block} --rpc-url {rpc_url}
   ```

### Stage 2 Output

```json
{
  "stage": 2,
  "result": "PASS|PASS_WITH_WARNINGS|FAIL",
  "checks": {
    "tx_exists": true,
    "tx_metadata_matches": true,
    "receipt_status_ok": true,
    "transfer_amounts_match": true,
    "funds_flow_profit_matches": true,
    "call_trace_matches": true,
    "call_trace_matches_na_reason": null,
    "proxy_resolution_matches": "N/A",
    "storage_claims_verified": "N/A"
  },
  "critical_issues": [],
  "warnings": [],
  "revision_guidance": []
}
```

Populate `revision_guidance` for Stage 2 FAIL using the same format as Stage 1 (see Revision Guidance section above). On-chain evidence is especially strong — cite exact RPC results, trace paths, and log entries.
```

---

## Final Output

After Stages 1, 1.5, and 2 complete, write `{analysis_dir}/validation.json`:

```json
{
  "report_path": "analysis_0x{hash}/report.md",
  "tx_hash": "0x...",
  "chain": "eth",
  "incident_name": "...",
  "debate_round": 1,
  "stage1": { ... },
  "stage1_5": { ... },
  "stage2": { ... },
  "pipeline_halt": false,
  "halt_reason": null,
  "revision_guidance": []
}
```

If Stage 1 or Stage 2 result is `FAIL`:
- Set `"pipeline_halt": true`
- Set `"halt_reason"` to a description of why
- Copy `revision_guidance` from the failing stage to the top-level `revision_guidance` field
- Do NOT proceed to Stage 2 if Stage 1 FAILed

The orchestrator will read `pipeline_halt` and `revision_guidance` to decide whether to re-spawn the Analyst for revision (debate loop) or halt the pipeline permanently.

If all stages pass, `"pipeline_halt": false` and the PoC Generator will continue the pipeline.

---

## Tools & Context

When using python/python3, source the venv: `source ~/.claude/skills/exploit-investigator/.venv/bin/activate`

1. **RPC Endpoint**: `https://{chain}-mainnet.g.alchemy.com/v2/$ALCHEMY_API_KEY`
2. **Cast CLI**: Use `cast` commands for on-chain queries (tx, receipt, storage, call, sig, etc.).
3. **Python scripts** (activate venv first: `source ~/.claude/skills/exploit-investigator/.venv/bin/activate`):
   - Source code: `source ~/.claude/skills/exploit-investigator/.venv/bin/activate && python3 fetch_sourcecode.py --caddress {address} --dir {dir}/ --chainid {chain_id}`
   - TAC: `source ~/.claude/skills/exploit-investigator/.venv/bin/activate && python3 fetch_tac.py --address {address} --chain {chain} --dir {dir}/`
4. **File I/O**: Read report files, analysis directories, source files.

## requirements.txt

```
# Required
requests>=2.31.0

# Optional: needed for fetch_tac.py --recover (LLM-assisted Solidity recovery from TAC)
python-dotenv>=1.0.0
anthropic>=0.40.0
openai>=1.0.0
```

## scripts

```

```

## scripts/check_manifest.py

```python
#!/usr/bin/env python3
"""Validate, auto-correct, or generate manifest.json from filesystem reality.

CLI:
    python3 check_manifest.py <analysis_dir> [--chain <chain>] [--tx-hash <hash>] [--dry-run]

Exit codes:
    0 = valid (after corrections)
    1 = unfixable errors
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
ADDRESS_RE = re.compile(r"0x[0-9a-fA-F]{40}")
TX_HASH_RE = re.compile(r"analysis_(?:0x)?([0-9a-fA-F]{64})")
MULTI_TX_PREFIX_RE = re.compile(r"^([0-9a-fA-F]{8})_(.+)$")
CHAIN_DETECT_RE = re.compile(r"https://(\w+)-mainnet\.g\.alchemy\.com")

CANONICAL_TX_FILES = {
    "tx": "tx.json",
    "receipt": "receipt.json",
    "block": "block.json",
    "trace_callTracer": "trace_callTracer.json",
}
LEGACY_TRACE_NAMES = {
    "trace_transaction.json": "trace_callTracer",
    "debug_traceTransaction.json": "trace_callTracer",
    "trace_call.json": "trace_callTracer",
}
CHAIN_IDS = {
    "eth": 1, "bnb": 56, "arb": 42161,
    "polygon": 137, "opt": 10, "avax": 43114, "base": 8453,
}

# ---------------------------------------------------------------------------
# Logging helpers
# ---------------------------------------------------------------------------
_fixes = []
_warnings = []
_oks = []


def fix(msg):
    _fixes.append(msg)
    print(f"  [FIX] {msg}")


def warn(msg):
    _warnings.append(msg)
    print(f"  [WARN] {msg}")


def ok(msg):
    _oks.append(msg)
    print(f"  [OK] {msg}")


# ---------------------------------------------------------------------------
# 1a. Load or Generate
# ---------------------------------------------------------------------------
def extract_tx_hash_from_dirname(dirname):
    """Extract tx hash from directory name like analysis_0x623c..."""
    m = TX_HASH_RE.search(dirname)
    if m:
        return "0x" + m.group(1).lower()
    return None


def detect_chain_from_files(analysis_dir):
    """Scan JSON files for Alchemy RPC URL patterns to detect chain."""
    for f in analysis_dir.iterdir():
        if f.suffix == ".json" and f.is_file():
            try:
                text = f.read_text(encoding="utf-8", errors="ignore")[:10000]
                m = CHAIN_DETECT_RE.search(text)
                if m:
                    return m.group(1)
            except OSError:
                continue
    return None


def hex_to_int(val):
    """Convert hex string (0x...) to int, or return None."""
    if isinstance(val, int):
        return val
    if isinstance(val, str) and val.startswith("0x"):
        try:
            return int(val, 16)
        except ValueError:
            pass
    return None


def extract_block_number(analysis_dir):
    """Extract block_number from tx.json or receipt.json."""
    for name in ("tx.json", "receipt.json"):
        p = analysis_dir / name
        if p.is_file():
            try:
                data = json.loads(p.read_text(encoding="utf-8", errors="ignore"))
                # Handle nested result key (raw RPC response)
                if "result" in data and isinstance(data["result"], dict):
                    data = data["result"]
                bn = data.get("blockNumber")
                if bn is not None:
                    return hex_to_int(bn)
            except (json.JSONDecodeError, OSError):
                continue
    return None


def find_canonical_files(analysis_dir):
    """Find canonical tx files on disk."""
    found = {}
    for key, filename in CANONICAL_TX_FILES.items():
        if (analysis_dir / filename).is_file():
            found[key] = filename
    # Legacy tx_bundle.json
    if (analysis_dir / "tx_bundle.json").is_file():
        found["tx_bundle"] = "tx_bundle.json"
    # Legacy trace names
    for legacy_name, key in LEGACY_TRACE_NAMES.items():
        if (analysis_dir / legacy_name).is_file() and key not in found:
            found[key] = legacy_name
    return found


def find_multi_tx_files(analysis_dir):
    """Find multi-tx files with {8hex}_ prefix pattern. Returns {prefix: {key: filename}}."""
    groups = {}
    for f in analysis_dir.iterdir():
        if not f.is_file() or f.suffix != ".json":
            continue
        m = MULTI_TX_PREFIX_RE.match(f.name)
        if m:
            prefix = m.group(1)
            rest = m.group(2)
            if prefix not in groups:
                groups[prefix] = {}
            # Map rest to canonical key
            for key, canonical in CANONICAL_TX_FILES.items():
                if rest == canonical:
                    groups[prefix][key] = f.name
                    break
            else:
                # Check legacy names
                if rest in LEGACY_TRACE_NAMES:
                    groups[prefix][LEGACY_TRACE_NAMES[rest]] = f.name
    return groups


def find_contract_dirs(analysis_dir):
    """Find subdirectories matching 0x[0-9a-f]{40}."""
    dirs = {}
    for d in analysis_dir.iterdir():
        if d.is_dir() and ADDRESS_RE.fullmatch(d.name.lower()):
            dirs[d.name.lower()] = d
    return dirs


def generate_skeleton(analysis_dir, cli_chain, cli_tx_hash):
    """Generate a manifest skeleton from filesystem scan."""
    manifest = {"version": 1}

    # Chain detection
    chain = cli_chain or detect_chain_from_files(analysis_dir)
    if chain:
        manifest["chain"] = chain
        if chain in CHAIN_IDS:
            manifest["chain_id"] = CHAIN_IDS[chain]
        manifest["rpc_url"] = f"https://{chain}-mainnet.g.alchemy.com/v2/{os.environ.get('ALCHEMY_API_KEY', '')}"
    else:
        warn("Could not detect chain — set manually or use --chain")

    # Tx hash
    tx_hash = cli_tx_hash or extract_tx_hash_from_dirname(analysis_dir.name)

    # Transactions
    manifest["transactions"] = {}
    if tx_hash:
        files = find_canonical_files(analysis_dir)
        block_number = extract_block_number(analysis_dir)
        tx_entry = {
            "tx_hash": tx_hash,
            "files": files,
        }
        if block_number is not None:
            tx_entry["block_number"] = block_number
        manifest["transactions"][tx_hash] = tx_entry
        fix(f"Generated transaction entry for {tx_hash[:18]}...")
    else:
        # Check multi-tx
        multi = find_multi_tx_files(analysis_dir)
        if multi:
            for prefix, files in multi.items():
                placeholder_hash = f"0x{prefix}..."
                manifest["transactions"][placeholder_hash] = {
                    "tx_hash": placeholder_hash,
                    "files": files,
                }
            fix(f"Generated {len(multi)} multi-tx entries from file prefixes")
        else:
            warn("No transaction hash found — set manually or use --tx-hash")

    # Contracts
    manifest["contracts"] = {}
    contract_dirs = find_contract_dirs(analysis_dir)
    for addr, d in contract_dirs.items():
        source_type, info = classify_contract(analysis_dir, addr)
        entry = {
            "name": "Unknown",
            "source_type": source_type,
            "is_proxy": False,
        }
        entry.update(info)
        manifest["contracts"][addr] = entry
        fix(f"Generated contract entry for {addr} (source_type={source_type})")

    # Also scan for top-level .tac files not associated with a directory
    for f in analysis_dir.iterdir():
        if f.is_file() and f.suffix == ".tac":
            # Try to associate with an address
            addr_match = ADDRESS_RE.search(f.stem)
            if addr_match:
                addr = addr_match.group(0).lower()
                if addr not in manifest["contracts"]:
                    manifest["contracts"][addr] = {
                        "name": "Unknown",
                        "source_type": "tac",
                        "tac_file": f.name,
                        "is_proxy": False,
                    }
                    fix(f"Generated contract entry for {addr} from {f.name}")

    manifest["report"] = {}
    return manifest


# ---------------------------------------------------------------------------
# 1b. Validate Transactions
# ---------------------------------------------------------------------------
def validate_transactions(manifest, analysis_dir):
    """Validate transaction file references exist on disk; fix missing/extra."""
    txs = manifest.get("transactions", {})
    for tx_hash, tx_info in list(txs.items()):
        files = tx_info.get("files", {})
        # Remove files that don't exist
        for key, filename in list(files.items()):
            if not (analysis_dir / filename).is_file():
                fix(f"Removed missing file reference: transactions[{tx_hash[:18]}].files.{key} = {filename}")
                del files[key]

        # Scan for canonical files NOT in manifest
        for key, canonical in CANONICAL_TX_FILES.items():
            if key not in files and (analysis_dir / canonical).is_file():
                files[key] = canonical
                fix(f"Added missing file: transactions[{tx_hash[:18]}].files.{key} = {canonical}")

        # Legacy tx_bundle
        if "tx_bundle" not in files and (analysis_dir / "tx_bundle.json").is_file():
            files["tx_bundle"] = "tx_bundle.json"
            fix(f"Added missing file: transactions[{tx_hash[:18]}].files.tx_bundle")

        # Legacy trace names
        for legacy_name, key in LEGACY_TRACE_NAMES.items():
            if key not in files and (analysis_dir / legacy_name).is_file():
                files[key] = legacy_name
                fix(f"Added legacy trace: {legacy_name} as {key}")

        # block.json
        if "block" not in files and (analysis_dir / "block.json").is_file():
            files["block"] = "block.json"
            fix(f"Added missing file: transactions[{tx_hash[:18]}].files.block")

        # Extract block_number if missing
        if "block_number" not in tx_info:
            bn = extract_block_number(analysis_dir)
            if bn is not None:
                tx_info["block_number"] = bn
                fix(f"Added block_number={bn} for {tx_hash[:18]}")

        tx_info["files"] = files


# ---------------------------------------------------------------------------
# 1c. Validate Contracts
# ---------------------------------------------------------------------------
def has_nonzero_sol(directory):
    """Check if directory (recursively) contains non-zero-byte .sol files."""
    if not directory.is_dir():
        return False
    for f in directory.rglob("*.sol"):
        if f.is_file() and f.stat().st_size > 0:
            return True
    return False


def has_any_tac(analysis_dir, addr):
    """Find .tac files associated with an address."""
    candidates = [
        analysis_dir / "contract.tac",
        analysis_dir / f"{addr}.tac",
    ]
    addr_dir = analysis_dir / addr
    if addr_dir.is_dir():
        candidates.extend(addr_dir.rglob("*.tac"))

    for c in candidates:
        if isinstance(c, Path) and c.is_file() and c.stat().st_size > 0:
            return str(c.relative_to(analysis_dir))
    return None


def classify_contract(analysis_dir, addr):
    """Classify a contract's source_type from filesystem reality.

    Returns (source_type, extra_info_dict).
    """
    addr_dir = analysis_dir / addr
    info = {}

    # Check for nested path: {addr}/{addr}/
    nested_dir = addr_dir / addr
    if nested_dir.is_dir():
        # Check if the nested dir has actual content
        nested_files = list(nested_dir.iterdir())
        if nested_files:
            # The real source is in the nested path
            effective_dir = nested_dir
        else:
            effective_dir = addr_dir
    else:
        effective_dir = addr_dir

    has_sol = has_nonzero_sol(addr_dir)  # Check entire addr dir tree
    has_abi = any(
        (effective_dir / name).is_file()
        for name in ("abi.json", "settings.json")
    )
    tac_file = has_any_tac(analysis_dir, addr)

    # Agent-native decompilation: recovered.sol + decompile_meta.json, no Etherscan abi.json
    has_decompile_meta = (effective_dir / "decompile_meta.json").is_file()
    has_recovered_sol = (effective_dir / "recovered.sol").is_file()

    if has_recovered_sol and has_decompile_meta and not has_abi:
        # This is agent-decompiled code, not Etherscan-verified
        source_type = "recovered"
        # Determine source_dir
        if addr_dir.is_dir():
            if nested_dir.is_dir() and list(nested_dir.iterdir()):
                info["source_dir"] = f"{addr}/{addr}/"
            else:
                info["source_dir"] = f"{addr}/"
        info["decompile_meta"] = True
        if tac_file:
            info["tac_file"] = tac_file
        return source_type, info

    # Determine source_dir — use nested path if it contains files
    if addr_dir.is_dir():
        if nested_dir.is_dir() and list(nested_dir.iterdir()):
            source_dir_val = f"{addr}/{addr}/"
        else:
            source_dir_val = f"{addr}/"
    else:
        source_dir_val = None

    if has_sol and (has_abi or not tac_file):
        # Has non-zero .sol + abi/settings → verified
        # Or has non-zero .sol without tac → verified
        source_type = "verified"
        if source_dir_val:
            info["source_dir"] = source_dir_val
    elif has_sol and tac_file:
        # Has non-zero .sol + .tac → recovered
        source_type = "recovered"
        if source_dir_val:
            info["source_dir"] = source_dir_val
        info["tac_file"] = tac_file
    elif tac_file:
        # Has .tac only (no .sol or 0-byte .sol) → tac
        source_type = "tac"
        info["tac_file"] = tac_file
        # Still set source_dir if the dir exists (may contain the tac inside)
        if source_dir_val:
            info["source_dir"] = source_dir_val
    else:
        # Nothing useful
        source_type = "unverified"
        if source_dir_val:
            info["source_dir"] = source_dir_val

    # Also check for top-level {addr}.sol
    top_sol = analysis_dir / f"{addr}.sol"
    if top_sol.is_file() and top_sol.stat().st_size > 0:
        if source_type in ("tac", "unverified"):
            source_type = "recovered"
            info["source_dir"] = info.get("source_dir", f"{addr}/")

    return source_type, info


def validate_contracts(manifest, analysis_dir):
    """Validate and auto-correct contract entries."""
    contracts = manifest.get("contracts", {})

    for addr, entry in list(contracts.items()):
        addr_lower = addr.lower()
        addr_dir = analysis_dir / addr_lower

        # Fix source_dir if it references a nonexistent path
        source_dir = entry.get("source_dir")
        if source_dir:
            full_path = analysis_dir / source_dir
            if not full_path.is_dir():
                # Try without the trailing nested duplicate
                fix(f"Removed invalid source_dir for {addr_lower}: {source_dir}")
                del entry["source_dir"]
                source_dir = None

        # If source_dir not set but {address}/ dir exists, auto-add
        if not source_dir and addr_dir.is_dir():
            nested = addr_dir / addr_lower
            if nested.is_dir() and list(nested.iterdir()):
                entry["source_dir"] = f"{addr_lower}/{addr_lower}/"
            else:
                entry["source_dir"] = f"{addr_lower}/"
            fix(f"Added source_dir for {addr_lower}: {entry['source_dir']}")

        # Preserve known_standard (set by Data Collector, not filesystem-detectable)
        old_type = entry.get("source_type", "unverified")
        if old_type == "known_standard":
            continue  # Data Collector explicitly classified this; don't override from filesystem

        # Auto-correct source_type
        new_type, extra_info = classify_contract(analysis_dir, addr_lower)
        if new_type != old_type:
            fix(f"Corrected source_type for {addr_lower}: {old_type} → {new_type}")
            entry["source_type"] = new_type

        # Merge extra info (tac_file, source_dir) if not already set
        for k, v in extra_info.items():
            if k not in entry or not entry[k]:
                entry[k] = v

    # Discover unlisted contract dirs
    on_disk = find_contract_dirs(analysis_dir)
    for addr, d in on_disk.items():
        if addr not in contracts:
            source_type, info = classify_contract(analysis_dir, addr)
            contracts[addr] = {
                "name": "Unknown",
                "source_type": source_type,
                "is_proxy": False,
            }
            contracts[addr].update(info)
            fix(f"Discovered unlisted contract dir: {addr} (source_type={source_type})")


# ---------------------------------------------------------------------------
# 1d. Validate Report
# ---------------------------------------------------------------------------
def validate_report(manifest, analysis_dir):
    """Validate report path exists."""
    report = manifest.get("report", {})
    report_path = report.get("path")
    incident_name = report.get("incident_name")

    # Report paths are relative to project root (parent of analysis_dir)
    project_root = analysis_dir.parent

    if report_path:
        full_path = project_root / report_path
        if full_path.is_file():
            ok(f"Report exists: {report_path}")
        else:
            warn(f"Report path not found: {report_path}")
            # Try to find it (scoped to incident_name if available)
            found = _scan_for_report(project_root, incident_name)
            if found:
                report["path"] = found
                fix(f"Updated report path to: {found}")
    else:
        found = _scan_for_report(project_root, incident_name)
        if found:
            report["path"] = found
            fix(f"Set report path: {found}")
        else:
            warn("No report path set and none found in analysis_0x*/report.md")

    manifest["report"] = report


def _scan_for_report(project_root, incident_name=None):
    """Find the report file for this analysis.

    New layout: report.md lives at analysis_0x{hash}/report.md (a fixed path).
    Falls back to scanning reports/{incident_name}/ for backwards compatibility.
    """
    # New layout: fixed path inside analysis dir
    # project_root is the analysis_0x{hash}/ dir's parent (CWD), so scan its children
    for analysis_dir in sorted(project_root.glob("analysis_0x*/"), key=lambda p: p.stat().st_mtime, reverse=True):
        candidate = analysis_dir / "report.md"
        if candidate.is_file():
            return str(candidate.relative_to(project_root))

    # Legacy fallback: reports/{incident_name}/
    if incident_name:
        legacy_dir = project_root / "reports" / incident_name
        if legacy_dir.is_dir():
            md_files = sorted(legacy_dir.rglob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
            for f in md_files:
                if not f.name.startswith("validation"):
                    return str(f.relative_to(project_root))

    return None


# ---------------------------------------------------------------------------
# 1e. Write Back
# ---------------------------------------------------------------------------
def print_summary():
    """Print summary of all fixes, warnings, and OKs."""
    print()
    if _fixes:
        print(f"  Fixes applied: {len(_fixes)}")
    if _warnings:
        print(f"  Warnings: {len(_warnings)}")
    if _oks:
        print(f"  OK checks: {len(_oks)}")

    if not _fixes and not _warnings:
        print("  Manifest is valid — no changes needed.")
    print()


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
    parser = argparse.ArgumentParser(
        description="Validate and auto-correct manifest.json for an analysis directory."
    )
    parser.add_argument("analysis_dir", type=str, help="path to the analysis directory")
    parser.add_argument("--chain", type=str, default=None, help="chain name (eth, bnb, arb, ...)")
    parser.add_argument("--tx-hash", type=str, default=None, help="transaction hash")
    parser.add_argument("--dry-run", action="store_true", help="print changes without writing")
    args = parser.parse_args()

    analysis_dir = Path(args.analysis_dir).resolve()
    if not analysis_dir.is_dir():
        print(f"Error: {analysis_dir} is not a directory", file=sys.stderr)
        sys.exit(1)

    manifest_path = analysis_dir / "manifest.json"
    print(f"Checking: {analysis_dir.name}/")

    # Load or generate
    if manifest_path.is_file():
        print(f"  Loading existing manifest.json")
        try:
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError as e:
            print(f"Error: malformed manifest.json: {e}", file=sys.stderr)
            sys.exit(1)
    else:
        print(f"  No manifest.json found — generating from filesystem")
        manifest = generate_skeleton(analysis_dir, args.chain, args.tx_hash)

    # Validate all sections
    validate_transactions(manifest, analysis_dir)
    validate_contracts(manifest, analysis_dir)
    validate_report(manifest, analysis_dir)

    # Summary
    print_summary()

    has_errors = False

    # Check 1: missing trace file when tx_hash is known
    trace_file = analysis_dir / "trace_callTracer.json"
    tx_hash_known = args.tx_hash or (manifest.get("transactions") and len(manifest["transactions"]) > 0)
    if tx_hash_known and not trace_file.is_file():
        print(f"  [ERROR] trace_callTracer.json missing — analysis cannot proceed without call trace", file=sys.stderr)
        has_errors = True

    # Check 2: zero contracts in analysis directory
    addr_dirs = [d for d in analysis_dir.iterdir() if d.is_dir() and ADDRESS_RE.fullmatch(d.name)]
    if not manifest.get("contracts") and not addr_dirs:
        print(f"  [ERROR] No contracts found in analysis directory", file=sys.stderr)
        has_errors = True

    # Check 3: tx_hash mismatch between CLI arg and manifest
    if args.tx_hash and manifest.get("transactions"):
        normalized = args.tx_hash.lower() if args.tx_hash.startswith("0x") else "0x" + args.tx_hash.lower()
        manifest_hashes = [h.lower() for h in manifest["transactions"].keys()]
        if manifest_hashes and normalized not in manifest_hashes:
            print(f"  [ERROR] tx_hash mismatch: {normalized} not in manifest transactions {manifest_hashes}", file=sys.stderr)
            has_errors = True

    # Write back
    if not args.dry_run and (_fixes or not manifest_path.is_file()):
        manifest_path.write_text(
            json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
            encoding="utf-8",
        )
        print(f"  Written: {manifest_path.relative_to(analysis_dir.parent)}")
    elif args.dry_run and _fixes:
        print("  (dry-run: no files written)")

    sys.exit(1 if has_errors else 0)


if __name__ == "__main__":
    main()
```

## scripts/decode_calldata.py

```python
#!/usr/bin/env python3
"""Walk trace_callTracer.json and decode each call's input using ABI files.

Produces:
  - analysis_dir/decoded_calls.json  -- all calls with decoded function info
  - analysis_dir/selectors.json      -- unique selector -> function name map

CLI:
    python3 decode_calldata.py --analysis-dir analysis_0x{hash}/

Exit codes:
    0 = success
    1 = critical failure
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _load_json(path):
    """Load JSON file, unwrapping RPC envelope {result: ...} if present."""
    data = json.loads(Path(path).read_text(encoding="utf-8"))
    if isinstance(data, dict) and "result" in data and len(data) <= 3:
        return data["result"]
    return data


def _cast_sig(func_sig):
    """Compute 4-byte selector via `cast sig`. Returns '0x...' or None."""
    try:
        result = subprocess.run(
            ["cast", "sig", func_sig],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            sel = result.stdout.strip()
            if sel.startswith("0x") and len(sel) == 10:
                return sel.lower()
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    return None


def _cast_4byte(selector):
    """Lookup function signature via `cast 4byte`. Returns signature string or None."""
    try:
        result = subprocess.run(
            ["cast", "4byte", selector],
            capture_output=True, text=True, timeout=10
        )
        if result.returncode == 0:
            # cast 4byte may return multiple lines; take the first
            sig = result.stdout.strip().split("\n")[0].strip()
            if sig and "(" in sig:
                return sig
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    return None


def _cast_available():
    """Check if cast CLI is available."""
    try:
        result = subprocess.run(["cast", "--version"], capture_output=True, timeout=5)
        return result.returncode == 0
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return False


def build_selector_map_from_abis(analysis_dir, use_cast):
    """
    Scan all {address}/abi.json files and build selector -> function_signature map.
    Returns dict: {'0x022c0d9f': 'swap(uint256,uint256,address,bytes)', ...}
    """
    selector_map = {}
    analysis_path = Path(analysis_dir)

    for abi_file in analysis_path.rglob("abi.json"):
        try:
            abi = json.loads(abi_file.read_text(encoding="utf-8"))
            if not isinstance(abi, list):
                continue
            for entry in abi:
                if not isinstance(entry, dict):
                    continue
                if entry.get("type") != "function":
                    continue
                name = entry.get("name", "")
                if not name:
                    continue
                # Build canonical signature
                inputs = entry.get("inputs", [])
                types = []
                for inp in inputs:
                    t = inp.get("type", "")
                    if t == "tuple" or t.startswith("tuple"):
                        # Build tuple type from components
                        components = inp.get("components", [])
                        inner = ",".join(c.get("type", "") for c in components)
                        t = f"({inner})" + t[len("tuple"):]
                    types.append(t)
                sig = f"{name}({','.join(types)})"

                if use_cast:
                    sel = _cast_sig(sig)
                    if sel:
                        selector_map[sel] = sig
                        print(f"  [abi] {sel} = {sig}", file=sys.stderr)
        except Exception as e:
            print(f"  [warn] Failed to parse {abi_file}: {e}", file=sys.stderr)

    return selector_map


def walk_trace(node, selector_map, calls_list, depth=0, use_cast=False):
    """Recursively walk the call trace tree, building the decoded calls list."""
    if not isinstance(node, dict):
        return

    call_type = node.get("type", "CALL")
    from_addr = (node.get("from") or "").lower()
    to_addr = (node.get("to") or "").lower()
    input_hex = node.get("input") or "0x"
    output_hex = node.get("output") or "0x"
    value_hex = node.get("value") or "0x0"

    # Extract selector (first 4 bytes)
    selector = None
    if input_hex and input_hex != "0x" and len(input_hex) >= 10:
        selector = input_hex[:10].lower()

    # Resolve function name
    function_signature = None
    resolved_from = "unresolved"
    if selector and selector in selector_map:
        function_signature = selector_map[selector]
        resolved_from = "abi"
    elif selector and use_cast:
        # Fallback: lookup via cast 4byte directory
        sig = _cast_4byte(selector)
        if sig:
            function_signature = sig
            resolved_from = "4byte"
            selector_map[selector] = sig  # cache for future calls

    # Compute lengths
    input_bytes = (len(input_hex) - 2) // 2 if input_hex.startswith("0x") else len(input_hex) // 2
    output_bytes = (len(output_hex) - 2) // 2 if output_hex.startswith("0x") else len(output_hex) // 2

    calls_list.append({
        "index": len(calls_list),
        "depth": depth,
        "from": from_addr,
        "to": to_addr,
        "call_type": call_type,
        "selector": selector,
        "function_name": function_signature.split("(")[0] if function_signature else None,
        "function_signature": function_signature,
        "input_hex": input_hex if len(input_hex) <= 256 else input_hex[:256] + "...",
        "input_length_bytes": input_bytes,
        "value": value_hex,
        "output_length_bytes": output_bytes,
        "resolved_from": resolved_from,
    })

    for child in node.get("calls", []):
        walk_trace(child, selector_map, calls_list, depth + 1, use_cast=use_cast)


def build_selectors_summary(calls_list):
    """Build unique selector -> resolution map with seen counts."""
    selectors = {}
    for call in calls_list:
        sel = call.get("selector")
        if not sel:
            continue
        if sel not in selectors:
            selectors[sel] = {
                "function_signature": call.get("function_signature"),
                "resolved_from": call.get("resolved_from", "unresolved"),
                "seen_count": 0,
            }
        selectors[sel]["seen_count"] += 1
        # Upgrade resolution if we got a better one
        if call.get("function_signature") and selectors[sel]["function_signature"] is None:
            selectors[sel]["function_signature"] = call["function_signature"]
            selectors[sel]["resolved_from"] = call["resolved_from"]
    return selectors


def main():
    parser = argparse.ArgumentParser(description="Decode call trace using ABI files")
    parser.add_argument("--analysis-dir", required=True, help="Path to analysis_0x{hash}/ directory")
    args = parser.parse_args()

    analysis_dir = Path(args.analysis_dir)
    trace_path = analysis_dir / "trace_callTracer.json"

    if not trace_path.is_file():
        print(f"[ERROR] trace_callTracer.json not found in {analysis_dir}", file=sys.stderr)
        sys.exit(1)

    # Check cast availability
    use_cast = _cast_available()
    if use_cast:
        print("[decode_calldata] cast CLI available — using for selector computation", file=sys.stderr)
    else:
        print("[decode_calldata] cast CLI not available — ABI resolution disabled", file=sys.stderr)

    # Build selector map from ABI files
    print("[decode_calldata] Building selector map from ABI files ...", file=sys.stderr)
    selector_map = build_selector_map_from_abis(analysis_dir, use_cast)
    print(f"[decode_calldata]   {len(selector_map)} selectors resolved from ABIs", file=sys.stderr)

    # Integrate selectors_raw.json from planner (adds selectors not found in ABIs)
    selectors_raw_path = analysis_dir / "selectors_raw.json"
    if selectors_raw_path.is_file():
        try:
            raw = json.loads(selectors_raw_path.read_text(encoding="utf-8"))
            raw_selectors = raw.get("selectors", {})
            merged = 0
            for sel, sig in raw_selectors.items():
                sel_lower = sel.lower()
                if sel_lower not in selector_map and sig and isinstance(sig, str) and "(" in sig:
                    selector_map[sel_lower] = sig
                    merged += 1
            print(f"[decode_calldata]   {len(raw_selectors)} selectors in planner selectors_raw.json, {merged} new merged", file=sys.stderr)
        except Exception as e:
            print(f"  [warn] Failed to load selectors_raw.json: {e}", file=sys.stderr)

    # Walk trace
    print("[decode_calldata] Walking call trace ...", file=sys.stderr)
    trace = _load_json(trace_path)
    calls_list = []
    walk_trace(trace, selector_map, calls_list, use_cast=use_cast)
    print(f"[decode_calldata]   {len(calls_list)} calls extracted", file=sys.stderr)

    resolved = sum(1 for c in calls_list if c["resolved_from"] == "abi")
    print(f"[decode_calldata]   {resolved}/{len(calls_list)} calls resolved to function names", file=sys.stderr)

    # Build selectors summary
    selectors_summary = build_selectors_summary(calls_list)

    # Write outputs
    decoded_path = analysis_dir / "decoded_calls.json"
    decoded_path.write_text(json.dumps(calls_list, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print(f"[decode_calldata] Written: {decoded_path}", file=sys.stderr)

    selectors_path = analysis_dir / "selectors.json"
    selectors_path.write_text(json.dumps(selectors_summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print(f"[decode_calldata] Written: {selectors_path}", file=sys.stderr)


if __name__ == "__main__":
    main()
```

## scripts/fetch_sourcecode.py

```python
import argparse
import json
import os
import time
import logging
from pathlib import Path

try:
    from dotenv import load_dotenv
    load_dotenv(Path(__file__).parent.parent / ".env")
except ImportError:
    pass

import requests as rq


def make_dir(path):
    os.makedirs(path, exist_ok=True)


def is_json(myjson):
    try:
        json_object = json.loads(myjson)
    except ValueError:
        return False
    return True


def crawl_contract(rootdir, c_address, chainid=1):
    root = rootdir
    # Ensure root ends with separator if not empty
    if root and not root.endswith(os.path.sep):
        root += os.path.sep
        
    contract_address = c_address
    api_key = os.environ.get("ETHERSCAN_API_KEY", "")
    params = {"module": "contract", "action": "getsourcecode", "address": c_address}
    params["chainid"] = chainid
    if api_key:
        params["apikey"] = api_key
    else:
        logging.warning("ETHERSCAN_API_KEY not set; proceeding without API key")

    MAX_RETRIES = 3
    for attempt in range(MAX_RETRIES):
        output = rq.get("https://api.etherscan.io/v2/api", params=params, timeout=30)
        if output.status_code == 429:
            wait = 2 ** (attempt + 1)  # 2, 4, 8 seconds
            logging.warning(f"Rate limited (429). Waiting {wait}s before retry {attempt + 1}/{MAX_RETRIES}")
            time.sleep(wait)
            continue
        break
    else:
        logging.error("Max retries exceeded due to rate limiting")
        return False

    json_res = output.json()
    if "result" in json_res:
        result = json_res["result"][0]
        source_code = result["SourceCode"]
        abi = result.get("ABI")

        # Unverified contracts return empty source or the sentinel string
        if not source_code or source_code == "Contract source code not verified":
            import sys
            print(f"[warn] No verified source for {contract_address}", file=sys.stderr)
            return False

        make_dir(root + contract_address)

        # save contract metadata info
        info_keys = [
            "ContractName", "CompilerVersion", "CompilerType", "OptimizationUsed",
            "Runs", "ConstructorArguments", "EVMVersion", "Library",
            "ContractFileName", "LicenseType", "Proxy", "Implementation",
            "SwarmSource", "SimilarMatch"
        ]
        contract_info = {key: result.get(key, "") for key in info_keys if key in result}
        if contract_info:
            info_path = root + contract_address + "/info.json"
            with open(info_path, "w", encoding="UTF-8") as info_file:
                json.dump(contract_info, info_file, indent=2, ensure_ascii=False)
            logging.info(f"Contract info saved to {info_path}")

        # save ABI
        if abi and abi != "Contract source code not verified":
            abi_path = root + contract_address + "/abi.json"
            with open(abi_path, "w", encoding="UTF-8") as abi_file:
                if is_json(abi):
                    json.dump(json.loads(abi), abi_file, indent=2, ensure_ascii=False)
                else:
                    abi_file.write(abi)
            logging.info(f"ABI saved to {abi_path}")
        
        if is_json(source_code):
            res = json.loads(source_code)
            for key in res:
                logging.debug(key)
                # Handle path creation for files in subdirectories
                file_path = root + contract_address + "/" + key
                _dir = os.path.dirname(file_path)
                make_dir(_dir)
                
                with open(file_path, "w", encoding="UTF-8") as sol_file:
                    sol_file.write(res[key]["content"])
        elif source_code and source_code[0] == source_code[1] == "{":
            new_code = source_code[1:-1]
            res = json.loads(new_code)
            
            # save settings.json
            if "settings" in res:
                settings_path = root + contract_address + "/settings.json"
                with open(settings_path, "w", encoding="UTF-8") as settings_file:
                    json.dump(res["settings"], settings_file, indent=2, ensure_ascii=False)
                logging.info(f"Settings saved to {settings_path}")
            
            # save source code files
            sources = res.get("sources", {})
            for name in sources:
                logging.debug(name)
                _dir, _file = os.path.split(root + contract_address + "/" + name)
                logging.debug(_dir)
                make_dir(_dir)
                with open(root + contract_address + "/" + name, "w", encoding="UTF-8") as sol_file:
                    sol_file.write(sources[name]["content"])
        else:
            with open(
                root + contract_address + "/" + contract_address + ".sol",
                "w",
                encoding="UTF-8",
            ) as sol_file:
                sol_file.write(source_code)

        return True


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--dir", type=str, help="output crawled file path, end with '/'"
    )
    parser.add_argument("--caddress", type=str, help="contract address")
    parser.add_argument(
        "--chainid", type=int, default=1,
        help="chain id (1=Ethereum, 56=BSC, 137=Polygon, 42161=Arbitrum, more refer to https://docs.etherscan.io/supported-chains.)"
    )
    args = parser.parse_args()
    import sys
    result = crawl_contract(args.dir, args.caddress, args.chainid)
    if result is False:
        sys.exit(1)
```

## scripts/fetch_tac.py

```python
#!/usr/bin/env python3
"""fetch_tac.py — fetch Gigahorse TAC (optional) and extract trace context.

TAC server communication is optional — the Decompiler Subagent handles
bytecode recovery directly. This script is retained for:
- TAC server communication (when Gigahorse is available locally)
- Bytecode file handling (for CREATE/self-destructed contracts)
- Trace context extraction utility
"""
import argparse
import hashlib
import json
import os
import shutil
import sys
import urllib.request
from pathlib import Path

# Load .env from the same directory as this script (if present)
try:
    from dotenv import load_dotenv
    load_dotenv(Path(__file__).parent.parent / ".env")
except ImportError:
    pass

DEFAULT_SERVER_URL = "http://127.0.0.1:8787/analyze"
ALCHEMY_RPC_TEMPLATE = "https://{chain}-mainnet.g.alchemy.com/v2/" + os.environ.get("ALCHEMY_API_KEY", "")
CHAIN_SLUGS = {"eth": "eth", "opt": "opt", "polygon": "polygon",
               "arb": "arb", "base": "base", "bnb": "bnb"}


# ─────────────────────────────────────────────────────────────────────────────
# RPC / TAC server helpers
# ─────────────────────────────────────────────────────────────────────────────

def normalize_hex(value, kind):
    data = value.strip()
    if not data:
        raise ValueError(f"{kind} is empty")
    if not data.startswith("0x"):
        data = f"0x{data}"
    return data


def fetch_bytecode(address, rpc_url, timeout):
    payload = {"jsonrpc": "2.0", "id": 1, "method": "eth_getCode",
               "params": [address, "latest"]}
    req = urllib.request.Request(
        rpc_url, data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = json.loads(resp.read().decode("utf-8", errors="ignore"))
    if "error" in data:
        raise RuntimeError(f"RPC error: {data['error']}")
    result = data.get("result")
    if not result or result in {"0x", "0x0"}:
        raise RuntimeError("RPC returned empty bytecode")
    return result


def post_for_tac(server_url, bytecode, address, timeout):
    # Server requires both bytecode and address; derive a synthetic address from
    # bytecode hash when none is provided so the server can use it as a cache key.
    if not address:
        address = "0x" + hashlib.sha256(bytecode.encode()).hexdigest()[:40]
    payload = {"bytecode": bytecode, "address": address}
    req = urllib.request.Request(
        server_url, data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        body = resp.read().decode("utf-8", errors="ignore").strip()
    if not body:
        raise RuntimeError("TAC server returned empty response")
    try:
        data = json.loads(body)
    except json.JSONDecodeError:
        return body
    for key in ("tac_path", "path", "file", "result"):
        v = data.get(key)
        if isinstance(v, str) and v.strip():
            return v.strip()
    raise RuntimeError(f"Unexpected TAC server response: {data}")


def resolve_tac_path(tac_path, gigahorse_root):
    p = Path(tac_path)
    return p if p.is_absolute() else Path(gigahorse_root) / p


def copy_tac_file(src, dst):
    dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(src, dst)


# ─────────────────────────────────────────────────────────────────────────────
# Trace context extraction
# ─────────────────────────────────────────────────────────────────────────────

def extract_trace_context(trace_file: str, target_address: str) -> str:
    """Concise text summary of calls TO/FROM target in the trace."""
    target = target_address.lower()
    try:
        data = json.loads(Path(trace_file).read_text(encoding="utf-8", errors="ignore"))
    except (json.JSONDecodeError, OSError) as e:
        return f"(trace parse error: {e})"
    if "result" in data and isinstance(data["result"], dict):
        data = data["result"]

    calls_to, calls_from = [], []

    def walk(node):
        if not isinstance(node, dict):
            return
        fr = (node.get("from") or "").lower()
        to = (node.get("to") or "").lower()
        inp = node.get("input") or ""
        sel = inp[:10] if len(inp) >= 10 else inp
        if to == target:
            calls_to.append({"from": fr, "type": node.get("type","CALL"),
                              "selector": sel, "value": node.get("value","0x0")})
        if fr == target:
            calls_from.append({"to": to, "type": node.get("type","CALL"),
                                "selector": sel, "value": node.get("value","0x0")})
        for child in node.get("calls", []):
            walk(child)

    walk(data)
    lines = [f"Trace context for {target}:", ""]
    if calls_to:
        lines.append(f"Calls TO this contract ({len(calls_to)}):")
        for c in calls_to[:20]:
            lines.append(f"  {c['type']} from={c['from'][:18]}... selector={c['selector']}")
    else:
        lines.append("No calls TO this contract found in trace.")
    lines.append("")
    if calls_from:
        lines.append(f"Calls FROM this contract ({len(calls_from)}):")
        for c in calls_from[:20]:
            lines.append(f"  {c['type']} to={c['to'][:18]}... selector={c['selector']}")
    else:
        lines.append("No calls FROM this contract found in trace.")
    return "\n".join(lines)


# ─────────────────────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────────────────────

def main():
    p = argparse.ArgumentParser(
        description="Fetch Gigahorse TAC and extract trace context.")
    p.add_argument("--address", help="contract address")
    p.add_argument("--bytecode", help="contract bytecode (0x...)")
    p.add_argument("--bytecode-file", help="file containing contract bytecode")
    p.add_argument("--rpc", help="RPC URL for eth_getCode")
    p.add_argument("--chain", choices=sorted(CHAIN_SLUGS),
                   help="Alchemy chain slug (used when --rpc is not set)")
    p.add_argument("--dir", help="output directory for the TAC file")
    p.add_argument("--out", help="explicit output file path for the TAC file")
    p.add_argument("--tac-file",
                   help="existing TAC file — skips bytecode fetch and TAC server")
    p.add_argument("--server-url", default=DEFAULT_SERVER_URL)
    p.add_argument("--gigahorse-root", default="~/gigahorse-toolchain/")
    p.add_argument("--timeout", type=int, default=30,
                   help="HTTP timeout in seconds for RPC/TAC server calls")
    p.add_argument("--trace-file",
                   help="callTracer JSON for trace context extraction")
    p.add_argument("--trace-context", action="store_true",
                   help="print trace context summary for given address and exit")
    p.add_argument("--tac-server", action="store_true",
                   help="enable Gigahorse TAC server mode (requires local server)")

    args = p.parse_args()

    # ── Trace context utility mode ──────────────────────────────────────────
    if args.trace_context:
        if not args.trace_file or not args.address:
            p.error("--trace-context requires --trace-file and --address")
        print(extract_trace_context(args.trace_file, args.address))
        return

    # ── Past this point: TAC server mode only ───────────────────────────────
    if not args.tac_server:
        p.error("Specify --tac-server to fetch TAC, or use --trace-context for trace utilities")

    # ── Resolve the TAC file ────────────────────────────────────────────────
    if args.tac_file:
        tac_source = Path(args.tac_file)
        if not tac_source.exists():
            raise FileNotFoundError(f"TAC file not found: {tac_source}")
        if args.out:
            out_path = Path(args.out)
        elif args.dir:
            out_path = Path(args.dir) / tac_source.name
        else:
            out_path = tac_source
        if out_path != tac_source:
            copy_tac_file(tac_source, out_path)
            print(f"saved {out_path}")
        else:
            print(f"using {out_path}")
    else:
        if not (args.bytecode or args.bytecode_file or args.address):
            p.error("Provide --bytecode, --bytecode-file, --address, or --tac-file")

        address = args.address.lower() if args.address else None
        if args.bytecode_file:
            bytecode = Path(args.bytecode_file).read_text().strip()
        elif args.bytecode:
            bytecode = args.bytecode
        else:
            rpc = args.rpc or ALCHEMY_RPC_TEMPLATE.format(
                chain=CHAIN_SLUGS[args.chain or "eth"])
            bytecode = fetch_bytecode(address, rpc, args.timeout)

        bytecode = normalize_hex(bytecode, "bytecode")
        tac_path_str = post_for_tac(args.server_url, bytecode, address, args.timeout)
        resolved = resolve_tac_path(tac_path_str,
                                    Path(args.gigahorse_root).expanduser())
        if not resolved.exists():
            raise FileNotFoundError(f"TAC file not found: {resolved}")

        if args.out:
            out_path = Path(args.out)
        elif args.dir:
            out_path = Path(args.dir) / resolved.name
        else:
            out_path = Path.cwd() / resolved.name

        copy_tac_file(resolved, out_path)
        print(f"saved {out_path}")


if __name__ == "__main__":
    main()
```

## scripts/funds_flow.py

```python
#!/usr/bin/env python3
"""Parse receipt.json and trace_callTracer.json to produce funds_flow.json.

Decodes ERC-20 Transfer/Approval events and ETH transfers from the call trace.
Produces net balance changes per address per token and an attacker profit summary.

CLI:
    python3 funds_flow.py --analysis-dir analysis_0x{hash}/ [--rpc-url https://...]

Exit codes:
    0 = success
    1 = critical failure (missing required files)
"""
import argparse
import json
import sys
import os
from pathlib import Path

# ---------------------------------------------------------------------------
# Known event topic signatures
# ---------------------------------------------------------------------------
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
APPROVAL_TOPIC = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"

# Token info cache: address -> {symbol, decimals}
_token_cache = {}


def _load_json(path):
    """Load JSON file, unwrapping RPC envelope if present."""
    data = json.loads(Path(path).read_text(encoding="utf-8"))
    if isinstance(data, dict) and "result" in data:
        return data["result"]
    return data


def _hex_to_int(h):
    if not h or h == "0x":
        return 0
    return int(h, 16)


def _pad_address(topic32):
    """Extract 20-byte address from a 32-byte padded topic hex string."""
    h = topic32.lstrip("0x")
    return "0x" + h[-40:].lower()


def _fetch_token_info(token_address, rpc_url, block_tag="latest"):
    """Fetch symbol and decimals via eth_call. Returns (symbol, decimals).

    Args:
        block_tag: block number hex string (e.g. "0x1234") or "latest".
                   Using the exploit's block ensures correct info for upgraded tokens.
    """
    if token_address in _token_cache:
        return _token_cache[token_address]

    if not rpc_url:
        _token_cache[token_address] = ("UNKNOWN", 18)
        return ("UNKNOWN", 18)

    try:
        import urllib.request
        import urllib.error

        def eth_call(data_hex):
            payload = json.dumps({
                "jsonrpc": "2.0",
                "method": "eth_call",
                "params": [{"to": token_address, "data": data_hex}, block_tag],
                "id": 1,
            }).encode()
            req = urllib.request.Request(rpc_url, data=payload,
                                         headers={"Content-Type": "application/json"})
            with urllib.request.urlopen(req, timeout=10) as resp:
                return json.loads(resp.read())["result"]

        # symbol() = 0x95d89b41
        sym_result = eth_call("0x95d89b41")
        symbol = "UNKNOWN"
        if sym_result and sym_result != "0x":
            raw = bytes.fromhex(sym_result.lstrip("0x"))
            # ABI-encoded string: offset (32 bytes) + length (32 bytes) + data
            if len(raw) >= 96:
                str_len = int.from_bytes(raw[32:64], "big")
                symbol = raw[64:64 + str_len].decode("utf-8", errors="replace").strip("\x00")
            elif len(raw) >= 32:
                # Some tokens return a bytes32
                symbol = raw.rstrip(b"\x00").decode("utf-8", errors="replace").strip()

        # decimals() = 0x313ce567
        dec_result = eth_call("0x313ce567")
        decimals = 18
        if dec_result and dec_result != "0x":
            decimals = int(dec_result, 16)

        _token_cache[token_address] = (symbol, decimals)
        print(f"  [token] {token_address}: {symbol} ({decimals} decimals)", file=sys.stderr)
        return (symbol, decimals)

    except Exception as e:
        print(f"  [warn] Could not fetch token info for {token_address}: {e}", file=sys.stderr)
        _token_cache[token_address] = ("UNKNOWN", 18)
        return ("UNKNOWN", 18)


def _human_amount(raw_int, decimals):
    """Convert raw integer amount to human-readable string."""
    if decimals == 0:
        return str(raw_int)
    divisor = 10 ** decimals
    whole = raw_int // divisor
    frac = raw_int % divisor
    frac_str = str(frac).zfill(decimals).rstrip("0")
    if frac_str:
        return f"{whole}.{frac_str}"
    return str(whole)


def parse_transfers(receipt, rpc_url, block_tag="latest"):
    """Parse ERC-20 Transfer events from receipt logs."""
    transfers = []
    approvals = []
    logs = receipt if isinstance(receipt, list) else receipt.get("logs", [])

    for log in logs:
        topics = log.get("topics", [])
        if not topics:
            continue
        topic0 = topics[0].lower()
        token_address = log.get("address", "").lower()
        log_index = _hex_to_int(log.get("logIndex", "0x0"))
        data = log.get("data", "0x")

        if topic0 == TRANSFER_TOPIC and len(topics) >= 3:
            from_addr = _pad_address(topics[1])
            to_addr = _pad_address(topics[2])
            raw_amount = _hex_to_int(data) if data and data != "0x" else 0
            symbol, decimals = _fetch_token_info(token_address, rpc_url, block_tag)
            transfers.append({
                "index": len(transfers),
                "token": token_address,
                "token_symbol": symbol,
                "from": from_addr,
                "to": to_addr,
                "raw_amount": str(raw_amount),
                "human_amount": _human_amount(raw_amount, decimals),
                "decimals": decimals,
                "log_index": log_index,
            })

        elif topic0 == APPROVAL_TOPIC and len(topics) >= 3:
            owner = _pad_address(topics[1])
            spender = _pad_address(topics[2])
            raw_amount = _hex_to_int(data) if data and data != "0x" else 0
            approvals.append({
                "index": len(approvals),
                "token": token_address,
                "owner": owner,
                "spender": spender,
                "raw_amount": str(raw_amount),
                "log_index": log_index,
            })

    return transfers, approvals


def parse_eth_transfers(trace):
    """Walk call trace recursively and collect non-zero ETH value transfers."""
    eth_transfers = []

    def walk(node, depth=0):
        if not isinstance(node, dict):
            return
        call_type = node.get("type", "CALL")
        # Only CALL and CREATE can carry ETH value
        if call_type not in ("DELEGATECALL", "STATICCALL"):
            value_hex = node.get("value", "0x0") or "0x0"
            value_wei = _hex_to_int(value_hex)
            if value_wei > 0:
                from_addr = (node.get("from") or "").lower()
                to_addr = (node.get("to") or "").lower()
                value_eth = value_wei / 10 ** 18
                eth_transfers.append({
                    "from": from_addr,
                    "to": to_addr,
                    "value_wei": str(value_wei),
                    "value_eth": f"{value_eth:.18f}".rstrip("0").rstrip("."),
                    "depth": depth,
                })
        for child in node.get("calls", []):
            walk(child, depth + 1)

    walk(trace)
    return eth_transfers


def compute_net_changes(transfers, rpc_url, block_tag="latest"):
    """Compute net token balance changes per address per token."""
    net = {}  # net[address][token] = signed int

    for t in transfers:
        token = t["token"]
        raw = int(t["raw_amount"])
        _, decimals = _fetch_token_info(token, rpc_url, block_tag)
        symbol = t["token_symbol"]

        from_addr = t["from"]
        to_addr = t["to"]

        # Debit sender
        net.setdefault(from_addr, {}).setdefault(token, {"raw": 0, "symbol": symbol, "decimals": decimals})
        net[from_addr][token]["raw"] -= raw

        # Credit recipient
        net.setdefault(to_addr, {}).setdefault(token, {"raw": 0, "symbol": symbol, "decimals": decimals})
        net[to_addr][token]["raw"] += raw

    # Convert to output format
    result = {}
    for addr, tokens in net.items():
        result[addr] = {}
        for token, info in tokens.items():
            raw = info["raw"]
            dec = info["decimals"]
            sym = info["symbol"]
            result[addr][token] = {
                "raw": str(raw),
                "human": _human_amount(abs(raw), dec) if raw >= 0 else "-" + _human_amount(abs(raw), dec),
                "symbol": sym,
                "decimals": dec,
            }
    return result


def main():
    parser = argparse.ArgumentParser(description="Parse token flows from blockchain attack tx data")
    parser.add_argument("--analysis-dir", required=True, help="Path to analysis_0x{hash}/ directory")
    parser.add_argument("--rpc-url", default=None, help="RPC URL for token symbol/decimal lookups (optional)")
    args = parser.parse_args()

    analysis_dir = Path(args.analysis_dir)
    rpc_url = args.rpc_url

    # --- Load required files ---
    receipt_path = analysis_dir / "receipt.json"
    trace_path = analysis_dir / "trace_callTracer.json"

    if not receipt_path.is_file():
        print(f"[ERROR] receipt.json not found in {analysis_dir}", file=sys.stderr)
        sys.exit(1)
    if not trace_path.is_file():
        print(f"[ERROR] trace_callTracer.json not found in {analysis_dir}", file=sys.stderr)
        sys.exit(1)

    print(f"[funds_flow] Loading receipt.json ...", file=sys.stderr)
    receipt = _load_json(receipt_path)
    print(f"[funds_flow] Loading trace_callTracer.json ...", file=sys.stderr)
    trace = _load_json(trace_path)

    # --- Determine attacker address and block tag ---
    attacker_address = None
    block_tag = "latest"
    tx_path = analysis_dir / "tx.json"
    if tx_path.is_file():
        try:
            tx = _load_json(tx_path)
            attacker_address = (tx.get("from") or "").lower()
            # Use the exploit's block number for token info lookups
            bn = tx.get("blockNumber")
            if bn and bn != "0x":
                block_tag = bn  # hex string like "0x173fb99"
        except Exception:
            pass
    if not attacker_address and isinstance(trace, dict):
        attacker_address = (trace.get("from") or "").lower()

    print(f"[funds_flow] Attacker address: {attacker_address}", file=sys.stderr)
    print(f"[funds_flow] Block tag for RPC lookups: {block_tag}", file=sys.stderr)

    # --- Parse events ---
    print(f"[funds_flow] Parsing Transfer/Approval events from receipt ...", file=sys.stderr)
    transfers, approvals = parse_transfers(receipt, rpc_url, block_tag)
    print(f"[funds_flow]   {len(transfers)} Transfer events, {len(approvals)} Approval events", file=sys.stderr)

    print(f"[funds_flow] Parsing ETH transfers from call trace ...", file=sys.stderr)
    eth_transfers = parse_eth_transfers(trace)
    print(f"[funds_flow]   {len(eth_transfers)} ETH value transfers", file=sys.stderr)

    # --- Net changes ---
    print(f"[funds_flow] Computing net balance changes ...", file=sys.stderr)
    net_changes = compute_net_changes(transfers, rpc_url, block_tag)

    # --- Attacker gains ---
    attacker_gains = []
    if attacker_address and attacker_address in net_changes:
        for token, info in net_changes[attacker_address].items():
            raw = int(info["raw"])
            if raw > 0:
                attacker_gains.append({
                    "token": token,
                    "symbol": info["symbol"],
                    "raw_amount": info["raw"],
                    "human_amount": info["human"],
                    "decimals": info["decimals"],
                })

    # --- ETH net change for attacker ---
    attacker_eth_in = sum(
        int(e["value_wei"]) for e in eth_transfers if e["to"] == attacker_address
    )
    attacker_eth_out = sum(
        int(e["value_wei"]) for e in eth_transfers if e["from"] == attacker_address
    )
    attacker_eth_net = attacker_eth_in - attacker_eth_out

    # Add ETH gains to attacker_gains (if positive)
    if attacker_eth_net > 0:
        attacker_gains.append({
            "token": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
            "symbol": "ETH",
            "raw_amount": str(attacker_eth_net),
            "human_amount": _human_amount(attacker_eth_net, 18),
            "decimals": 18,
        })

    # --- Summary string ---
    gain_parts = [f"{g['human_amount']} {g['symbol']}" for g in attacker_gains]
    if attacker_eth_net < 0:
        eth_human = attacker_eth_net / 10 ** 18
        gain_parts.append(f"{eth_human:.6f} ETH")
    summary = f"Attacker gained: {', '.join(gain_parts)}" if gain_parts else "No attacker gains detected"

    # --- Output ---
    output = {
        "attacker_address": attacker_address,
        "transfers": transfers,
        "approvals": approvals,
        "eth_transfers": eth_transfers,
        "net_changes": net_changes,
        "attacker_gains": attacker_gains,
        "summary": summary,
    }

    out_path = analysis_dir / "funds_flow.json"
    out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print(f"[funds_flow] Written: {out_path}", file=sys.stderr)
    print(str(out_path))
    print(f"[funds_flow] Summary: {summary}", file=sys.stderr)


if __name__ == "__main__":
    main()
```

## scripts/tac_server.py

```python
import argparse
import json
import logging
import os
import re
import shutil
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict

import global_params

log = logging.getLogger(__name__)

REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
DOCKER_WORKDIR = "/opt/gigahorse/gigahorse-toolchain"


def ensure_dirs() -> None:
    os.makedirs(os.path.join(REPO_ROOT, global_params.CONTRACT_PATH), exist_ok=True)
    os.makedirs(os.path.join(REPO_ROOT, ".temp"), exist_ok=True)


def sanitize_hex(s: str) -> str:
    s = s.strip()
    if s.startswith(("0x", "0X")):
        s = s[2:]
    if not s:
        raise ValueError("bytecode is empty")
    if len(s) % 2 != 0:
        raise ValueError("bytecode hex length must be even")
    if not HEX_RE.match(s):
        raise ValueError("bytecode contains non-hex characters")
    return s.lower()


def normalize_addr(addr: str) -> str:
    a = addr.strip()
    if a.startswith(("0x", "0X")):
        a = a[2:]
    if not a:
        raise ValueError("address is empty")
    if len(a) > 40:
        raise ValueError("address length exceeds 40 hex chars")
    if not HEX_RE.match(a):
        raise ValueError("address contains non-hex characters")
    return "0x" + a.lower().rjust(40, "0")


def write_hex(bytecode: str, addr: str) -> str:
    ensure_dirs()
    addr_n = normalize_addr(addr)
    path = os.path.join(REPO_ROOT, global_params.CONTRACT_PATH, addr_n + ".hex")
    with open(path, "w") as f:
        f.write(sanitize_hex(bytecode))
    return addr_n


def run_gigahorse(addr: str) -> int:
    contract_path = os.path.join(global_params.CONTRACT_PATH, addr + ".hex")
    cmd = ["./gigahorse.py", "-C", "./clients/visualizeout.py", contract_path]
    log.info("gigahorse run command: %s", " ".join(cmd))
    res = subprocess.run(cmd, cwd=REPO_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    return res.returncode


def tac_rel_path(addr: str) -> str:
    return os.path.join(".temp", addr, "out", "contract.tac")


def tac_abs_path(addr: str) -> str:
    return os.path.join(REPO_ROOT, tac_rel_path(addr))


def out_dir(addr: str) -> str:
    return os.path.join(REPO_ROOT, ".temp", addr, "out")


def maybe_clean_stale_output(addr: str) -> None:
    out_path = tac_abs_path(addr)
    work_dir = os.path.join(REPO_ROOT, ".temp", addr)
    if os.path.isdir(work_dir) and not os.path.exists(out_path):
        shutil.rmtree(work_dir, ignore_errors=True)


def analyze_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
    bytecode = payload.get("bytecode", "")
    address = payload.get("address", "")

    if not bytecode or not address:
        return {"ok": False, "error": "missing bytecode or address"}

    try:
        addr_n = write_hex(bytecode, address)
    except ValueError as e:
        return {"ok": False, "error": str(e)}

    maybe_clean_stale_output(addr_n)
    rc = run_gigahorse(addr_n)
    if rc != 0:
        return {"ok": False, "error": f"gigahorse exit code {rc}", "address": addr_n}

    out_path = tac_abs_path(addr_n)
    if not os.path.exists(out_path):
        err_path = os.path.join(out_dir(addr_n), "visualizeout.py.err")
        err_msg = ""
        if os.path.exists(err_path) and os.path.getsize(err_path) > 0:
            with open(err_path, "r") as f:
                err_msg = f.read().strip()
        return {
            "ok": False,
            "error": "contract.tac not generated",
            "address": addr_n,
            "client_error": err_msg or None,
        }

    return {"ok": True, "address": addr_n, "tac_path": tac_rel_path(addr_n)}


class TacHandler(BaseHTTPRequestHandler):
    def _set_headers(self, status: int = 200) -> None:
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.end_headers()

    def do_GET(self) -> None:
        if self.path == "/health":
            self._set_headers(200)
            self.wfile.write(json.dumps({"ok": True}).encode())
        else:
            self._set_headers(404)
            self.wfile.write(json.dumps({"ok": False, "error": "not found"}).encode())

    def do_POST(self) -> None:
        if self.path != "/analyze":
            self._set_headers(404)
            self.wfile.write(json.dumps({"ok": False, "error": "not found"}).encode())
            return
        try:
            length = int(self.headers.get("Content-Length", "0"))
            raw = self.rfile.read(length) if length > 0 else b""
            payload = json.loads(raw.decode() or "{}")
        except Exception as e:
            self._set_headers(400)
            self.wfile.write(json.dumps({"ok": False, "error": f"bad json: {e}"}).encode())
            return

        res = analyze_payload(payload)
        status = 200 if res.get("ok") else 500
        if not res.get("ok") and res.get("error", "").startswith("missing"):
            status = 400
        self._set_headers(status)
        self.wfile.write(json.dumps(res).encode())

    def log_message(self, format, *args):
        pass  # silence request logs


def serve(port: int) -> None:
    server = ThreadingHTTPServer(("0.0.0.0", port), TacHandler)
    print(f"TAC service listening on 0.0.0.0:{port}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Gigahorse TAC generation service")
    parser.add_argument("--port", type=int, default=8787)
    args = parser.parse_args()
    serve(args.port)
```

