🛠️ 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:
- Sepolia ETH (from Sepolia Faucet).
-
PRIVATE_KEY(Wallet deploying contract & signing backend txs - Use a burner wallet!). -
ALCHEMY_API_URL(e.g.,https://eth-sepolia.g.alchemy.com/v2/xxx). -
ETHERSCAN_API_KEY. -
OPENAI_API_KEY(or runollama run llama3locally).
📁 Step 1: Project Setup (Monorepo Style)
We use a Turborepo structure for shared types/config.
mkdir sentinel-ai && cd sentinel-ai
pnpm init -y
Install Turborepo & Tooling:
pnpm add -D turbo typescript @types/node eslint prettier
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"
}
}
Create turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", "out/**"] },
"dev": { "cache": false, "persistent": true },
"deploy": { "cache": false }
}
}
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
📜 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
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;
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]];
}
}
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;
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
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
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)
}
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)"
}
`;
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;
}
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));
}
⚙️ 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
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
Top comments (0)