� 🛡️ Tutorial: Build an AI-Powered Smart Contract Auditor (RAG + Local LLM)
Project: SoliditySentinel — A CLI tool that ingests Solidity code, indexes it into a Vector Database, and uses a Local LLM (via Ollama) + RAG (Retrieval-Augmented Generation) to detect vulnerabilities, gas optimizations, and logic errors.
Why this project?
- Crypto: Real-world Solidity parsing, Slither integration, Hardhat/Foundry compatibility.
- AI: RAG pipeline, Embeddings, Prompt Engineering, Local LLMs (Privacy/No API keys).
- Engineering: CLI design, Docker containerization, CI/CD integration.
🏗️ System Architecture
graph LR
A[Solidity Files] --> B(Python Ingestion Script)
B --> C[ChromaDB Vector Store]
D[User Query / Auto-Scan] --> E[Retriever Top-K Chunks]
C --> E
E --> F[Prompt Template + Context]
F --> G[Ollama Local LLM]
G --> H[Structured JSON Report]
✅ Prerequisites
1. Hardware & OS
- RAM: 16GB+ (8GB absolute minimum for 7B models).
- GPU: NVIDIA GPU (CUDA) highly recommended for LLM speed. CPU works but slow.
- OS: Linux / macOS / Windows (WSL2).
2. Software Dependencies
| Tool | Version | Install Command |
|---|---|---|
| Python | 3.10+ |
brew install python / apt install python3
|
| Node.js | 18+ | nvm install 20 |
| Docker | Latest | docker --version |
| Ollama | Latest | `curl -fsSL https://ollama.com/install.sh \ |
| Git | Latest | Standard |
3. Ollama Model Setup (Run before coding)
Pull a code-specialized model. {% raw %}deepseek-coder or codellama are best for Solidity.
ollama pull deepseek-coder:6.7b-instruct # Best balance speed/quality (4GB VRAM/RAM)
# ollama pull codellama:13b-instruct # Higher quality, needs 8GB+ VRAM/RAM
# ollama pull llama3:8b # General purpose, okay for logic
Verify: ollama run deepseek-coder:6.7b-instruct "Write a simple ERC20 transfer function"
📁 Phase 1: Project Scaffolding & Smart Contract Target
Create a monorepo structure.
mkdir solidity-sentinel && cd solidity-sentinel
mkdir -p contracts/targets src/sentinel ingestion data/chroma_db reports
touch requirements.txt Dockerfile docker-compose.yml .env.example README.md
1.1 Add a Vulnerable Target Contract (contracts/targets/VulnerableVault.sol)
We need a "victim" to audit. This contract has Reentrancy, Access Control, and Gas issues.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
function transfer(address recipient, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
contract VulnerableVault {
IERC20 public immutable token;
mapping(address => uint256) public balances;
address public owner;
bool public locked = false; // Weak reentrancy guard
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event OwnerSet(address indexed newOwner);
constructor(address _token) {
token = IERC20(_token);
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// ISSUE 1: No ReentrancyGuard (OpenZeppelin). Custom 'locked' bool is bypassable via fallback.
// ISSUE 2: State change AFTER external call (Checks-Effects-Interactions violation).
function deposit() external {
uint256 amount = token.balanceOf(msg.sender); // Deposit ALL balance? Dangerous UX.
require(amount > 0, "Zero balance");
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
emit Deposited(msg.sender, amount);
}
function withdraw(uint256 _amount) external {
require(balances[msg.sender] >= _amount, "Insufficient balance");
require(!locked, "Reentrancy detected"); // Weak guard
locked = true;
// VIOLATION: External call before state update
(bool success, ) = msg.sender.call{value: 0}(""); // Simulating ETH transfer or low-level call
// Real exploit: token.transfer(msg.sender, _amount);
// If token has fallback hook -> reentrancy -> balances[msg.sender] still high.
balances[msg.sender] -= _amount; // State update AFTER call
locked = false;
emit Withdrawn(msg.sender, _amount);
}
// ISSUE 3: Missing input validation (zero address)
// ISSUE 4: Centralization risk - single owner can rug
function setOwner(address _newOwner) external onlyOwner {
owner = _newOwner;
emit OwnerSet(_newOwner);
}
// ISSUE 5: Gas Optimization - Public mapping creates automatic getter (storage read).
// Better: private mapping + view function.
// ISSUE 6: Floating Pragma (^0.8.20) - Lock pragma for deployment.
}
1.2 Initialize Node/Hardhat (for compilation artifacts/ABI)
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init # Choose "Create a JavaScript project", accept defaults
Update hardhat.config.js to use solidity: "0.8.20".
🐍 Phase 2: Python Ingestion & RAG Pipeline (ingestion/)
2.1 requirements.txt
# Core AI/ML
langchain==0.2.1
langchain-community==0.2.1
langchain-chroma==0.1.0
sentence-transformers==3.0.0
chromadb==0.5.1
# Solidity Parsing
slither-analyzer==0.10.0 # Static analysis integration
py-solc-x==1.1.1 # Solidity compiler wrapper
# Utilities
pydantic==2.7.1
typer==0.12.3 # CLI Framework
rich==13.7.1 # Pretty Terminal Output
python-dotenv==1.0.1
ollama==0.3.1 # Official Ollama Python Client
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Slither requires Java: sudo apt install default-jre (or brew install openjdk)
2.2 src/sentinel/config.py
Centralized configuration using Pydantic Settings.
# src/sentinel/config.py
from pydantic_settings import BaseSettings
from pathlib import Path
class Settings(BaseSettings):
# Paths
PROJECT_ROOT: Path = Path(__file__).parent.parent.parent
CONTRACTS_DIR: Path = PROJECT_ROOT / "contracts/targets"
CHROMA_PATH: Path = PROJECT_ROOT / "data/chroma_db"
REPORTS_DIR: Path = PROJECT_ROOT / "reports"
# Models
EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" # Fast, small, good for code
LLM_MODEL: str = "deepseek-coder:6.7b-instruct" # Must match Ollama pull
OLLAMA_BASE_URL: str = "http://localhost:11434"
# RAG Params
CHUNK_SIZE: int = 1000
CHUNK_OVERLAP: int = 100
TOP_K_RETRIEVAL: int = 5
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()
2.3 src/sentinel/ingest.py — The "ETL" for Code
We use Slither (static analysis) to extract functions/modifiers semantically, not just raw text chunks. This dramatically improves retrieval quality.
# src/sentinel/ingest.py
import os
import json
from pathlib import Path
from typing import List, Dict
from langchain.docstore.document import Document
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_chroma import Chroma
from slither import Slither
from sentinel.config import settings
def extract_slither_elements(slither: Slither) -> List[Document]:
"""Extract functions, modifiers, contracts as structured Documents."""
docs = []
for contract in slither.contracts:
# 1. Contract Level Doc
contract_meta = {
"source": contract.source_mapping.filename.absolute,
"contract_name": contract.name,
"type": "contract_definition",
"is_abstract": contract.is_abstract,
"inheritance": [c.name for c in contract.inheritance],
}
docs.append(Document(
page_content=f"Contract {contract.name} inherits {contract_meta['inheritance']}. Functions: {[f.name for f in contract.functions]}",
metadata=contract_meta
))
# 2. Function Level Docs (The meat)
for func in contract.functions:
# Skip constructors/fallbacks for brevity, or handle specifically
if func.is_constructor or func.is_fallback or func.is_receive:
continue
# Get source code snippet
source_lines = func.source_mapping.lines
# Note: Getting exact source code via Slither API can be tricky;
# fallback to reading file lines if needed.
func_meta = {
"source": contract.source_mapping.filename.absolute,
"contract_name": contract.name,
"function_name": func.name,
"type": "function_definition",
"visibility": str(func.visibility),
"modifiers": [m.name for m in func.modifiers],
"state_vars_read": [v.name for v in func.state_variables_read],
"state_vars_written": [v.name for v in func.state_variables_written],
"external_calls": [str(c) for c in func.external_calls], # High value for reentrancy
"is_payable": func.payable,
}
# Construct rich context for LLM
content = f"""
Contract: {contract.name}
Function: {func.name}({', '.join([f'{p.type} {p.name}' for p in func.parameters])})
Visibility: {func.visibility}
Modifiers: {func_meta['modifiers']}
Reads: {func_meta['state_vars_read']}
Writes: {func_meta['state_vars_written']}
External Calls: {func_meta['external_calls']}
Source Snippet:
```
{% endraw %}
solidity
{func.source_mapping.content if func.source_mapping.content else '// Source unavailable'}
{% raw %}
```
"""
docs.append(Document(page_content=content.strip(), metadata=func_meta))
return docs
def build_vector_store():
print(f"🔍 Scanning contracts in: {settings.CONTRACTS_DIR}")
all_docs = []
for sol_file in settings.CONTRACTS_DIR.rglob("*.sol"):
try:
print(f" Parsing {sol_file.name} with Slither...")
# Slither compiles automatically if solc installed via py-solc-x
slither = Slither(str(sol_file))
all_docs.extend(extract_slither_elements(slither))
except Exception as e:
print(f" ❌ Failed to parse {sol_file}: {e}")
# Fallback: Raw text splitting
with open(sol_file, 'r') as f:
text = f.read()
splitter = RecursiveCharacterTextSplitter(
chunk_size=settings.CHUNK_SIZE,
chunk_overlap=settings.CHUNK_OVERLAP,
separators=["\nfunction ", "\nmodifier ", "\ncontract ", "\n}", "\n"]
)
chunks = splitter.create_documents([text], metadatas=[{"source": str(sol_file), "type": "raw_fallback"}])
all_docs.extend(chunks)
if not all_docs:
print("⚠️ No documents generated. Exiting.")
return
print(f"🧠 Embedding {len(all_docs)} chunks using {settings.EMBEDDING_MODEL}...")
embeddings = HuggingFaceEmbeddings(model_name=settings.EMBEDDING_MODEL)
# Clear old DB
if settings.CHROMA_PATH.exists():
import shutil
shutil.rmtree(settings.CHROMA_PATH)
vectorstore = Chroma.from_documents(
documents=all_docs,
embedding=embeddings,
persist_directory=str(settings.CHROMA_PATH)
)
print(f"✅ Vector DB persisted to {settings.CHROMA_PATH}")
if __name__ == "__main__":
build_vector_store()
🤖 Phase 3: The AI Auditor Agent (src/sentinel/agent.py)
This is the "Brain". We define a strict JSON Output Schema (via Pydantic) so the output is parseable for CI/CD.
3.1 src/sentinel/schemas.py
# src/sentinel/schemas.py
from pydantic import BaseModel, Field
from typing import List, Optional, Literal
from enum import Enum
class Severity(str, Enum):
CRITICAL = "CRITICAL"
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
INFORMATIONAL = "INFORMATIONAL"
GAS_OPTIMIZATION = "GAS_OPTIMIZATION"
class Finding(BaseModel):
title: str = Field(..., description="Concise vulnerability title")
severity: Severity
location: str = Field(..., description="ContractName.functionName or ContractName")
description: str = Field(..., description="Detailed explanation of the issue")
recommendation: str = Field(..., description="Code snippet or specific fix steps")
swc_id: Optional[str] = Field(None, description="Smart Contract Weakness Classification ID (e.g., SWC-107)")
confidence: float = Field(..., ge=0.0, le=1.0, description="Model confidence 0-1")
class AuditReport(BaseModel):
contract_name: str
summary: str
findings: List[Finding]
overall_score: int = Field(..., ge=0, le=100, description="0=Insecure, 100=Secure")
3.2 src/sentinel/agent.py — Prompt Engineering & Chain
python
# src/sentinel/agent.py
import json
import ollama
from langchain_chroma import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from sentinel.config import settings
from sentinel.schemas import AuditReport, Finding, Severity
# --- PROMPT TEMPLATE ---
# Few-shot prompting is crucial for structured output.
SYSTEM_PROMPT = """You are **SoliditySentinel**, a world-class Smart Contract Security Auditor.
Your task is to analyze the provided Solidity code context and identify vulnerabilities, logic errors, and gas optimizations.
**Rules:**
1.
#coding #tutorial #web3 #AI
Top comments (0)