🎓 End‑to‑End Tutorial
Build a Crypto‑AI dApp – “Pay‑per‑Use AI Text Generator”
In this tutorial you’ll create a decentralised application (dApp) that lets anyone pay a small amount of an ERC‑20 token (e.g. USDC on Sepolia) to receive a response from an AI language model (a GPT‑2‑style text generator).
The project combines three core blocks:
| Layer | Tech | What it does |
|---|---|---|
| Blockchain | Solidity, Hardhat, Sepolia testnet, MetaMask | Smart contract that collects payment, records a request, and emits an event |
| AI Backend | Python, Flask, 🤗 Transformers (GPT‑2), Web3.py | Listens for contract events, verifies payment, runs the model, returns the text |
| Frontend | HTML + Vanilla JS, Ethers.js | UI for users to connect wallet, send a request, and view the AI answer |
By the end you will have a fully functional dApp that you can run locally and then deploy to the cloud (Vercel/Render).
📚 Prerequisites
| Skill | Minimum level |
|---|---|
| JavaScript/TypeScript | Comfortable with Node.js + npm |
| Solidity | Know basic contract structure, events, modifiers |
| Python | Able to install packages, write a Flask server |
| Git | Basic clone / commit workflow |
| Docker (optional) | For running a local Ethereum node (Ganache) |
| MetaMask | Browser wallet installed and funded with Sepolia ETH/USDC |
System Packages
| OS | Commands |
|---|---|
| Ubuntu / Debian | sudo apt-get update && sudo apt-get install -y build-essential git curl |
| macOS | Install Xcode command‑line tools (xcode-select --install) and Homebrew (/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)") |
| Windows | Use WSL2 (Ubuntu) or install Git Bash + Node.js installer |
Software Stack
| Tool | Version (as of Aug 2026) | Install command |
|---|---|---|
| Node.js | ≥ 20.x | nvm install 20 && nvm use 20 |
| npm | ≥ 10.x (bundled) | npm i -g npm@latest |
| Hardhat | 2.22.x | npm install --save-dev hardhat |
| Python | ≥ 3.11 | pyenv install 3.11 && pyenv global 3.11 |
| pip | ≥ 24.x | python -m ensurepip --upgrade |
| Poetry (optional) | 1.8.x | `curl -sSL https://install.python-poetry.org |
| Flask | 3.0.x | {% raw %}pip install flask
|
| transformers | 4.44.x | pip install transformers torch |
| Web3.py | 6.20.x | pip install web3 |
| Ethers.js | 6.11.x | npm install ethers |
| MetaMask | latest Chrome/Firefox extension | — |
| Docker (optional) | 27.x | Install from Docker Desktop |
📂 Project Layout
crypto‑ai-dapp/
├─ contracts/
│ └─ PayPerUseAI.sol
├─ scripts/
│ └─ deploy.js
├─ test/
│ └─ PayPerUseAI.test.js
├─ backend/
│ ├─ app.py
│ └─ requirements.txt
├─ frontend/
│ ├─ index.html
│ └─ app.js
├─ hardhat.config.js
└─ README.md
1️⃣ Smart Contract – PayPerUseAI.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title Pay‑per‑Use AI Service
/// @notice Users pay an ERC‑20 token to request AI text generation.
contract PayPerUseAI {
// ------------------------------------------------------------------------
// State
// ------------------------------------------------------------------------
address public owner; // contract owner (receives fees)
IERC20 public paymentToken; // e.g. USDC on Sepolia
uint256 public price; // price per request (token decimals)
// Mapping to avoid replay attacks (requestId => used)
mapping(bytes32 => bool) public usedRequests;
// ------------------------------------------------------------------------
// Events
// ------------------------------------------------------------------------
event RequestGenerated(
address indexed requester,
bytes32 indexed requestId,
string prompt,
uint256 timestamp
);
// ------------------------------------------------------------------------
// Modifiers
// ------------------------------------------------------------------------
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// ------------------------------------------------------------------------
// Constructor
// ------------------------------------------------------------------------
constructor(address _paymentToken, uint256 _price) {
owner = msg.sender;
paymentToken = IERC20(_paymentToken);
price = _price;
}
// ------------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------------
/// @notice Pay and request AI generation.
/// @param prompt The user supplied text prompt (max 256 bytes).
function requestAI(string calldata prompt) external {
require(bytes(prompt).length > 0 && bytes(prompt).length <= 256,
"Prompt length invalid");
// Transfer tokens from the caller to the contract.
require(
paymentToken.transferFrom(msg.sender, address(this), price),
"Token transfer failed"
);
// Create a unique request id (hash of caller + prompt + block.timestamp)
bytes32 requestId = keccak256(
abi.encodePacked(msg.sender, prompt, block.timestamp)
);
// Mark as used (prevents double‑processing)
usedRequests[requestId] = true;
emit RequestGenerated(msg.sender, requestId, prompt, block.timestamp);
}
// ------------------------------------------------------------------------
// Owner Functions
// ------------------------------------------------------------------------
/// @notice Withdraw accumulated tokens.
function withdraw() external onlyOwner {
uint256 bal = paymentToken.balanceOf(address(this));
require(bal > 0, "Zero balance");
require(paymentToken.transfer(owner, bal), "Withdraw failed");
}
/// @notice Update price.
function setPrice(uint256 _price) external onlyOwner {
price = _price;
}
/// @notice Update payment token (e.g. switch to a newer USDC address).
function setPaymentToken(address _token) external onlyOwner {
paymentToken = IERC20(_token);
}
}
// Minimal ERC‑20 interface
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
Explanation
| Part | What it does |
|---|---|
| owner / paymentToken / price | Stores contract admin, which ERC‑20 token is used, and the per‑request fee (in token base units). |
| usedRequests | Guarantees that each request ID can be processed only once – the backend will check this flag before sending a response. |
| requestAI() | - Verifies prompt length. - Pulls price tokens from the caller (requires prior approve).- Emits RequestGenerated containing the prompt and a unique requestId. |
| withdraw() | Owner can pull the collected tokens. |
| setPrice / setPaymentToken | Admin can change fee or token address without redeploying. |
2️⃣ Hardhat Setup
2.1 Initialise the project
mkdir crypto-ai-dapp && cd crypto-ai-dapp
npm init -y
npm i --save-dev hardhat ethers @nomicfoundation/hardhat-toolbox dotenv
npx hardhat # choose "Create a basic sample project"
Delete the sample contract and copy PayPerUseAI.sol into contracts/.
2.2 Environment variables (.env)
# .env (never commit this file!)
SEPOLIA_RPC_URL="https://sepolia.infura.io/v3/<INFURA_PROJECT_ID>"
PRIVATE_KEY="0xYOUR_WALLET_PRIVATE_KEY"
USDC_SEPOLIA="0x6f9c4e196cdd8c0e2ab8ab9be0cbd6e5c4c36724" # Sepolia USDC (example)
2.3 Hardhat config (hardhat.config.js)
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: "0.8.24",
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
// optional: gasPrice: 10e9
},
},
};
2.4 Deploy script (scripts/deploy.js)
const { ethers } = require("hardhat");
require("dotenv").config();
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying from:", deployer.address);
const PayPerUseAI = await ethers.getContractFactory("PayPerUseAI");
const price = ethers.parseUnits("0.5", 6); // 0.5 USDC (6 decimals)
const contract = await PayPerUseAI.deploy(process.env.USDC_SEPOLIA, price);
await contract.waitForDeployment();
console.log("PayPerUseAI deployed to:", contract.target);
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
2.5 Deploy
npx hardhat run scripts/deploy.js --network sepolia
Copy the resulting contract address – you’ll need it for the backend & frontend.
3️⃣ Backend – Flask AI Service
The backend listens for RequestGenerated events, verifies that the request hasn’t been processed, runs a GPT‑2 model, and returns the generated text via a simple HTTP endpoint.
3.1 Set up a virtual environment
cd backend
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
Create requirements.txt:
flask
torch
transformers
web3
python-dotenv
3.2 Environment (backend/.env)
# Contract & blockchain
CONTRACT_ADDRESS=0xYourDeployedAddress
SEPOLIA_RPC=https://sepolia.infura.io/v3/<INFURA_PROJECT_ID>
USDC_ADDRESS=0x6f9c4e196cdd8c0e2ab8ab9be0cbd6e5c4c36724
# Model (you can replace with a larger model later)
MODEL_NAME=gpt2
MAX_LENGTH=64
3.3 Flask app (backend/app.py)
import os
import json
import hashlib
from datetime import datetime
from flask import Flask, request, jsonify
from dotenv import load_dotenv
from web3 import Web3
from transformers import pipeline, set_seed
load_dotenv()
app = Flask(__name__)
# -------------------------------------------------
# 1️⃣ Web3 / Contract Setup
# -------------------------------------------------
w3 = Web3(Web3.HTTPProvider(os.getenv("SEPOLIA_RPC")))
assert w3.is_connected(), "Web3 provider not reachable"
contract_address = Web3.to_checksum_address(os.getenv("CONTRACT_ADDRESS"))
abi = json.loads("""[
{"anonymous":false,"inputs":[
{"indexed":true,"internalType":"address","name":"requester","type":"address"},
{"indexed":true,"internalType":"bytes32","name":"requestId","type":"bytes32"},
{"indexed":false,"internalType":"string","name":"prompt","type":"string"},
{"indexed":false,"internalType":"uint256","name":"timestamp","type":"uint256"}
],"name":"RequestGenerated","type":"event"}
]""")
contract = w3.eth.contract(address=contract_address, abi=abi)
# In‑memory store of processed request IDs (in prod → DB/Redis)
processed = set()
# -------------------------------------------------
# 2️⃣ AI Model Setup
# -------------------------------------------------
model_name = os.getenv("MODEL_NAME", "gpt2")
generator = pipeline("text-generation", model=model_name, device=0) # GPU if available
MAX_LENGTH = int(os.getenv("MAX_LENGTH", "64"))
# -------------------------------------------------
# 3️⃣ Helper: Verify that request is legit & not processed
# -------------------------------------------------
def is_valid_event(event) -> bool:
request_id = event["args"]["requestId"]
if request_id in processed:
return False
# Mark as processed early to avoid race conditions
processed.add(request_id)
return True
# -------------------------------------------------
# 4️⃣ Route: Poll for new events (simple polling endpoint)
# -------------------------------------------------
@app.route("/poll", methods=["GET"])
def poll_events():
# Get latest block number (you could store last seen block in DB)
latest = w3.eth.block_number
from_block = max(latest - 5000, 0) # look back ~1‑day on Sepolia
events = contract.events.RequestGenerated().get_logs(
fromBlock=from_block, toBlock=latest
)
new_requests = []
for ev in events:
if is_valid_event(ev):
new_requests.append({
"requester": ev["args"]["requester"],
"requestId": ev["args"]["requestId"].hex(),
"prompt": ev["args"]["prompt"],
"timestamp": ev["args"]["timestamp"]
})
return jsonify(new_requests)
# -------------------------------------------------
# 5️⃣ Route: Generate text for a specific requestId
# -------------------------------------------------
@app.route("/generate", methods=["POST"])
def generate():
data = request.get_json()
request_id = data.get("requestId")
prompt = data.get("prompt")
if not request_id or not prompt:
return jsonify({"error": "Missing fields"}), 400
# Simple idempotency guard
if request_id in processed:
return jsonify({"error": "Already processed"}), 409
# Run model
set_seed(42) # deterministic for demo; remove for randomness
output = generator(prompt, max_length=MAX_LENGTH, num_return_sequences=1)[0]["generated_text"]
# Mark processed
processed.add(request_id)
return jsonify({"requestId": request_id, "output": output})
# -------------------------------------------------
# 6️⃣ Health check
# -------------------------------------------------
@app.route("/health", methods=["GET"])
def health():
return "OK", 200
if __name__ == "__main__":
# In production use gunicorn or uvicorn (ASGI) behind a reverse proxy
app.run(host="0.0.0.0", port=5000, debug=True)
How it works
-
Polling endpoint (
/poll) – the frontend calls this every few seconds. It fetches recentRequestGeneratedevents, filters out already‑handled IDs, and returns an array of pending requests. -
Generate endpoint (
/generate) – the frontend sends therequestIdandpromptit just received. The backend runs the GPT‑2 model and returns the generated text.
Tip: In production you would replace the polling model with a WebSocket or Chainlink Keepers to push events instantly, and store processed IDs in a persistent DB (PostgreSQL, Redis, etc.) to survive restarts.
4️⃣ Frontend – Simple HTML + Ethers.js
4.1 Install Ethers
cd ../frontend
npm init -y
npm install ethers
4.2 index.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Pay‑per‑Use AI</title>
<style>
body {font-family: Arial, sans-serif; max-width: 600px; margin: 2rem auto;}
textarea {width: 100%; height: 120px;}
button {padding: 0.6rem 1rem; margin-top: 0.5rem;}
#output {border: 1px solid #
#coding #tutorial #web3 #AI
Top comments (0)