DEV Community

Cover image for From Zero to GenLayer: Hands-On Tutorial to Build an Intelligent Contract dApp
Drk-codey
Drk-codey

Posted on

From Zero to GenLayer: Hands-On Tutorial to Build an Intelligent Contract dApp

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:

  1. Call LLMs (GPT-4o, Claude, Gemini, etc.) on-chain
  2. Fetch and read live web pages
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Dependencies

npm install
Enter fullscreen mode Exit fullscreen mode

Step 3: Start GenLayer Studio

npm run start
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

  1. Open BetSettler.py in Studio's editor
  2. Click "Compile" — check for syntax errors in the output panel
  3. Click "Deploy (Local)" — deploys to the local multi-validator simulator
  4. 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
Enter fullscreen mode Exit fullscreen mode
  1. Call settle_bet(0) and watch the validator simulation panel — you'll see each validator independently execute the contract and compare outputs via is_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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

[Screenshot: TruthStake frontend showing bet list with settled outcomes]


Part 6 — Deploying to Testnet

Step 1: Deploy Contract via Studio

  1. Open BetSettler.py in Studio
  2. Click "Deploy" → select "GenLayer Testnet"
  3. Confirm the deployment
  4. 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
Enter fullscreen mode Exit fullscreen mode

⚠️ Add .env to .gitignore before 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
Enter fullscreen mode Exit fullscreen mode

⚠️ Vercel environment variables: Set VITE_NETWORK, VITE_CONTRACT_ADDRESS, and VITE_PRIVATE_KEY in the Vercel dashboard under Settings → Environment Variables. Do not rely on your local .env for 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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

⚠️ 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:


Tags: #blockchain #python #web3 #ai #tutorial #genlayer #smartcontracts #buildinpublic

Top comments (0)