DEV Community

Naren karthi
Naren karthi

Posted on

How to Build an Autonomous Trading Agent with Python

� 🛡️ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

🤖 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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)