📚 End‑to‑End Tutorial
Build a “Crypto‑AI Inference Marketplace” – a tiny dApp where users can pay a small amount of Ether (or any ERC‑20 token) to get a prediction from a machine‑learning model (sentiment analysis on a short text).
The tutorial covers everything you need to spin the project up locally, test it, and finally deploy it to a public testnet (Sepolia).
What you’ll learn
- Setting up a Solidity smart‑contract that holds a price and records requests.
- Training a lightweight PyTorch model and exporting it as a TorchScript file.
- Building a Node.js/Express “oracle” server that (a) receives payments, (b) calls the AI model, and (c) returns the result on‑chain via an event.
- Writing a simple React front‑end that talks to MetaMask and the oracle.
- Deploying the contract with Hardhat, publishing the oracle to a cloud VM (or Railway/Render), and wiring everything together.
📋 Table of Contents
| # | Section | What you’ll get |
|---|---|---|
| 1 | Prerequisites | Tools, accounts, basic knowledge |
| 2 | Project Layout | Directory tree & purpose of each folder |
| 3 | Train & Export the AI Model | Python script, TorchScript, test inference |
| 4 | Write the Solidity Contract | Pricing, request tracking, event emission |
| 5 | Hardhat Setup & Deployment | Compilation, test, Sepolia deployment |
| 6 | Oracle Server (Node.js) | Express API, Web3 interaction, model inference |
| 7 | Front‑End (React + ethers.js) | UI, MetaMask integration, request flow |
| 8 | End‑to‑End Testing | Local testnet, Sepolia test, troubleshooting |
| 9 | Production‑Ready Deployment | Cloud VM, Docker, environment variables, SSL |
| 10 | Next Steps & Security Checklist | Scaling, Oracles, DAO governance, audit pointers |
1️⃣ Prerequisites
| Category | Minimum | Recommended |
|---|---|---|
| Programming | JavaScript/TypeScript basics, Python basics | Familiarity with async/await and Solidity |
| Blockchain | MetaMask, some test‑ETH on Sepolia (use faucet) | Understanding of ERC‑20, events, gas |
| AI/ML | Python 3.9+, pip, basic PyTorch | GPU optional (model is tiny) |
| Dev Tools | - Node 20 (or 18 LTS) - npm or Yarn - Hardhat ( npm i -D hardhat) - Python 3.9+ - Git |
Docker (for production) |
| Accounts | - GitHub (optional) - Infura/Alchemy project (Sepolia RPC) - Cloud provider (Railway, Render, or any VPS) |
- Cloudflare DNS (optional) |
| OS | macOS / Linux / WSL2 (Windows) | Any |
Quick Install (macOS / Linux)
# Node & npm
brew install node # macOS (or apt-get install nodejs npm)
# Hardhat (global optional)
npm i -g hardhat
# Python & venv
python3 -m venv .venv && source .venv/bin/activate
pip install torch torchvision torchaudio tqdm
# Git
brew install git
Make sure you have MetaMask installed in your browser and set the network to Sepolia.
2️⃣ Project Layout
crypto‑ai‑marketplace/
├─ contracts/ # Solidity contracts
│ └─ InferenceMarketplace.sol
├─ scripts/ # Hardhat deployment scripts
│ └─ deploy.js
├─ test/ # Hardhat tests (JS/TS)
│ └─ InferenceMarketplace.test.js
├─ ai/ # Python AI code
│ ├─ train.py
│ ├─ model.pt # exported TorchScript model (generated)
│ └─ requirements.txt
├─ server/ # Node.js oracle
│ ├─ index.js
│ ├─ package.json
│ └─ .env # secrets (Infura key, contract address, etc.)
├─ client/ # React front‑end
│ ├─ src/
│ │ ├─ App.jsx
│ │ └─ components/
│ └─ package.json
├─ hardhat.config.js
└─ README.md
We’ll fill each folder step‑by‑step.
3️⃣ Train & Export the AI Model
3.1 Choose a tiny model
We’ll use DistilBERT (a 66 M parameter transformer) fine‑tuned for binary sentiment classification. The model is small enough to run on a CPU in < 50 ms.
3.2 Install Python deps
ai/requirements.txt
torch==2.2.0
transformers==4.38.0
datasets==2.16.0
tqdm==4.66.2
cd ai
pip install -r requirements.txt
3.3 Training script
ai/train.py
import torch
from torch import nn
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from datasets import load_dataset
from tqdm.auto import tqdm
MODEL_NAME = "distilbert-base-uncased"
DATASET = "imdb" # binary sentiment dataset
EPOCHS = 1 # for demo; increase for real accuracy
BATCH_SIZE = 16
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
def main():
# 1️⃣ Load dataset (train/validation split)
raw = load_dataset("imdb")
train_ds = raw["train"].shuffle(seed=42).select(range(2000)) # tiny subset
val_ds = raw["test"].shuffle(seed=42).select(range(500))
# 2️⃣ Tokenizer
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
def tokenize(batch):
return tokenizer(batch["text"], padding="max_length", truncation=True, max_length=128)
train_enc = train_ds.map(tokenize, batched=True, batch_size=BATCH_SIZE)
val_enc = val_ds.map(tokenize, batched=True, batch_size=BATCH_SIZE)
# 3️⃣ Convert to torch tensors
columns = ["input_ids", "attention_mask", "label"]
train_enc.set_format(type="torch", columns=columns)
val_enc.set_format(type="torch", columns=columns)
# 4️⃣ Model
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=2)
model.to(DEVICE)
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5)
loss_fn = nn.CrossEntropyLoss()
# 5️⃣ Training loop
model.train()
for epoch in range(EPOCHS):
prog = tqdm(train_enc, total=len(train_enc), desc=f"Epoch {epoch+1}")
for batch in prog:
optimizer.zero_grad()
input_ids = batch["input_ids"].to(DEVICE)
attn_mask = batch["attention_mask"].to(DEVICE)
labels = batch["label"].to(DEVICE)
outputs = model(input_ids, attention_mask=attn_mask, labels=labels)
loss = outputs.loss
loss.backward()
optimizer.step()
prog.set_postfix(loss=loss.item())
# 6️⃣ Save as TorchScript (self‑contained, no Python needed)
model.eval()
example = {
"input_ids": torch.randint(0, 1000, (1, 128), dtype=torch.long).to(DEVICE),
"attention_mask": torch.ones((1, 128), dtype=torch.long).to(DEVICE)
}
scripted = torch.jit.trace(
lambda **kwargs: model(**kwargs).logits,
example_kwarg_inputs=example
)
scripted.save("model.pt")
print("✅ Model exported to model.pt")
if __name__ == "__main__":
main()
Why TorchScript?
The Node.js oracle will load the model withtorchscript-node(a thin binding) or viaonnxruntime-node. TorchScript produces a single binary file that can be loaded without Python, making the inference server lightweight.
3.4 Run training (once)
cd ai
python train.py
You’ll get model.pt (≈ 250 MB). Commit it to the repo (or store on IPFS if you want a truly decentralized model). For the tutorial we keep it local.
3.5 Quick inference test
import torch
model = torch.jit.load("model.pt")
model.eval()
def predict(text: str):
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
enc = tokenizer(text, truncation=True, padding="max_length", max_length=128, return_tensors="pt")
with torch.no_grad():
logits = model(**enc)
prob = torch.softmax(logits, dim=-1).squeeze()
label = "POSITIVE" if prob[1] > prob[0] else "NEGATIVE"
return label, prob.tolist()
print(predict("I love crypto!"))
You should see a POSITIVE label with a probability vector.
4️⃣ Solidity Smart Contract
4.1 Core requirements
- price – how much the user pays per inference (in wei).
- request mapping – tracks which address requested which text (hash).
-
event –
InferenceRequested(address indexed user, bytes32 requestId, string text) -
function –
requestInference(string calldata text)payable, checks price, emits event. - owner – can update price.
4.2 Contract code
contracts/InferenceMarketplace.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title Crypto‑AI Inference Marketplace
/// @notice Users pay a fixed price to request a prediction from an off‑chain AI model.
/// @dev The contract only stores request metadata and emits an event. The off‑chain oracle
/// listens for the event, runs inference, then calls `fulfillInference`.
contract InferenceMarketplace {
address public owner;
uint256 public price; // price per inference (in wei)
// requestId => result (0 = pending, 1 = positive, 2 = negative)
mapping(bytes32 => uint8) public results;
event InferenceRequested(address indexed user, bytes32 indexed requestId, string text);
event InferenceFulfilled(address indexed user, bytes32 indexed requestId, uint8 result);
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor(uint256 _price) {
owner = msg.sender;
price = _price;
}
/// @notice User sends `price` wei + text to get a prediction.
function requestInference(string calldata text) external payable returns (bytes32 requestId) {
require(msg.value == price, "Incorrect payment");
requestId = keccak256(abi.encodePacked(msg.sender, block.timestamp, text));
// Store a placeholder (0 = pending)
results[requestId] = 0;
emit InferenceRequested(msg.sender, requestId, text);
}
/// @notice Oracle calls this after computing the result.
/// @param requestId The id emitted in the request event.
/// @param result 1 = POSITIVE, 2 = NEGATIVE
function fulfillInference(bytes32 requestId, uint8 result) external onlyOwner {
require(results[requestId] == 0, "Already fulfilled");
require(result == 1 || result == 2, "Invalid result");
results[requestId] = result;
// Find the original user from the requestId (cannot be derived on‑chain,
// so we emit the user address again for front‑ends to catch)
emit InferenceFulfilled(msg.sender, requestId, result);
}
/// @notice Owner can withdraw accumulated fees.
function withdraw() external onlyOwner {
payable(owner).transfer(address(this).balance);
}
/// @notice Owner can change the price.
function setPrice(uint256 _price) external onlyOwner {
price = _price;
}
}
Why store the result on‑chain?
- Guarantees immutability – the user can verify the oracle did not tamper with the answer.
- Front‑ends can simply read
results[requestId]to display the outcome.
5️⃣ Hardhat Setup & Deployment
5.1 Initialise Hardhat
mkdir crypto-ai-marketplace && cd crypto-ai-marketplace
npm init -y
npm i -D hardhat @nomicfoundation/hardhat-toolbox ethers dotenv
npx hardhat # choose "Create a basic sample project"
Replace the auto‑generated sample contract with InferenceMarketplace.sol.
5.2 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, // e.g., https://sepolia.infura.io/v3/<KEY>
accounts: [process.env.PRIVATE_KEY], // deployer account
},
localhost: {
url: "http://127.0.0.1:8545",
},
},
};
Create a .env file (never commit it):
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/your-infura-id
PRIVATE_KEY=0xYOUR_PRIVATE_KEY # account with Sepolia test ETH
5.3 Deploy script
script/deploy.js
const { ethers } = require("hardhat");
async function main() {
const price = ethers.parseEther("0.001"); // 0.001 ETH per inference
const InferenceMarketplace = await ethers.getContractFactory("InferenceMarketplace");
const contract = await InferenceMarketplace.deploy(price);
await contract.waitForDeployment();
console.log("Contract deployed to:", contract.target);
console.log("Price (wei):", price.toString());
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
Run locally on Hardhat network first:
npx hardhat node # in a separate terminal
npx hardhat run script/deploy.js --network localhost
You’ll see the contract address (e.g., 0xAbc...). Keep it – the oracle and front‑end need it.
5.4 Deploy to Sepolia
npx hardhat run script/deploy.js --network sepolia
Copy the printed address; later we’ll put it in server/.env and client/.env.
5.5 Test (optional)
test/InferenceMarketplace.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("InferenceMarketplace", function () {
let contract, owner, user;
const price = ethers.parseEther("0.001");
beforeEach(async () => {
[owner, user] = await ethers.getSigners();
const Factory = await ethers.getContractFactory("InferenceMarketplace");
contract = await Factory.deploy(price);
await contract.waitForDeployment();
});
it("rejects wrong payment", async () => {
await expect(contract.connect(user).requestInference("test", { value: ethers.parseEther("0.0005") }))
.to.be.revertedWith("Incorrect payment");
});
it("accepts correct payment and emits event", async () => {
await expect(contract.connect(user).requestInference("hello", { value: price }))
.to.emit(contract, "InferenceRequested")
.withArgs(user.address, anyValue, "hello");
});
});
Run:
npx hardhat test
All green → you’re ready for the oracle.
6️⃣ Oracle Server (Node.js)
The oracle does three things:
-
Listen to
InferenceRequestedevents (via ethers.js + WebSocket provider). - Run the TorchScript model on the supplied text.
-
Call
fulfillInferenceon the contract with the result (signed by the owner key).
6.1 Initialise the server
mkdir server && cd server
npm init -y
npm i express ethers dotenv @xenova/transformers # we’ll use @xenova/transformers for on‑node inference (no Python)
npm i -D nodemon
Alternative: Use
torchscript-nodebindings (requires native build). For simplicity and cross‑platform compatibility we’ll use the JavaScript inference library that can load the ONNX version of the model (exported later).
6.2 Export ONNX from PyTorch (once)
Add to ai/train.py after saving model.pt:
python
torch.onnx.export(
model,
(example["input_ids"], example["attention_mask"]),
"model.onnx",
input_names=["input_ids", "attention_mask"],
output_names=["logits"],
dynamic_axes={"input_ids": {0:
#coding #tutorial #web3 #AI
Top comments (0)