What You'll Build
A P2P Subjective Betting Platform (TruthStake) where:
- Users create bets on real-world events ("Will it rain in Abuja on Friday?")
- An Intelligent Contract fetches live web data, calls an LLM, and settles the bet trustlessly
- A React frontend lets users create, accept, and view bets via
genlayer-js
No oracles. No judges. Just on-chain reasoning.
Prerequisites
| Requirement | Version |
|---|---|
| Node.js | 18+ |
| Python | 3.11+ |
| Docker | Latest |
| Git | Any |
Basic knowledge of: Python, JavaScript/React, and blockchain fundamentals (what a wallet/transaction is).
Part 1 — What Is GenLayer? (The 90-Second Version)
GenLayer is the first AI-native blockchain. Its killer feature is Intelligent Contracts: Python smart contracts that can:
- Call LLMs (GPT-4o, Claude, Gemini, etc.) on-chain
- Fetch and read live web pages
- Reach consensus on non-deterministic outputs via Optimistic Democracy + the Equivalence Principle
Traditional Smart Contract: 2 + 2 = 4 ✅ (deterministic)
Intelligent Contract: "Did it rain in Lagos?" → LLM+Web → YES ✅ (non-deterministic, but consensual)
Three core concepts to internalize before coding:
Intelligent Contracts
- Written in Python, inherit from
IContract - Executed inside GenVM (WASM-based virtual machine)
- Run across a distributed validator set — each validator independently executes LLM calls and web fetches
Optimistic Democracy Consensus
1. Leader validator executes contract → proposes result
2. Other validators execute same logic (potentially with different LLMs)
3. Validators compare results using Equivalence Principle
4. Supermajority agrees → result written on-chain
5. Minority can appeal → escalated validator round
Equivalence Principle
The key insight: two LLM outputs can be semantically identical but textually different. GenLayer lets you define what "equivalent" means:
def is_acceptable(self, output: str, reference: str) -> bool:
# Are both outputs saying "YES"? Then they're equivalent.
return ("yes" in output.lower()) == ("yes" in reference.lower())
Docs: https://docs.genlayer.com/
Part 2 — Environment Setup
Step 1: Clone the Boilerplate
git clone https://github.com/genlayerlabs/genlayer-project-boilerplate
cd genlayer-project-boilerplate
Step 2: Install Dependencies
npm install
Step 3: Start GenLayer Studio
npm run start
Navigate to http://localhost:8080.
[Screenshot: GenLayer Studio homepage]
GenLayer Studio is a browser-based IDE that includes:
- Contract editor with syntax highlighting
- Local multi-validator simulator
- Transaction/state explorer
- One-click testnet deployment
⚠️ First run tip: Docker images download on first start. Give it 3–5 minutes before assuming something is broken.
Step 4: Configure Your Wallet
In Studio, generate or import a test wallet. Save the private key — you'll need it for the frontend .env.
⚠️ Never use a mainnet wallet for development. Always use a throwaway test wallet.
Part 3 — Writing the Intelligent Contract
Create contracts/BetSettler.py:
# contracts/BetSettler.py
from genlayer import *
from genlayer.py.types import Address
class BetSettler(IContract):
"""
TruthStake — P2P Subjective Betting Platform
Bets on real-world events settled by LLM reasoning + live web data.
"""
def __init__(self):
self.bets: DynArray[dict] = DynArray()
self.bet_count: u256 = 0
# ── Write Functions (state-changing) ──────────────────────────────
@gl.public.write
def create_bet(
self,
question: str,
yes_creator: Address,
settlement_url: str,
stake_amount: u256
) -> u256:
"""Create a new bet. Returns the new bet's ID."""
bet = {
"id": self.bet_count,
"question": question,
"yes_address": yes_creator,
"no_address": None,
"settlement_url": settlement_url,
"stake": stake_amount,
"settled": False,
"outcome": None,
}
self.bets.append(bet)
self.bet_count += 1
return self.bet_count - 1
@gl.public.write
def accept_bet(self, bet_id: u256, taker: Address) -> bool:
"""Accept an open bet from the NO side."""
bet = self.bets[bet_id]
assert bet["no_address"] is None, "Bet already has a taker"
assert not bet["settled"], "Bet is already settled"
self.bets[bet_id]["no_address"] = taker
return True
@gl.public.write
async def settle_bet(self, bet_id: u256) -> str:
"""
Settlement engine:
1. Fetch live web data from the settlement URL
2. Ask LLM for a YES/NO verdict
3. Store and return the outcome
Consensus is reached via is_acceptable() across validators.
"""
bet = self.bets[bet_id]
# Guard clauses
assert not bet["settled"], "Bet already settled"
assert bet["no_address"] is not None, "Bet not yet accepted by a taker"
# Step 1: Fetch live context
web_context = await gl.get_webpage(bet["settlement_url"])
# Step 2: LLM reasoning
prompt = f"""You are a neutral fact-checker. Using ONLY the web content below,
answer the question with exactly one word: YES or NO.
Question: {bet["question"]}
Web Content (first 3000 chars):
{web_context[:3000]}
Your answer (YES or NO):"""
raw_result = await gl.exec_prompt(prompt)
outcome = "YES" if "YES" in raw_result.strip().upper() else "NO"
# Step 3: Persist
self.bets[bet_id]["settled"] = True
self.bets[bet_id]["outcome"] = outcome
return outcome
# ── View Functions (read-only, free) ─────────────────────────────
@gl.public.view
def get_bet(self, bet_id: u256) -> dict:
return dict(self.bets[bet_id])
@gl.public.view
def get_all_bets(self) -> list:
return [dict(b) for b in self.bets]
@gl.public.view
def get_bet_count(self) -> u256:
return self.bet_count
# ── Equivalence Principle ─────────────────────────────────────────
def is_acceptable(self, output: str, reference: str) -> bool:
"""
Called by GenLayer validators during Optimistic Democracy consensus.
Two settlement results are equivalent if they agree on YES or NO,
regardless of surrounding text or phrasing differences.
IMPORTANT: This function must be deterministic.
Do NOT call gl.exec_prompt() or gl.get_webpage() here.
"""
def extract_verdict(text: str) -> str:
t = text.strip().upper()
if "YES" in t:
return "YES"
if "NO" in t:
return "NO"
return "UNDECIDED"
return extract_verdict(output) == extract_verdict(reference)
Contract Anatomy — Quick Reference
| Decorator | Gas Cost | Purpose |
|---|---|---|
@gl.public.write |
Costs gas | Modifies state, creates a transaction |
@gl.public.view |
Free | Read-only, no transaction |
| Built-in | What It Does |
|---|---|
gl.get_webpage(url) |
Fetches live web content (sandboxed, no auth) |
gl.exec_prompt(prompt) |
Calls the validator's connected LLM |
DynArray[T] |
GenLayer's dynamic array type |
u256 |
Unsigned 256-bit integer (like Solidity's uint256) |
Part 4 — Testing in GenLayer Studio
Local Testing
- Open
BetSettler.pyin Studio's editor - Click "Compile" — check for syntax errors in the output panel
- Click "Deploy (Local)" — deploys to the local multi-validator simulator
- Use the "Interact" panel to call functions manually:
Function: create_bet
Args:
question: "Will it rain in Abuja on March 20, 2026?"
yes_creator: 0xYourTestAddress
settlement_url: "https://weather.com/en-NG/weather/today/Abuja"
stake_amount: 100
- Call
settle_bet(0)and watch the validator simulation panel — you'll see each validator independently execute the contract and compare outputs viais_acceptable().
[Screenshot: Studio validator simulation showing equivalence checks]
Common Test Failures & Fixes
AssertionError: Bet not yet accepted
→ Call accept_bet(0, 0xAnotherAddress) before calling settle_bet(0).
gl.get_webpage() timeout
→ The local simulator's web access is sandboxed. Try a different URL or check your Docker network settings.
is_acceptable() always returns False
→ Make sure your verdict extraction is case-insensitive and handles extra whitespace/punctuation from LLM outputs.
DynArray index out of range
→ You're calling get_bet(id) with an ID that doesn't exist yet. Always call create_bet() first.
Part 5 — Building the React Frontend
# From the project root
cd frontend
npm install @genlayer/genlayer-js
Project Structure
frontend/
├── src/
│ ├── lib/
│ │ └── genLayer.js ← SDK client setup
│ ├── hooks/
│ │ └── useBets.js ← Contract interaction logic
│ ├── components/
│ │ ├── BetList.jsx ← Display all bets
│ │ ├── CreateBetForm.jsx ← Create a new bet
│ │ └── BetCard.jsx ← Individual bet display
│ ├── constants.js ← Contract address, network config
│ └── App.jsx
├── .env ← NEVER commit this
└── vite.config.js
SDK Client — src/lib/genLayer.js
import { createClient } from '@genlayer/genlayer-js';
export const client = createClient({
network: import.meta.env.VITE_NETWORK || 'localnet',
privateKey: import.meta.env.VITE_PRIVATE_KEY,
});
export const CONTRACT_ADDRESS = import.meta.env.VITE_CONTRACT_ADDRESS;
Contract Hooks — src/hooks/useBets.js
import { client, CONTRACT_ADDRESS } from '../lib/genLayer';
/**
* Read all bets from the contract (free, no gas).
*/
export async function getAllBets() {
return await client.readContract({
address: CONTRACT_ADDRESS,
functionName: 'get_all_bets',
args: [],
});
}
/**
* Create a new bet. Returns transaction hash.
* Note: Takes ~30s on testnet (consensus time).
*/
export async function createBet({ question, settlementUrl, stakeAmount }) {
const txHash = await client.writeContract({
address: CONTRACT_ADDRESS,
functionName: 'create_bet',
args: [question, client.account.address, settlementUrl, stakeAmount],
});
// Poll until Optimistic Democracy consensus is reached
await client.waitForTransactionReceipt({ hash: txHash });
return txHash;
}
/**
* Accept an existing bet from the NO side.
*/
export async function acceptBet(betId) {
const txHash = await client.writeContract({
address: CONTRACT_ADDRESS,
functionName: 'accept_bet',
args: [betId, client.account.address],
});
await client.waitForTransactionReceipt({ hash: txHash });
return txHash;
}
/**
* Trigger bet settlement. Contract will fetch web data + call LLM.
* This is the most gas-intensive operation.
*/
export async function settleBet(betId) {
const txHash = await client.writeContract({
address: CONTRACT_ADDRESS,
functionName: 'settle_bet',
args: [betId],
});
await client.waitForTransactionReceipt({ hash: txHash });
return txHash;
}
Create Bet Form — src/components/CreateBetForm.jsx
import { useState } from 'react';
import { createBet } from '../hooks/useBets';
export default function CreateBetForm({ onSuccess }) {
const [form, setForm] = useState({
question: '',
settlementUrl: '',
stakeAmount: 100,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async () => {
setLoading(true);
setError(null);
try {
await createBet(form);
onSuccess?.();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="create-bet-form">
<h2>Create a New Bet</h2>
<label>
Question (e.g. "Will it rain in Abuja on Friday?")
<input
type="text"
value={form.question}
onChange={(e) => setForm({ ...form, question: e.target.value })}
placeholder="Will Nigeria qualify for the World Cup?"
/>
</label>
<label>
Settlement URL (web page the contract will read to settle)
<input
type="url"
value={form.settlementUrl}
onChange={(e) => setForm({ ...form, settlementUrl: e.target.value })}
placeholder="https://weather.com/Abuja"
/>
</label>
<label>
Stake Amount (tokens)
<input
type="number"
value={form.stakeAmount}
onChange={(e) => setForm({ ...form, stakeAmount: Number(e.target.value) })}
/>
</label>
{error && <p className="error">Error: {error}</p>}
<button onClick={handleSubmit} disabled={loading}>
{loading ? 'Creating bet (waiting for consensus...)' : 'Create Bet'}
</button>
<p className="hint">
⏳ Transactions take ~15–30 seconds — Optimistic Democracy consensus in progress.
</p>
</div>
);
}
Bet List — src/components/BetList.jsx
import { useEffect, useState } from 'react';
import { getAllBets, settleBet } from '../hooks/useBets';
export default function BetList() {
const [bets, setBets] = useState([]);
const [settling, setSettling] = useState(null);
const refresh = () => getAllBets().then(setBets);
useEffect(() => { refresh(); }, []);
const handleSettle = async (betId) => {
setSettling(betId);
try {
await settleBet(betId);
await refresh();
} finally {
setSettling(null);
}
};
return (
<div className="bet-list">
{bets.length === 0 && <p>No bets yet. Create the first one!</p>}
{bets.map((bet) => (
<div key={bet.id} className={`bet-card ${bet.settled ? 'settled' : 'open'}`}>
<h3>{bet.question}</h3>
<p className="status">
{bet.settled
? `✅ Settled: ${bet.outcome}`
: bet.no_address
? '🤝 Matched — Awaiting Settlement'
: '🔓 Open — Waiting for Taker'}
</p>
<p>Stake: {bet.stake} tokens</p>
{!bet.settled && bet.no_address && (
<button
onClick={() => handleSettle(bet.id)}
disabled={settling === bet.id}
>
{settling === bet.id ? 'Settling...' : 'Settle Bet'}
</button>
)}
</div>
))}
<button onClick={refresh}>🔄 Refresh</button>
</div>
);
}
[Screenshot: TruthStake frontend showing bet list with settled outcomes]
Part 6 — Deploying to Testnet
Step 1: Deploy Contract via Studio
- Open
BetSettler.pyin Studio - Click "Deploy" → select "GenLayer Testnet"
- Confirm the deployment
- Copy the contract address from the Deployments panel
[Screenshot: Studio deployment panel showing contract address]
Step 2: Configure Environment Variables
# frontend/.env
VITE_NETWORK=testnet
VITE_CONTRACT_ADDRESS=0xPasteYourContractAddressHere
VITE_PRIVATE_KEY=your_test_wallet_private_key
⚠️ Add
.envto.gitignorebefore your first commit:echo ".env" >> .gitignore
Step 3: Get Testnet Tokens
Use the faucet linked inside GenLayer Studio to get test tokens for gas. You'll need them for create_bet(), accept_bet(), and settle_bet() transactions.
Step 4: Deploy Frontend
cd frontend
npm run build
# Deploy dist/ to Vercel, Netlify, or any static host
# Vercel (recommended):
npx vercel --prod
⚠️ Vercel environment variables: Set
VITE_NETWORK,VITE_CONTRACT_ADDRESS, andVITE_PRIVATE_KEYin the Vercel dashboard under Settings → Environment Variables. Do not rely on your local.envfor production deploys.
Part 7 — Optimistic Democracy: Under the Hood
Here's the full consensus flow for a settle_bet() call:
User calls settle_bet(0) on frontend
│
▼
[GenLayer Network]
│
┌──────┴──────────────────────────────────┐
│ Leader Validator │
│ 1. gl.get_webpage(settlement_url) │
│ 2. gl.exec_prompt(prompt) → "YES" │
│ 3. Proposes result: "YES" │
└──────┬──────────────────────────────────┘
│
[Broadcast to validator set]
│
┌──────┴──────┬─────────────┬─────────────┐
│ Validator B │ Validator C │ Validator D │
│ GPT-4o │ Claude │ Gemini │
│ → "YES" │ → "Yes." │ → "Affirm." │
└──────┬───────┴──────┬──────┴──────┬───────┘
│ │ │
[Apply is_acceptable() pairwise vs leader's "YES"]
│ │ │
TRUE TRUE TRUE
│
[Supermajority: 3/3 equivalent → CONSENSUS]
│
[Result "YES" written on-chain → bet settled]
If one validator returns "NO" and the others return "YES":
-
is_acceptable("NO", "YES")→False - Minority validator can trigger an appeal
- Appeals use a larger, randomly-sampled validator set
- Final decision after appeal is binding
Part 8 — Advanced: Customizing the Equivalence Principle
The is_acceptable() function is powerful. Here are a few real-world patterns:
Pattern 1: Numeric Range Tolerance
def is_acceptable(self, output: str, reference: str) -> bool:
"""For bets like 'What will BTC price be?' — allow ±2% tolerance."""
import re
def extract_number(text):
matches = re.findall(r'\d+\.?\d*', text)
return float(matches[0]) if matches else None
out_val = extract_number(output)
ref_val = extract_number(reference)
if out_val is None or ref_val is None:
return False
return abs(out_val - ref_val) / ref_val <= 0.02 # 2% tolerance
Pattern 2: Multi-Class Classification
def is_acceptable(self, output: str, reference: str) -> bool:
"""For sentiment classification: positive/negative/neutral."""
CLASSES = ["positive", "negative", "neutral"]
def extract_class(text):
t = text.lower()
for cls in CLASSES:
if cls in t:
return cls
return None
return extract_class(output) == extract_class(reference)
Pattern 3: JSON Structure Matching
def is_acceptable(self, output: str, reference: str) -> bool:
"""For structured data outputs — check key fields match."""
import json
try:
out_data = json.loads(output)
ref_data = json.loads(reference)
# Only compare the "verdict" key, ignore "reasoning"
return out_data.get("verdict") == ref_data.get("verdict")
except (json.JSONDecodeError, AttributeError):
return output.strip() == reference.strip()
⚠️ Critical rule:
is_acceptable()must be pure and deterministic. No LLM calls, no web fetches, no random numbers. It's executed by all validators locally during consensus — it must always produce the same result for the same inputs.
Troubleshooting Reference
| Problem | Likely Cause | Fix |
|---|---|---|
| Studio won't start | Docker not running | Start Docker Desktop |
gl.get_webpage() returns empty |
URL is behind auth/JS | Use a public, server-rendered URL |
| Transaction never confirms | Testnet congestion | Wait longer; check Studio logs |
is_acceptable() causes split consensus |
Logic too strict or undefined edge case | Add more fallback cases |
| Frontend can't connect | Wrong network in .env
|
Double-check VITE_NETWORK value |
| Gas estimation fails | Contract has an unhandled assertion | Check all assert conditions before calling |
What's Next
You now have a working Intelligent Contract dApp. Here's where to take it:
- Add staking logic: Use GenLayer's native token primitives to actually hold and transfer funds on settlement
- Multi-event bets: Allow multiple YES/NO sub-questions with weighted outcomes
- DAO governance: Let token holders vote on which settlement URLs are trusted sources
- AI agents as participants: Build autonomous agents that create and accept bets programmatically
Resources:
- 📖 Official Docs: https://docs.genlayer.com/
- 🚀 Boilerplate: https://github.com/genlayerlabs/genlayer-project-boilerplate
Tags: #blockchain #python #web3 #ai #tutorial #genlayer #smartcontracts #buildinpublic
Top comments (0)