🚀 End‑to‑End Tutorial
Build a “Crypto‑AI Oracle” – an on‑chain price‑prediction service powered by a TensorFlow model
In this tutorial you will:
- Collect historical price data for a crypto token (e.g., ETH).
- Train a lightweight LSTM model that predicts the next‑day price.
- Expose the model through a FastAPI web service.
- Wrap the service in a Chainlink External Adapter (Node.js) so a Solidity contract can request a prediction.
- Deploy the smart contract to an Ethereum testnet (Sepolia) and register it with a Chainlink oracle.
- Call the contract from a front‑end (React) and see the on‑chain prediction.
📋 Table of Contents
| Step | Title | Files |
|---|---|---|
| 0 | Prerequisites & Environment Setup | – |
| 1 | Data collection & preprocessing | data/ |
| 2 | Model training & export |
model/train.py, model/price_lstm.h5
|
| 3 | FastAPI inference server | api/main.py |
| 4 | Chainlink External Adapter (Node.js) | adapter/index.js |
| 5 | Solidity Oracle contract | contracts/CryptoAIOracle.sol |
| 6 | Hardhat deployment & verification | scripts/deploy.js |
| 7 | Front‑end demo (React) | frontend/ |
| 8 | Testing & troubleshooting | – |
| 9 | Next steps & security notes | – |
0️⃣ Prerequisites & Environment Setup
| Tool | Version (tested) | Install command |
|---|---|---|
| Node.js | 20.x | `curl -fsSL https://deb.nodesource.com/setup_20.x |
| Python | 3.11 | {% raw %}sudo apt-get install python3.11 python3.11-venv
|
| Git | latest | sudo apt-get install git |
| Docker | 27.x | Follow Docker’s official guide |
| Hardhat | 2.22.x | npm i -g hardhat |
| Solidity compiler | 0.8.24 | Comes with Hardhat |
| Chainlink CLI (optional) | 1.2.x | npm i -g @chainlink/cli |
| Metamask | Chrome/Firefox extension | – |
| Testnet ETH | Sepolia faucet | https://sepoliafaucet.com |
Tip: All commands below assume a Unix‑like shell (Linux/macOS). Windows users can use WSL2 or Git‑Bash.
1️⃣ Create a monorepo folder
mkdir crypto-ai-oracle && cd crypto-ai-oracle
git init
2️⃣ Initialise Python & Node workspaces
# Python virtual env for data/model
python3.11 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
# Install data + ML libs
pip install pandas numpy yfinance tensorflow==2.16.1 fastapi uvicorn python-dotenv
# Node workspace (for adapter & hardhat)
mkdir node && cd node
npm init -y
npm i axios ethers @chainlink/contracts @chainlink/external-adapter
cd ..
3️⃣ Initialise Hardhat project
npx hardhat init
# Choose "Create a basic sample project"
You should now have a contracts/, scripts/, test/ folder.
1️⃣ Data Collection & Pre‑processing
We’ll pull daily OHLCV data for ETH‑USDT from Yahoo Finance (via yfinance) and create a CSV that the model can consume.
data/collect.py
# data/collect.py
import yfinance as yf
import pandas as pd
from datetime import datetime, timedelta
# Pull the last 3 years of daily data
ticker = "ETH-USD"
end = datetime.utcnow()
start = end - timedelta(days=3*365)
df = yf.download(ticker, start=start, end=end, interval="1d")
df = df[['Open', 'High', 'Low', 'Close', 'Volume']]
# Simple feature engineering: use close price and pct change
df['Pct_Change'] = df['Close'].pct_change()
df = df.dropna()
# Save for later
df.to_csv('data/eth_daily.csv')
print(f"Saved {len(df)} rows to data/eth_daily.csv")
python data/collect.py
You should now have data/eth_daily.csv.
2️⃣ Model Training & Export
We’ll train a tiny LSTM that looks at the past 7 days and predicts the next day’s close price.
model/train.py
# model/train.py
import pandas as pd
import numpy as np
import tensorflow as tf
from tensorflow.keras import layers, callbacks, models
from sklearn.preprocessing import MinMaxScaler
import joblib
import os
DATA_PATH = "../data/eth_daily.csv"
MODEL_PATH = "price_lstm.h5"
SCALER_PATH = "scaler.save"
# 1️⃣ Load & scale
df = pd.read_csv(DATA_PATH, parse_dates=['Date'])
close = df['Close'].values.reshape(-1, 1)
scaler = MinMaxScaler()
scaled = scaler.fit_transform(close)
# 2️⃣ Build sequences (7‑day look‑back)
look_back = 7
X, y = [], []
for i in range(len(scaled) - look_back):
X.append(scaled[i:i+look_back])
y.append(scaled[i+look_back])
X, y = np.array(X), np.array(y)
# 3️⃣ Train / validation split
split = int(0.85 * len(X))
X_train, X_val = X[:split], X[split:]
y_train, y_val = y[:split], y[split:]
# 4️⃣ Model definition
model = models.Sequential([
layers.LSTM(32, input_shape=(look_back, 1)),
layers.Dense(1)
])
model.compile(optimizer='adam', loss='mse')
model.summary()
# 5️⃣ Callbacks
es = callbacks.EarlyStopping(patience=10, restore_best_weights=True)
# 6️⃣ Train
model.fit(
X_train, y_train,
validation_data=(X_val, y_val),
epochs=200,
batch_size=16,
callbacks=[es],
verbose=2
)
# 7️⃣ Save model & scaler
os.makedirs('model', exist_ok=True)
model.save('model/' + MODEL_PATH)
joblib.dump(scaler, 'model/' + SCALER_PATH)
print(f"✅ Model saved to model/{MODEL_PATH}")
print(f"✅ Scaler saved to model/{SCALER_PATH}")
python model/train.py
You should now have:
model/price_lstm.h5
model/scaler.save
Why MinMax? The LSTM works best on data scaled to [0,1] and we’ll invert the scaling before returning the price.
3️⃣ FastAPI Inference Server
This service loads the model once on start‑up and answers HTTP POST /predict with the next‑day price.
Project layout
api/
├─ main.py
├─ requirements.txt
└─ model/ ← copy model files here (price_lstm.h5, scaler.save)
api/requirements.txt
fastapi
uvicorn[standard]
tensorflow==2.16.1
python-dotenv
joblib
numpy
api/main.py
# api/main.py
import os
import joblib
import numpy as np
import tensorflow as tf
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
from dotenv import load_dotenv
load_dotenv() # optional: load PORT, LOG_LEVEL, etc.
app = FastAPI(title="Crypto AI Oracle API", version="0.1.0")
# -------------------------------------------------
# 1️⃣ Load model & scaler once (global)
MODEL_PATH = os.path.join(os.path.dirname(__file__), "model/price_lstm.h5")
SCALER_PATH = os.path.join(os.path.dirname(__file__), "model/scaler.save")
try:
model = tf.keras.models.load_model(MODEL_PATH)
scaler = joblib.load(SCALER_PATH)
except Exception as exc:
raise RuntimeError(f"Failed to load model/scaler: {exc}")
# -------------------------------------------------
# 2️⃣ Request schema
class PredictRequest(BaseModel):
# Last 7 close prices (float) – order oldest → newest
recent_closes: List[float]
# -------------------------------------------------
# 3️⃣ Helper: turn raw closes → model input
def create_input(closes: List[float]) -> np.ndarray:
if len(closes) != 7:
raise ValueError("Exactly 7 recent close prices required")
arr = np.array(closes).reshape(-1, 1)
scaled = scaler.transform(arr) # shape (7,1)
return scaled.reshape(1, 7, 1) # (batch, time, features)
# -------------------------------------------------
# 4️⃣ Endpoint
@app.post("/predict")
def predict(req: PredictRequest):
try:
x = create_input(req.recent_closes) # (1,7,1)
y_hat_scaled = model.predict(x) # (1,1)
# Inverse scaling
y_hat = scaler.inverse_transform(y_hat_scaled)
pred_price = float(y_hat[0][0])
return {"prediction": pred_price}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
Run locally
cd api
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8000
Test with curl:
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"recent_closes":[1850,1865,1840,1855,1860,1870,1885]}'
You should receive JSON like:
{"prediction": 1892.34}
4️⃣ Chainlink External Adapter (Node.js)
Chainlink nodes call external adapters (HTTP services) to fetch off‑chain data.
Our adapter will be a thin wrapper around the FastAPI endpoint, adding Chainlink‑specific response formatting.
Folder layout
adapter/
├─ index.js
├─ package.json
└─ .env
adapter/package.json
{
"name": "crypto-ai-adapter",
"version": "1.0.0",
"description": "Chainlink EA for price prediction",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"axios": "^1.7.2",
"dotenv": "^16.4.5",
"express": "^4.19.2"
}
}
cd adapter
npm i
.env
API_URL=http://host.docker.internal:8000/predict # points to FastAPI container
PORT=8080
Note:
host.docker.internalworks when the adapter runs in Docker on the same host as the FastAPI container. If you run everything locally without Docker, replace withhttp://localhost:8000/predict.
adapter/index.js
// adapter/index.js
import express from "express";
import axios from "axios";
import dotenv from "dotenv";
dotenv.config();
const app = express();
app.use(express.json());
const API_URL = process.env.API_URL || "http://localhost:8000/predict";
const PORT = process.env.PORT || 8080;
/**
* Chainlink expects a response shape:
* {
* jobRunID: string,
* data: { result: any },
* result: any,
* statusCode: number
* }
*/
app.post("/", async (req, res) => {
const { id, data } = req.body; // `id` is the jobRunID
const recentCloses = data?.recent_closes; // Array of 7 numbers
if (!Array.isArray(recentCloses) || recentCloses.length !== 7) {
return res.status(400).json({
jobRunID: id,
statusCode: 400,
error: "recent_closes must be an array of 7 numbers",
});
}
try {
const response = await axios.post(API_URL, { recent_closes: recentCloses });
const prediction = response.data.prediction;
return res.json({
jobRunID: id,
data: { result: prediction },
result: prediction,
statusCode: 200,
});
} catch (e) {
console.error("Adapter error:", e.message);
return res.status(500).json({
jobRunID: id,
statusCode: 500,
error: e.message,
});
}
});
app.listen(PORT, () => console.log(`🔗 EA listening on ${PORT}`));
Run the adapter
npm run start
You should see 🔗 EA listening on 8080.
If you hit it manually:
curl -X POST http://localhost:8080/ \
-H "Content-Type: application/json" \
-d '{"id":"test123","data":{"recent_closes":[1850,1865,1840,1855,1860,1870,1885]}}'
You’ll get a Chainlink‑shaped JSON back.
5️⃣ Solidity Oracle Contract
The contract will:
- Request a prediction from a Chainlink node (via an oracle contract).
-
Receive the result in
fulfillOracleRequest. -
Store the price in a public variable
lastPrediction.
contracts/CryptoAIOracle.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";
import "@chainlink/contracts/src/v0.8/ChainlinkClient.sol";
/**
* @title CryptoAIOracle
* @notice Demonstrates a Chainlink request that calls an external
* adapter which in turn queries a TensorFlow model.
*/
contract CryptoAIOracle is ChainlinkClient {
using Chainlink for Chainlink.Request;
// --- State ----------------------------------------------------
uint256 public lastPrediction;
address public oracle;
bytes32 public jobId;
uint256 public fee; // in LINK (scaled by 10**18)
// --- Events ---------------------------------------------------
event PredictionRequested(bytes32 indexed requestId);
event PredictionFulfilled(bytes32 indexed requestId, uint256 price);
// --- Constructor -----------------------------------------------
constructor(
address _link,
address _oracle,
bytes32 _jobId,
uint256 _fee
) {
setChainlinkToken(_link);
oracle = _oracle;
jobId = _jobId;
fee = _fee; // e.g., 0.1 * 10**18 LINK
}
/**
* @notice Initiates a request to the external adapter.
* @param recentCloses An array of 7 ETH close prices (uint256 with 8 decimals).
* The front‑end should convert e.g., 1850.1234 → 185012340000.
*/
function requestPrediction(uint256[7] calldata recentCloses) external returns (bytes32) {
Chainlink.Request memory req = buildChainlinkRequest(jobId, address(this), this.fulfill.selector);
// Convert the uint256 array to a JSON array of numbers.
// Chainlink's `addUintArray` does the conversion automatically.
req.addUintArray("recent_closes", recentCloses);
// Send the request
bytes32 requestId = sendChainlinkRequestTo(oracle, req, fee);
emit PredictionRequested(requestId);
return requestId;
}
/**
* @notice Called by the oracle when the data is ready.
* @param _requestId The request identifier.
* @param _price The predicted price, scaled by 1e8 (same as input).
*/
function fulfill(bytes32 _requestId, uint256 _price) public recordChainlinkFulfillment(_requestId) {
lastPrediction = _price;
emit PredictionFulfilled(_requestId, _price);
}
// ----------------------------------------------------------------
// Helper: withdraw LINK from the contract (owner only in production)
function withdrawLink(address to, uint256 amount) external {
require(LinkTokenInterface(chainlinkTokenAddress()).transfer(to, amount), "LINK transfer failed");
}
}
5️⃣ Deploy the contract with Hardhat
5.1 Add environment variables
Create a .env in the project root:
dotenv
# .env
SEPOLIA_RPC_URL=https://sepolia.infura.io/v
#coding #tutorial #web3 #AI
Top comments (0)