DEV Community

Naren karthi
Naren karthi

Posted on

How to Build an Autonomous Trading Agent with Python

🛠️ Tutorial: Building an "AI Smart Contract Auditor" (Crypto + AI)

Project: SentinelAI – A decentralized app where users submit a verified contract address, an AI analyzes the source code for vulnerabilities/gas optimizations, and the analysis hash is stored on-chain for immutable provenance.


🏗️ Architecture Overview

graph LR
    A[User / Frontend<br>Next.js] -->|1. Submit Address| B[Backend API<br>Next.js Route Handler]
    B -->|2. Fetch Source Code| C[Etherscan / Blockscout API]
    B -->|3. Prompt Engineering| D[LLM Provider<br>OpenAI / Ollama / HF]
    D -->|4. Structured JSON Report| B
    B -->|5. Write Hash to Chain| E[Smart Contract<br>Sepolia Testnet]
    B -->|6. Return Report + Tx Hash| A

✅ Prerequisites

Tool Version Purpose
Node.js v20+ Runtime
pnpm v8+ Package Manager (faster, strict)
Git Latest Version Control
VS Code Latest IDE (Solidity + TS extensions)
MetaMask Browser Ext Wallet for Sepolia
Alchemy/Infura Free Tier RPC Provider URL
Etherscan API Key Free Tier Fetch verified source code
OpenAI API Key (or Ollama) Paid / Local LLM Inference

