🚀 End‑to‑End Tutorial
Project: AI‑Powered Crypto Signal dApp
A minimal, production‑ready example that shows how to combine a machine‑learning model (sentiment analysis of crypto news) with a blockchain smart contract that stores signals and rewards token holders.
You’ll end up with three moving parts:
| Part | Tech | What it does |
|---|---|---|
| AI service | Python + FastAPI + 🤗 Transformers | Pulls the latest crypto news, runs a sentiment model, returns a “BUY/SELL/HOLD” signal via a REST endpoint. |
| Smart contract | Solidity + Hardhat | ERC‑20 “SignalToken” (SGT) that mints rewards when a new signal is posted and lets holders claim them. |
| Frontend | React + Ethers.js | UI to request a new signal, view the last signal, and claim rewards. |
All three pieces can be deployed for free on test‑networks (Sepolia, Polygon Mumbai) and public cloud services (Render, Vercel, Netlify).
📋 Table of Contents
- Prerequisites
- Folder Structure
- 1️⃣ Build the AI Service
- 2️⃣ Write & Test the Solidity Contract
- 3️⃣ Front‑end React App
- 4️⃣ Deploy Everything
- Next Steps & Security Tips
1️⃣ Prerequisites
| Category | Tool | Version (tested) | Install Command |
|---|---|---|---|
| Node | Node.js | v20.x |
brew install node / nvm install 20
|
| npm | 10.x |
bundled with Node | |
| Hardhat | — | npm i -D hardhat |
|
| Python | Python | 3.10+ |
brew install python@3.10 |
| pip | — | bundled | |
| FastAPI | 0.115 |
pip install fastapi uvicorn[standard] |
|
| Transformers | 4.44 |
pip install transformers torch |
|
| Requests | 2.32 |
pip install requests |
|
| Blockchain | Metamask (browser extension) | — | Chrome/Firefox add‑on |
| Sepolia test‑ETH | faucet | https://sepoliafaucet.com/ | |
| IDE | VS Code (recommended) | — | https://code.visualstudio.com/ |
| Optional (Docker) | Docker Desktop | latest | https://www.docker.com/products/docker-desktop/ |
Environment variables
Create a .env file in the root of the repo (add it to .gitignore).
# ---------- AI SERVICE ----------
NEWS_API_KEY=YOUR_NEWSAPI_OR_ALTERNATIVE_KEY
HUGGINGFACE_TOKEN=hf_XXXXXXXXXXXXXXXXXXXX # optional, for private models
# ---------- BLOCKCHAIN ----------
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID
PRIVATE_KEY=0xYOUR_WALLET_PRIVATE_KEY # used by Hardhat deploy script
# ---------- FRONTEND ----------
REACT_APP_BACKEND_URL=http://localhost:8000 # will be overridden in prod
2️⃣ Folder Structure
crypto‑ai‑dapp/
│
├─ backend/ # Python FastAPI service
│ ├─ app/
│ │ ├─ __init__.py
│ │ ├─ main.py
│ │ └─ model.py
│ ├─ requirements.txt
│ └─ Dockerfile
│
├─ contracts/ # Solidity contracts + Hardhat
│ ├─ contracts/
│ │ └─ SignalToken.sol
│ ├─ test/
│ │ └─ SignalToken.test.js
│ ├─ scripts/
│ │ └─ deploy.js
│ ├─ hardhat.config.js
│ └─ package.json
│
├─ frontend/ # React app
│ ├─ src/
│ │ ├─ components/
│ │ │ ├─ SignalCard.jsx
│ │ │ └─ ClaimButton.jsx
│ │ ├─ App.jsx
│ │ └─ index.jsx
│ ├─ public/
│ ├─ vite.config.js
│ └─ package.json
│
└─ .env
3️⃣ Build the AI Service
3.1 Install dependencies
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
backend/requirements.txt
fastapi
uvicorn[standard]
transformers
torch
requests
python-dotenv
3.2 Model wrapper (model.py)
# backend/app/model.py
import os
from transformers import pipeline, AutoModelForSequenceClassification, AutoTokenizer
from dotenv import load_dotenv
load_dotenv()
# Use a small, fast sentiment model (distilbert-base-uncased-finetuned-sst-2)
MODEL_NAME = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME)
sentiment = pipeline("sentiment-analysis", model=model, tokenizer=tokenizer)
def analyse_text(text: str) -> str:
"""Return BUY/SELL/HOLD based on sentiment score."""
result = sentiment(text)[0] # e.g. {'label': 'POSITIVE', 'score': 0.997}
label, score = result["label"], result["score"]
# Simple heuristic:
if label == "POSITIVE" and score > 0.8:
return "BUY"
elif label == "NEGATIVE" and score > 0.8:
return "SELL"
else:
return "HOLD"
3.3 News fetcher & API (main.py)
# backend/app/main.py
import os
import requests
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from dotenv import load_dotenv
from .model import analyse_text
load_dotenv()
NEWS_API_KEY = os.getenv("NEWS_API_KEY")
if not NEWS_API_KEY:
raise RuntimeError("NEWS_API_KEY missing in .env")
app = FastAPI(title="Crypto Sentiment Service")
class SignalResponse(BaseModel):
signal: str # BUY / SELL / HOLD
headline: str
source: str
url: str
def fetch_latest_crypto_news() -> dict:
"""Pull the most recent crypto news headline."""
url = "https://newsapi.org/v2/everything"
params = {
"q": "cryptocurrency OR bitcoin OR ethereum",
"language": "en",
"sortBy": "publishedAt",
"pageSize": 1,
"apiKey": NEWS_API_KEY,
}
resp = requests.get(url, params=params)
data = resp.json()
if resp.status_code != 200 or not data.get("articles"):
raise HTTPException(status_code=502, detail="News API failure")
article = data["articles"][0]
return article
@app.get("/signal", response_model=SignalResponse)
def get_signal():
article = fetch_latest_crypto_news()
headline = article["title"]
signal = analyse_text(headline)
return SignalResponse(
signal=signal,
headline=headline,
source=article["source"]["name"],
url=article["url"],
)
3.4 Run locally
uvicorn app.main:app --host 0.0.0.0 --port 8000
Visit http://localhost:8000/signal → you’ll see a JSON payload like:
{
"signal": "BUY",
"headline": "Bitcoin rallies past $30,000 as institutional interest surges",
"source": "CoinDesk",
"url": "https://www.coindesk.com/..."
}
3.5 Dockerize (optional)
backend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Build & run:
docker build -t crypto-ai-backend .
docker run -d -p 8000:8000 --env-file .env crypto-ai-backend
4️⃣ Write & Test the Solidity Contract
4.1 Initialize Hardhat
cd contracts
npm init -y
npm i -D hardhat @nomicfoundation/hardhat-toolbox ethers dotenv
npx hardhat
# Choose "Create a basic sample project"
4.2 Install OpenZeppelin
npm i @openzeppelin/contracts
4.3 SignalToken.sol
// contracts/SignalToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract SignalToken is ERC20 {
address public admin; // contract owner (deployer)
uint256 public rewardPerSignal = 10 * 10**18; // 10 SGT per signal
// Store the latest signal for UI convenience
enum Signal { NONE, BUY, SELL, HOLD }
Signal public lastSignal;
uint256 public lastSignalBlock;
// Events
event SignalPosted(Signal signal, uint256 blockNumber);
event RewardClaimed(address indexed user, uint256 amount);
constructor() ERC20("SignalToken", "SGT") {
admin = msg.sender;
_mint(admin, 1_000_000 * 10**18); // pre‑mint for rewards
}
modifier onlyAdmin() {
require(msg.sender == admin, "Not admin");
_;
}
// --------- Admin functions ----------
function setReward(uint256 _newReward) external onlyAdmin {
rewardPerSignal = _newReward;
}
// --------- Core logic ----------
/**
* @dev Called by the backend (via a signed tx) to register a new signal.
* The backend must sign the tx with the admin private key.
*/
function postSignal(string calldata _signal) external onlyAdmin {
Signal s;
if (keccak256(bytes(_signal)) == keccak256("BUY")) {
s = Signal.BUY;
} else if (keccak256(bytes(_signal)) == keccak256("SELL")) {
s = Signal.SELL;
} else {
s = Signal.HOLD;
}
lastSignal = s;
lastSignalBlock = block.number;
emit SignalPosted(s, block.number);
// Mint reward to the contract itself (kept for distribution)
_mint(address(this), rewardPerSignal);
}
/**
* @dev Anyone holding SGT can claim the reward that accumulated
* since the last claim. Simple proportional distribution.
*/
function claimReward() external {
uint256 totalSupply_ = totalSupply() - balanceOf(address(this));
require(totalSupply_ > 0, "No tokens in circulation");
uint256 contractBal = balanceOf(address(this));
require(contractBal > 0, "No rewards to claim");
// Pro‑rata share
uint256 userShare = (contractBal * balanceOf(msg.sender)) / totalSupply_;
require(userShare > 0, "No reward for you yet");
// Transfer reward
_transfer(address(this), msg.sender, userShare);
emit RewardClaimed(msg.sender, userShare);
}
}
Why the contract works this way
| Feature | Reason |
|---|---|
Admin‑only postSignal |
Prevents anyone from spamming the contract; the backend signs the tx using the same admin private key used for deployment. |
| Reward pool in the contract | Keeps rewards inside the contract, making claimReward a pure math operation (no external oracle needed). |
| Pro‑rata distribution | Fairly rewards token holders based on their share of the total supply. |
4.4 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],
},
// Add Polygon Mumbai if you like:
// mumbai: { url: process.env.MUMBAI_RPC_URL, accounts: [process.env.PRIVATE_KEY] },
},
};
4.5 Test (SignalToken.test.js)
// test/SignalToken.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("SignalToken", function () {
let SignalToken, token, admin, user1, user2;
beforeEach(async () => {
[admin, user1, user2] = await ethers.getSigners();
SignalToken = await ethers.getContractFactory("SignalToken");
token = await SignalToken.deploy();
await token.waitForDeployment();
});
it("should mint initial supply to admin", async () => {
const adminBal = await token.balanceOf(admin.address);
expect(adminBal).to.equal(ethers.parseEther("1000000"));
});
it("admin can post a signal and reward pool grows", async () => {
await token.postSignal("BUY");
const pool = await token.balanceOf(token.getAddress());
expect(pool).to.equal(await token.rewardPerSignal());
});
it("users can claim proportional rewards", async () => {
// admin transfers tokens to users
await token.transfer(user1.address, ethers.parseEther("100"));
await token.transfer(user2.address, ethers.parseEther("300"));
// post a signal (adds reward)
await token.postSignal("SELL");
// user1 claims
await token.connect(user1).claimReward();
const bal1 = await token.balanceOf(user1.address);
// Reward pool = 10 SGT; user1 owns 25% (100/400)
expect(bal1).to.equal(ethers.parseEther("100").add(ethers.parseEther("2.5")));
});
});
Run tests:
npx hardhat test
All should pass.
4.6 Deploy script (deploy.js)
// scripts/deploy.js
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with:", deployer.address);
const SignalToken = await ethers.getContractFactory("SignalToken");
const token = await SignalToken.deploy();
await token.waitForDeployment();
console.log("SignalToken deployed at:", await token.getAddress());
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Deploy to Sepolia:
npx hardhat run scripts/deploy.js --network sepolia
Copy the resulting contract address – you’ll need it in the frontend.
5️⃣ Front‑end React App
We’ll use Vite (fast) + Ethers.js for wallet interaction.
5.1 Scaffold
cd frontend
npm create vite@latest . --template react
npm i ethers axios
Add dotenv for Vite env variables (npm i -D vite-plugin-env-compatible optional).
5.2 Vite env variables
Create frontend/.env (Vite prefixes env vars with VITE_):
VITE_BACKEND_URL=http://localhost:8000
VITE_CONTRACT_ADDRESS=0xYourDeployedSignalToken
VITE_SEPOLIA_RPC=https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID
5.3 App.jsx
jsx
// src/App.jsx
import { useEffect, useState } from "react";
import { ethers } from "ethers";
import axios from "axios";
import SignalCard from "./components/SignalCard";
import ClaimButton from "./components/ClaimButton";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
const CONTRACT
#coding #tutorial #web3 #AI
Top comments (0)