Accounts & Keys Needed:

  1. Sepolia ETH (from Sepolia Faucet).
  2. PRIVATE_KEY (Wallet deploying contract & signing backend txs - Use a burner wallet!).
  3. ALCHEMY_API_URL (e.g., https://eth-sepolia.g.alchemy.com/v2/xxx).
  4. ETHERSCAN_API_KEY.
  5. OPENAI_API_KEY (or run ollama run llama3 locally).

📁 Step 1: Project Setup (Monorepo Style)

We use a Turborepo structure for shared types/config.

mkdir sentinel-ai && cd sentinel-ai
pnpm init -y
Enter fullscreen mode Exit fullscreen mode

Install Turborepo & Tooling:

pnpm add -D turbo typescript @types/node eslint prettier
Enter fullscreen mode Exit fullscreen mode

Create package.json (Root):

{
  "name": "sentinel-ai",
  "private": true,
  "workspaces": ["packages/*", "apps/*"],
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "deploy:contract": "turbo run deploy --filter=contracts",
    "lint": "turbo run lint"
  },
  "devDependencies": {
    "turbo": "^1.13.0",
    "typescript": "^5.4.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create turbo.json:

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**", "out/**"] },
    "dev": { "cache": false, "persistent": true },
    "deploy": { "cache": false }
  }
}
Enter fullscreen mode Exit fullscreen mode

Folder Structure:

sentinel-ai/
├── apps/
│   ├── frontend/      # Next.js App Router
│   └── backend/       # Next.js API Routes (or separate Express/Fastify)
├── packages/
│   ├── contracts/     # Hardhat/Foundry Solidity Project
│   ├── ai-logic/      # Shared TS: Prompts, Parsers, LLM Clients
│   └── ui-components/ # Shared React Components (Optional)
└── package.json
Enter fullscreen mode Exit fullscreen mode

📜 Step 2: Smart Contract (packages/contracts)

Stores the hash of the AI report (IPFS CID or Keccak256 of JSON) + metadata.

Init Hardhat:

cd packages
mkdir contracts && cd contracts
pnpm init -y
pnpm add -D hardhat @nomicfoundation/hardhat-toolbox @openzeppelin/contracts
pnpm add ethers viem
npx hardhat init # Choose "TypeScript Project", add .gitignore, install deps
Enter fullscreen mode Exit fullscreen mode

hardhat.config.ts:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "dotenv/config";

const config: HardhatUserConfig = {
  solidity: "0.8.24",
  networks: {
    sepolia: {
      url: process.env.ALCHEMY_API_URL || "",
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
    },
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY || "",
  },
  gasReporter: { enabled: true, currency: "USD" },
};
export default config;
Enter fullscreen mode Exit fullscreen mode

contracts/AuditRegistry.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract AuditRegistry is Ownable {
    struct AuditRecord {
        address indexed contractAddress;
        bytes32 reportHash;       // Keccak256(reportJSON) or IPFS CIDv0 hash
        string modelVersion;      // e.g., "gpt-4-turbo-2024-04-09"
        uint256 timestamp;
        address auditor;          // Backend wallet address
    }

    event AuditStored(address indexed contractAddress, bytes32 reportHash, string modelVersion);

    mapping(bytes32 => AuditRecord) public records; // Key = reportHash
    mapping(address => bytes32[]) public contractHistory; // Contract -> List of Hashes

    function storeAudit(
        address _contractAddress,
        bytes32 _reportHash,
        string calldata _modelVersion
    ) external returns (bytes32) {
        // Optional: Restrict to backend signer via Ownable or ECDSA verification
        // For tutorial: Only owner (deployer) can write.
        // In production: Verify signature from authorized backend key.

        AuditRecord memory record = AuditRecord({
            contractAddress: _contractAddress,
            reportHash: _reportHash,
            modelVersion: _modelVersion,
            timestamp: block.timestamp,
            auditor: msg.sender
        });

        records[_reportHash] = record;
        contractHistory[_contractAddress].push(_reportHash);

        emit AuditStored(_contractAddress, _reportHash, _modelVersion);
        return _reportHash;
    }

    function getLatestAudit(address _contract) external view returns (AuditRecord memory) {
        bytes32[] storage history = contractHistory[_contract];
        require(history.length > 0, "No audits found");
        return records[history[history.length - 1]];
    }
}
Enter fullscreen mode Exit fullscreen mode

Deploy Script (ignition/modules/AuditRegistry.ts):

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

const AuditRegistryModule = buildModule("AuditRegistryModule", (m) => {
  const registry = m.contract("AuditRegistry");
  return { registry };
});

export default AuditRegistryModule;
Enter fullscreen mode Exit fullscreen mode

Run Deploy:

# In packages/contracts/.env
# ALCHEMY_API_URL=...
# PRIVATE_KEY=...
# ETHERSCAN_API_KEY=...

npx hardhat ignition deploy ./ignition/modules/AuditRegistry.ts --network sepolia --verify
Enter fullscreen mode Exit fullscreen mode

Save the deployed Address! e.g., 0xAbC...1234


🧠 Step 3: AI Logic Package (packages/ai-logic)

Decouples prompting/parsing from the framework.

cd ../..
mkdir -p packages/ai-logic/src
cd packages/ai-logic
pnpm init -y
pnpm add openai zod viem
pnpm add -D typescript @types/node vitest
Enter fullscreen mode Exit fullscreen mode

tsconfig.json: Standard NodeNext config.

src/types.ts:

export interface AuditFinding {
  severity: "Critical" | "High" | "Medium" | "Low" | "Informational" | "Gas Optimization";
  title: string;
  description: string;
  location: string; // Function name / Line approx
  recommendation: string;
  swcId?: string; // Smart Contract Weakness Classification ID
}

export interface AuditReport {
  contractAddress: string;
  contractName: string;
  compilerVersion: string;
  analyzedAt: string; // ISO String
  modelUsed: string;
  summary: string;
  findings: AuditFinding[];
  score: number; // 0-100 (100 = Secure)
}
Enter fullscreen mode Exit fullscreen mode

src/prompts.ts:

export const SYSTEM_PROMPT = `You are SentinelAI, a world-class Smart Contract Security Auditor. 
Analyze the provided Solidity code. Output ONLY a valid JSON object matching the TypeScript interface 'AuditReport' provided in the user prompt. 
Do not include markdown formatting, comments, or conversational text. 
Focus on: Reentrancy, Access Control, Arithmetic, Unchecked Return Values, Gas Griefing, Logic Errors, Centralization Risks.`;

export const USER_PROMPT_TEMPLATE = (code: string, contractName: string) => `
**Contract Name:** ${contractName}
**Source Code:**
\`\`\`solidity
${code}
\`\`\`

**Required JSON Output Schema:**
{
  "contractAddress": "string (placeholder)",
  "contractName": "string",
  "compilerVersion": "string",
  "analyzedAt": "ISO String (placeholder)",
  "modelUsed": "string (placeholder)",
  "summary": "Executive summary (2-3 sentences)",
  "findings": [
    {
      "severity": "Critical|High|Medium|Low|Informational|Gas Optimization",
      "title": "Short Title",
      "description": "Detailed technical explanation",
      "location": "Function/Modifier name",
      "recommendation": "Specific fix code or pattern",
      "swcId": "Optional SWC-ID (e.g., SWC-107)"
    }
  ],
  "score": "number (0-100)"
}
`;
Enter fullscreen mode Exit fullscreen mode

src/llm-client.ts (Supports OpenAI & Local Ollama):

import OpenAI from "openai";
import { AuditReport } from "./types";
import { SYSTEM_PROMPT, USER_PROMPT_TEMPLATE } from "./prompts";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// For Ollama: const openai = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });

export async function analyzeContract(
  sourceCode: string,
  contractName: string,
  model: string = "gpt-4-turbo" // or "llama3" for Ollama
): Promise<AuditReport> {
  const completion = await openai.chat.completions.create({
    model,
    messages: [
      { role: "system", content: SYSTEM_PROMPT },
      { role: "user", content: USER_PROMPT_TEMPLATE(sourceCode, contractName) },
    ],
    response_format: { type: "json_object" }, // Enforces JSON mode
    temperature: 0.1, // Low temp for deterministic security analysis
    max_tokens: 4096,
  });

  const raw = completion.choices[0].message.content;
  if (!raw) throw new Error("Empty response from LLM");

  const parsed = JSON.parse(raw) as AuditReport;

  // Enrich metadata
  parsed.contractAddress = "PENDING"; // Filled by backend
  parsed.analyzedAt = new Date().toISOString();
  parsed.modelUsed = model;

  return parsed;
}
Enter fullscreen mode Exit fullscreen mode

src/utils.ts:

import { keccak256, toBytes } from "viem";
import { AuditReport } from "./types";

export function calculateReportHash(report: AuditReport): `0x${string}` {
  // Canonicalize: Sort keys, remove whitespace for deterministic hash
  const canonical = JSON.stringify(report, Object.keys(report).sort());
  return keccak256(toBytes(canonical));
}
Enter fullscreen mode Exit fullscreen mode

⚙️ Step 4: Backend API (apps/backend)

Next.js Route Handlers (App Router) acting as the orchestrator.

cd ../../apps
mkdir backend && cd backend
pnpm init -y
pnpm add next react react-dom viem ethers @ai-logic/contracts # link local packages later
pnpm add -D typescript @types/react @types/node
Enter fullscreen mode Exit fullscreen mode

next.config.js: Standard.

src/app/api/audit/route.ts:


typescript
import { NextRequest, NextResponse } from "next/server";
import { createPublicClient, http, parseAbi, createWalletClient, formatEther } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { analyzeContract, calculateReportHash, AuditReport } from "@ai-logic/ai-logic"; // Workspace alias

// 1. Config
const RPC_URL = process.env.ALCHEMY_API_URL!;
const ETHERSCAN_KEY = process.env.ETHERSCAN_API_KEY!;
const REGISTRY_ADDRESS = process.env.NEXT_PUBLIC_REGISTRY_ADDRESS! as `0x${string}`;
const SIGNER_PK = process.env.BACKEND_SIGNER_PK! as `0x${string}`;

// 2. Viem Clients
const publicClient = createPublicClient({ chain: sepolia, transport: http(RPC_URL) });
const account = privateKeyToAccount(SIGNER_PK);
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(RPC_URL) });

// 3. Contract ABI (Minimal)
const registryAbi = parseAbi([
  "function storeAudit(address, bytes32, string) external returns (bytes32)",
  "function getLatestAudit(address) external view returns (tuple(address, bytes32, string, uint256, address))",
]);

// 4. Etherscan Source Fetch
async function fetchVerifiedSource(address: string) {
  const url = `https://api-sepolia.etherscan.io/api?module=contract&action=getsourcecode&address=${address}&apikey=${ETHERSCAN_KEY}`;
  const res = await fetch(url);
  const data = await res.json();

  if (data.status !== "1" || !data.result[0].SourceCode) {
    throw new Error("Contract not verified on Etherscan or source code unavailable.");
  }

  // Handle standard JSON input format vs single file
  let source = data.result[0].SourceCode;
  let name = data.result[0].ContractName;

  if (source.startsWith("{")) { // Standard JSON Input
    const input = JSON.parse(source);
    // Flatten sources for LLM context (Simple concat for tutorial)
    source = Object.values(input.sources).map((f: any) => f.content).join("\n// --- FILE SEPARATOR ---\n");
    name = Object.keys(input.sources)[0].split("/").pop()?.replace(".sol", "") || name;
  }

  return { sourceCode: source, contractName: name, compilerVersion: data.result[0].CompilerVersion };
}

export async function POST(req: NextRequest) {
  try {
    const { contractAddress } = await req.json();
    if (!contractAddress || !/^0x[a-fA-F0-9]{40}$/.test(contractAddress)) {
      return NextResponse.json({ error: "Invalid Address" }, { status: 400 });
    }

    // Step A: Fetch Source
    console.log(`[Backend] Fetch

#coding #tutorial #web3 #AI
Enter fullscreen mode Exit fullscreen mode

Top comments (0)