DEV Community

Cover image for Build a Fact-Checking dApp That Actually Googles Things (Yes, On-Chain) — Part 1
0xgayyy
0xgayyy

Posted on

Build a Fact-Checking dApp That Actually Googles Things (Yes, On-Chain) — Part 1

Build a Fact-Checking dApp That Actually Googles Things (Yes, On-Chain)

Part 1: Why GenLayer Changes Everything & How to Get Started

Welcome to a 4-part series and a cheatsheet where we build **TruthPost—a decentralized fact-checker powered by AI. No blockchain experience needed. If you can write Python and basic JavaScript, you're ready to build something impossible on any other chain.


Picture this: a smart contract that Googles something for you.

Not through some sketchy oracle. Not through an API you have to trust. The contract itself opens a browser, reads a Wikipedia page, thinks about what it found, and writes the answer directly to the blockchain.

Sounds impossible? That's because it was impossible. Until now.

Welcome to GenLayer—the first blockchain where smart contracts can access the internet, reason with AI, and reach consensus on subjective outputs.

By the end of this series, you'll have built TruthPost: a fact-checking platform where anyone can submit a claim like "The Eiffel Tower is 330 meters tall," and the blockchain will:

  1. Fetch Wikipedia (or any source you specify)
  2. Send the content to an LLM for analysis
  3. Have multiple validators independently verify the AI's verdict
  4. Record the result on-chain—permanently, transparently, trustlessly

We're talking about a complete working dApp: a Python smart contract doing things literally impossible on Ethereum, plus a sleek React frontend with wallet integration.

Let's start with why this is possible at all.


Every Other Blockchain Hits the Same Three Walls

If you've ever built on Ethereum, Solana, or really any blockchain, you know the frustrations:

Wall #1: Smart Contracts Are Blind

They can't see the outside world. No HTTP requests. No reading news articles. No checking stock prices. If they need real-world data, they depend on oracles—expensive, slow, centralized middlemen that defeat the entire point of decentralization.

Wall #2: Smart Contracts Can't Think

They execute deterministic logic: math, if/else statements, state updates. But they can't reason. They can't parse natural language, summarize documents, or decide whether a statement is true. They're calculators, not thinkers.

Wall #3: Every Validator Must Agree Exactly

This is the deepest constraint. Blockchain consensus requires determinism—run the same code, get identical output. But AI models are inherently non-deterministic. Ask GPT the same question twice, you get two different phrasings. Traditional blockchains simply can't handle this.

GenLayer breaks all three walls. Not with workarounds—at the protocol level.


What Actually Is GenLayer?

GenLayer is an AI-native blockchain. But "AI-native" gets thrown around a lot in crypto, so let me be specific.

Smart contracts on GenLayer (called Intelligent Contracts) are Python classes that run inside a custom VM called GenVM. Inside GenVM, your contracts have native access to two things no other blockchain offers:

1. The Internet

Contracts can fetch web pages, call APIs, take screenshots—directly, without oracles.

2. Large Language Models

Contracts can run prompts through AI models and get back structured responses.

And GenLayer has a consensus mechanism specifically designed to handle the fact that AI outputs aren't deterministic.

This isn't a layer-2. This isn't a plugin. This is a new blockchain architecture built from the ground up for AI-powered applications.


The Two Breakthrough Concepts You Need to Understand

Everything in GenLayer rests on two ideas. Master these, and the rest of the tutorial will click into place.

Breakthrough #1: Optimistic Democracy

On Ethereum, consensus works through repetition: every validator runs the same code and checks they all got the same answer. That model breaks the moment you introduce AI—two validators will get two different phrasings from an LLM, and the chain rejects the transaction.

GenLayer replaces this with Optimistic Democracy:

  1. A transaction arrives (e.g., "fact-check this claim")
  2. A Leader validator processes it first—fetching web data, running the AI, producing a result
  3. Other validators independently do the same work—each one fetches, each one runs the AI
  4. Validators compare for equivalent outputs, not identical ones
  5. If a majority agrees the results are equivalent, the transaction is accepted
  6. A finality window opens where anyone can appeal the result
  7. Each appeal brings in more validators (doubling each round), creating escalating security
  8. Once appeals close, the result is finalized and permanent

The breakthrough: GenLayer doesn't demand every validator produces the string "true". It demands every validator agrees the claim is true.

That's the difference between determinism and equivalence—and it's what makes AI on-chain possible.

This is grounded in Condorcet's Jury Theorem—the mathematical principle that a majority of independent decision-makers is more likely to be correct than any individual. More validators = higher confidence.

Breakthrough #2: The Equivalence Principle

If validators aren't checking for identical outputs, what are they checking?

That's where the Equivalence Principle comes in. As a developer, you define what "equivalent" means for your specific use case.

GenLayer gives you three modes:

Type What It Means When to Use
Strict Equality Outputs must be byte-for-byte identical Structured data: yes/no, categories, JSON with fixed keys
Comparative An LLM judges whether two outputs are "close enough" Numeric ranges, similar-but-not-identical text, ratings
Non-Comparative An LLM checks whether the leader's output meets criteria Subjective quality checks, summaries, creative outputs

For TruthPost, we'll use strict equality. Our AI returns a structured verdict—"true", "false", or "partially_true"—and we want every validator to land on the same label. Because the output space is small and constrained, strict equality works perfectly.

Think of it this way:

Traditional consensus asks: "Did everyone compute 42?"

GenLayer consensus asks: "Does everyone agree this claim is false?"

Same security guarantee. Radically more flexibility.


What You Need to Get Started

Before we dive into setup, make sure you have:

  • Python 3.11+—for writing intelligent contracts
  • Node.js 18+—for the frontend and deployment tools
  • Git—for cloning the starter project
  • MetaMask browser extension—for wallet connection
  • GenLayer Studio—the dev environment (we'll set this up next)

You don't need blockchain experience. No Solidity knowledge. No Web3 background. If you're comfortable with Python and TypeScript/JavaScript, you're ready.


Setting Up Your Environment (The Fun Stuff)

Step 1: Get GenLayer Studio Running

GenLayer Studio is your local blockchain. It spins up a network with validators so you can deploy and test contracts.

Option A (Easiest): Use the hosted version

Just go to studio.genlayer.com. No installation. No Docker. No config files. It just works.

Option B: Run locally

Follow the setup guide at docs.genlayer.com if you prefer running your own instance.

Step 2: Install the GenLayer CLI

The CLI handles network selection and contract deployment:

npm install -g genlayer
Enter fullscreen mode Exit fullscreen mode

Verify it's working:

genlayer --version
Enter fullscreen mode Exit fullscreen mode

Step 3: Clone the Boilerplate Project

We're using the official GenLayer starter template. It includes everything: project structure, deployment scripts, and a complete Next.js frontend.

git clone https://github.com/genlayerlabs/genlayer-project-boilerplate.git truthpost
cd truthpost
Enter fullscreen mode Exit fullscreen mode

Install all dependencies:

# Root dependencies (deployment tools)
npm install

# Frontend dependencies
cd frontend
npm install
cd ..

# Python dependencies (for contract testing)
pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Step 4: Choose Your Network

GenLayer has three networks to choose from:

Network What It's For RPC URL
studionet Development with hosted Studio https://studio.genlayer.com/api
localnet Your own local Studio instance http://localhost:8545
testnet (Asimov) Public testnet Provided by GenLayer

For this tutorial, we'll use studionet:

genlayer network
# Select "studionet" from the interactive menu
Enter fullscreen mode Exit fullscreen mode

Step 5: Understand Your Project Structure

Here's what the boilerplate gives you:

truthpost/
├── contracts/           # Python intelligent contracts go here
│   └── football_bets.py # Example contract (we'll replace this)
├── frontend/            # Next.js 15 app
│   ├── app/             # Pages and layouts
│   ├── components/      # React components
│   ├── lib/             # Hooks, utilities, contract interactions
│   └── .env.example     # Environment config template
├── deploy/              # TypeScript deployment script
│   └── deployScript.ts  # Deploys contracts to GenLayer
├── test/                # Python integration tests
├── package.json         # Root dependencies
└── requirements.txt     # Python dependencies
Enter fullscreen mode Exit fullscreen mode

The boilerplate ships with a football betting dApp as an example. Over the next two parts, we'll gut the contract and rebuild it as TruthPost.

Step 6: The Moment of Truth (Sanity Check)

Let's make sure everything's wired up correctly by deploying the example contract:

npm run deploy
Enter fullscreen mode Exit fullscreen mode

If you see this, you're golden:

Deploying contract...
Contract deployed at address: 0x...
Enter fullscreen mode Exit fullscreen mode

Troubleshooting if deployment fails:

  • Make sure GenLayer Studio is running (or you're connected to studionet)
  • Double-check that genlayer network shows the right network
  • Verify npm install completed successfully at the root

The Roadmap: What's Coming Next

Here's the full series at a glance:

Part What You'll Build
Part 1 (you are here) GenLayer fundamentals, environment setup, project walkthrough
Part 2 The TruthPost intelligent contract—web access, LLM calls, consensus magic
Part 3 React frontend with genlayer-js SDK, MetaMask integration, TanStack Query
Part 4 Testing with gltest, testnet deployment, extending the app, what to build next

Key Takeaways (The "You Can Quote Me On This" Section)

Let's lock in what makes GenLayer fundamentally different:

Intelligent Contracts are written in Python—if you know Python, you can build on GenLayer. No Solidity required.

Contracts access the internet natively—no oracles, no off-chain workers, no trusted intermediaries.

Contracts call AI models directly—LLMs are first-class citizens in the execution environment.

Optimistic Democracy enables consensus on non-deterministic outputs—the breakthrough that makes AI on-chain actually possible.

The Equivalence Principle lets you define "agreement"—strict matching, similarity comparison, or criteria-based judgment.

The ecosystem includes Studio (dev env), CLI (deployment), and genlayer-js (frontend SDK)—everything you need is ready to go.

In Part 2, we're writing a Python smart contract that does something no Ethereum contract could ever do: fetch a web page, ask an AI to analyze it, and commit the verdict to the blockchain.

Next up: Part 2—Writing Your First Intelligent Contract


This tutorial is part of the GenLayer Builder Program. Full source code: TruthPost repository

Follow the series:

  • Part 1: Introduction & Setup (you are here)
  • Part 2: Writing the Intelligent Contract
  • Part 3: Building the Frontend
  • Part 4: Testing & Deployment

title: "Your First Intelligent Contract: Teaching the Blockchain to Think — Part 2"
published: true
description: "Write a Python smart contract that fetches web pages and runs AI analysis on-chain—doing things literally impossible on Ethereum"
tags: blockchain, ai, python, tutorial
series: "Zero to GenLayer: Build an AI Fact-Checker"


Your First Intelligent Contract: Teaching the Blockchain to Think

Part 2: Writing a Smart Contract That Fetches Web Pages and Runs AI (In Python!)

In Part 1, we set up our environment and learned why GenLayer is different. Now we're writing the actual contract—a Python class that does things literally impossible on any other blockchain.


Let me show you something wild.

Here's what we're about to build: a smart contract that takes a claim like "The Great Wall of China is visible from space," Googles it (well, fetches Wikipedia), sends the content to an LLM, gets back a verdict with an explanation, and writes it to the blockchain.

All of that happens inside the contract itself. No oracles. No backend servers. The blockchain does the thinking.

By the end of this part, you'll have written an intelligent contract that:

  1. Lets users submit claims to be fact-checked
  2. Fetches real web sources directly from on-chain code
  3. Uses an LLM to analyze those sources and produce structured verdicts
  4. Leverages the Equivalence Principle so all validators reach consensus
  5. Tracks reputation points for users who contribute verified claims

Ready? Let's write some Python.


Intelligent Contracts 101: The GenLayer Way

Before we dive into TruthPost, let's understand the anatomy of a GenLayer contract. If you've written Solidity before, some of this will feel familiar. Some will feel very different.

# Every contract starts with this import
from genlayer import *

class MyContract(gl.Contract):
    # State variables—persisted on-chain
    my_data: TreeMap[Address, str]

    # Constructor—called once at deployment
    def __init__(self):
        pass

    # Read-only method—anyone can call, no gas cost
    @gl.public.view
    def get_data(self) -> str:
        return "hello"

    # Write method—modifies state, costs gas
    @gl.public.write
    def set_data(self, value: str) -> None:
        self.my_data[gl.message.sender_address] = value
Enter fullscreen mode Exit fullscreen mode

Key differences from Solidity:

  • Contracts are Python classes that extend gl.Contract
  • Only one contract class per file (keep it simple)
  • Storage uses GenLayer types (TreeMap, DynArray) instead of Python's dict/list
  • Methods are decorated: @gl.public.view for reads, @gl.public.write for writes
  • Access the caller's address with gl.message.sender_address (like msg.sender)

Got it? Cool. Let's build TruthPost.


Step 1: Define Your Data Model

Create contracts/truth_post.py and start with the data structures:

# { "Depends": "py-genlayer:test" }

import json
from dataclasses import dataclass
from genlayer import *

@allow_storage
@dataclass
class Claim:
    id: str                  # Unique identifier
    text: str                # The claim to fact-check
    verdict: str             # "true", "false", "partially_true", or "pending"
    explanation: str         # AI-generated explanation
    source_url: str          # URL used for verification
    submitter: str           # Address of who submitted
    has_been_checked: bool   # Whether fact-check has run
Enter fullscreen mode Exit fullscreen mode

What's happening here:

  • @allow_storage tells GenLayer this dataclass can be persisted on-chain. Without this decorator, you can't store custom objects in state.
  • @dataclass is standard Python for structured data—nothing fancy.
  • Each Claim tracks everything we need: the original text, the AI's verdict, metadata about who submitted it, and whether it's been checked yet.

Quick note on storage types:

Python Type GenLayer Type Why the Change?
dict TreeMap[K, V] Blockchain-optimized key-value storage
list DynArray[T] Blockchain-optimized arrays
int u256, i64 Fixed-size integers for deterministic behavior

Rule of thumb: Use GenLayer types for state variables. Regular Python types work fine for local variables inside methods.


Step 2: Set Up the Contract Class

Now let's define the actual contract with its state:

class TruthPost(gl.Contract):
    claims: TreeMap[str, Claim]          # claim_id -> Claim object
    reputation: TreeMap[Address, u256]   # user address -> reputation points
    claim_count: u256                    # total claims submitted

    def __init__(self):
        self.claim_count = 0
Enter fullscreen mode Exit fullscreen mode

Our contract stores three things:

  • claims—Maps claim IDs to full Claim objects
  • reputation—Tracks reputation scores for each user
  • claim_count—A simple counter for generating unique IDs

The __init__ method is the constructor—it runs once when the contract is deployed.


Step 3: Let Users Submit Claims

Time to add our first write method. This is how users submit claims for fact-checking:

    @gl.public.write
    def submit_claim(self, claim_text: str, source_url: str) -> None:
        sender = gl.message.sender_address

        # Generate a unique claim ID
        self.claim_count += 1
        claim_id = f"claim_{self.claim_count}"

        # Create the claim in "pending" state
        claim = Claim(
            id=claim_id,
            text=claim_text,
            verdict="pending",
            explanation="",
            source_url=source_url,
            submitter=sender.as_hex,
            has_been_checked=False,
        )

        self.claims[claim_id] = claim
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

  • @gl.public.write marks this as a state-changing method (costs gas to call)
  • gl.message.sender_address gives us the wallet address of whoever called the function (just like msg.sender in Solidity)
  • We create a Claim with verdict="pending"—the AI hasn't analyzed it yet
  • The claim gets stored in our TreeMap with a unique ID

Simple enough. But here's where things get interesting.


Step 4: The Magic Part—AI-Powered Fact-Checking

This is where GenLayer shows its true power. We're writing a private helper method that:

  1. Fetches a web page from the internet
  2. Asks an LLM to analyze it
  3. Returns a structured verdict

Ready for this?

    def _fact_check(self, claim_text: str, source_url: str) -> dict:
        def check_claim() -> str:
            # Step 1: Fetch real data from the internet
            web_data = gl.nondet.web.render(source_url, mode="text")

            # Step 2: Ask the LLM to fact-check the claim against the source
            prompt = f"""You are a fact-checker. Based on the web content provided,
determine whether the following claim is true, false, or partially true.

CLAIM: {claim_text}

WEB CONTENT:
{web_data}

Respond ONLY with this exact JSON format, nothing else:
{{
    "verdict": "<true|false|partially_true>",
    "explanation": "<brief 1-2 sentence explanation>"
}}

Rules:
- "true" = the claim is fully supported by the source
- "false" = the claim is contradicted by the source
- "partially_true" = some parts are correct but others are wrong or misleading
- Keep the explanation concise and factual
- Your response must be valid JSON only, no extra text
"""
            result = gl.nondet.exec_prompt(prompt, response_format="json")
            return json.dumps(result, sort_keys=True)

        # Step 3: Use Equivalence Principle for consensus
        result_json = json.loads(gl.eq_principle.strict_eq(check_claim))
        return result_json
Enter fullscreen mode Exit fullscreen mode

Okay, hold up. Let's unpack every single line of this, because this is the heart of the entire tutorial.

gl.nondet.web.render(url, mode="text")

This line fetches a web page. From inside a smart contract. Let that sink in.

  • mode="text" returns the page content as plain text (HTML tags stripped out)
  • mode="html" would give you raw HTML
  • mode="screenshot" would actually screenshot the page and return an image

On Ethereum? On Solana? This is literally impossible. Smart contracts are sandboxed. They have zero network access. They can't even ping an IP address.

GenLayer contracts break that barrier at the protocol level. The blockchain itself reaches out to the internet.

gl.nondet.exec_prompt(prompt, response_format="json")

This line runs a prompt through an LLM. Inside a smart contract. On-chain.

The response_format="json" parameter tells the model to return structured JSON instead of free-form text.

Again: impossible on any other blockchain. There's no native AI integration anywhere else. GenLayer validators each have access to LLM providers, making AI a first-class citizen in contract execution.

gl.eq_principle.strict_eq(check_claim)

This is the Equivalence Principle in action. Here's the exact flow:

  1. The Leader validator executes check_claim()—fetching the web page, calling the LLM, getting a result
  2. Every other validator independently does the exact same thing
  3. strict_eq checks whether all validators got the same JSON output
  4. If they agree → consensus reached, transaction accepted
  5. If they disagree → transaction might enter appeal

We use strict_eq because our output is structured: "true", "false", or "partially_true". Since we're asking the LLM to pick from a constrained set of labels, validators are very likely to agree.

When would you use the other equivalence modes?

  • prompt_comparative: When outputs should be similar but not identical (e.g., "ratings should be within 0.1 of each other")
  • prompt_non_comparative: When you want validators to judge whether the leader's output meets criteria, without redoing the work themselves

Why Is check_claim a Nested Function?

Notice the pattern: we define check_claim() as an inner function and pass it to strict_eq.

This is how GenLayer's non-deterministic execution works. The function gets executed independently by each validator, and the equivalence principle compares their outputs to reach consensus.


Step 5: Trigger the Fact-Check

Now let's add the method that actually runs the AI analysis:

    @gl.public.write
    def resolve_claim(self, claim_id: str) -> None:
        if claim_id not in self.claims:
            raise Exception("Claim not found")

        claim = self.claims[claim_id]

        if claim.has_been_checked:
            raise Exception("Claim already fact-checked")

        # Run the AI fact-check (this is where the magic happens!)
        result = self._fact_check(claim.text, claim.source_url)

        # Update the claim with the verdict
        claim.verdict = result["verdict"]
        claim.explanation = result.get("explanation", "")
        claim.has_been_checked = True

        # Award reputation to the submitter
        submitter_addr = Address(claim.submitter)
        if submitter_addr not in self.reputation:
            self.reputation[submitter_addr] = 0
        self.reputation[submitter_addr] += 1
Enter fullscreen mode Exit fullscreen mode

What happens when someone calls resolve_claim:

  1. We validate the claim exists and hasn't been checked yet
  2. We call _fact_check()—this fetches the web, queries the AI, and gets validator consensus
  3. We update the claim with the verdict and explanation
  4. We award +1 reputation to whoever submitted the claim (incentivizing contributions)

Step 6: Add Read Methods (View Functions)

Let's add methods so the frontend can read data from the contract:

    @gl.public.view
    def get_claims(self) -> dict:
        return {k: v for k, v in self.claims.items()}

    @gl.public.view
    def get_claim(self, claim_id: str) -> dict:
        if claim_id not in self.claims:
            raise Exception("Claim not found")
        claim = self.claims[claim_id]
        return {
            "id": claim.id,
            "text": claim.text,
            "verdict": claim.verdict,
            "explanation": claim.explanation,
            "source_url": claim.source_url,
            "submitter": claim.submitter,
            "has_been_checked": claim.has_been_checked,
        }

    @gl.public.view
    def get_reputation(self) -> dict:
        return {k.as_hex: v for k, v in self.reputation.items()}

    @gl.public.view
    def get_user_reputation(self, user_address: str) -> int:
        return self.reputation.get(Address(user_address), 0)
Enter fullscreen mode Exit fullscreen mode

View methods are:

  • Read-only—they can't modify state
  • Free to call—no gas cost
  • Decorated with @gl.public.view
  • Return data to whoever's querying

Note how get_reputation converts Address keys to hex strings with .as_hex—this is needed because the frontend works with string addresses, not Address objects.


The Complete Contract (All 95 Lines)

Here's the full contracts/truth_post.py:

# { "Depends": "py-genlayer:test" }

import json
from dataclasses import dataclass
from genlayer import *

@allow_storage
@dataclass
class Claim:
    id: str
    text: str
    verdict: str
    explanation: str
    source_url: str
    submitter: str
    has_been_checked: bool

class TruthPost(gl.Contract):
    claims: TreeMap[str, Claim]
    reputation: TreeMap[Address, u256]
    claim_count: u256

    def __init__(self):
        self.claim_count = 0

    def _fact_check(self, claim_text: str, source_url: str) -> dict:
        def check_claim() -> str:
            web_data = gl.nondet.web.render(source_url, mode="text")

            prompt = f"""You are a fact-checker. Based on the web content provided,
determine whether the following claim is true, false, or partially true.

CLAIM: {claim_text}

WEB CONTENT:
{web_data}

Respond ONLY with this exact JSON format, nothing else:
{{
    "verdict": "<true|false|partially_true>",
    "explanation": "<brief 1-2 sentence explanation>"
}}

Rules:
- "true" = the claim is fully supported by the source
- "false" = the claim is contradicted by the source
- "partially_true" = some parts are correct but others are wrong or misleading
- Keep the explanation concise and factual
- Your response must be valid JSON only, no extra text
"""
            result = gl.nondet.exec_prompt(prompt, response_format="json")
            return json.dumps(result, sort_keys=True)

        result_json = json.loads(gl.eq_principle.strict_eq(check_claim))
        return result_json

    @gl.public.write
    def submit_claim(self, claim_text: str, source_url: str) -> None:
        sender = gl.message.sender_address
        self.claim_count += 1
        claim_id = f"claim_{self.claim_count}"

        claim = Claim(
            id=claim_id,
            text=claim_text,
            verdict="pending",
            explanation="",
            source_url=source_url,
            submitter=sender.as_hex,
            has_been_checked=False,
        )
        self.claims[claim_id] = claim

    @gl.public.write
    def resolve_claim(self, claim_id: str) -> None:
        if claim_id not in self.claims:
            raise Exception("Claim not found")

        claim = self.claims[claim_id]
        if claim.has_been_checked:
            raise Exception("Claim already fact-checked")

        result = self._fact_check(claim.text, claim.source_url)

        claim.verdict = result["verdict"]
        claim.explanation = result.get("explanation", "")
        claim.has_been_checked = True

        submitter_addr = Address(claim.submitter)
        if submitter_addr not in self.reputation:
            self.reputation[submitter_addr] = 0
        self.reputation[submitter_addr] += 1

    @gl.public.view
    def get_claims(self) -> dict:
        return {k: v for k, v in self.claims.items()}

    @gl.public.view
    def get_claim(self, claim_id: str) -> dict:
        if claim_id not in self.claims:
            raise Exception("Claim not found")
        claim = self.claims[claim_id]
        return {
            "id": claim.id,
            "text": claim.text,
            "verdict": claim.verdict,
            "explanation": claim.explanation,
            "source_url": claim.source_url,
            "submitter": claim.submitter,
            "has_been_checked": claim.has_been_checked,
        }

    @gl.public.view
    def get_reputation(self) -> dict:
        return {k.as_hex: v for k, v in self.reputation.items()}

    @gl.public.view
    def get_user_reputation(self, user_address: str) -> int:
        return self.reputation.get(Address(user_address), 0)
Enter fullscreen mode Exit fullscreen mode

That's it. 95 lines of Python that do something no Ethereum contract could ever do.


Tracing the Full Flow

Let's walk through what happens when someone fact-checks a claim:

1. User calls submit_claim("The Eiffel Tower is 330m tall", "https://en.wikipedia.org/wiki/Eiffel_Tower")
   ↓
   Claim stored with verdict="pending"

2. User (or anyone) calls resolve_claim("claim_1")
   ↓
   Contract calls _fact_check()

3. Inside _fact_check():

   Leader validator runs check_claim():
   - Fetches Wikipedia via gl.nondet.web.render()
   - Sends content + claim to LLM via gl.nondet.exec_prompt()
   - LLM returns: {"verdict": "true", "explanation": "The Eiffel Tower is 330m tall including its antenna"}

   Other validators independently do the same

   gl.eq_principle.strict_eq() compares all results
   - All validators got "true" → consensus reached ✓

4. Claim updated:
   verdict="true"
   has_been_checked=True

5. Submitter receives +1 reputation point
Enter fullscreen mode Exit fullscreen mode

Deploy Your Contract

Time to deploy this beast. Update the deployment script to point at our new contract.

Open deploy/deployScript.ts and change the file path:

import path from "path";
import { GenLayerClient } from "genlayer-js";

export default async function main(client: GenLayerClient<any>) {
  // Point to our TruthPost contract
  const contractFilePath = path.resolve("contracts/truth_post.py");
  // ... rest stays the same
}
Enter fullscreen mode Exit fullscreen mode

Then deploy:

npm run deploy
Enter fullscreen mode Exit fullscreen mode

Copy that contract address—you'll need it when we build the frontend in Part 3.


Quick Reference: Core Concepts

Concept What It Does Code Example
State variables Persistent blockchain storage claims: TreeMap[str, Claim]
Custom storage types Store complex objects on-chain @allow_storage @dataclass class Claim
Write methods Modify state (costs gas) @gl.public.write
View methods Read state (free) @gl.public.view
Web access Fetch data from the internet gl.nondet.web.render(url, mode="text")
LLM calls Run AI prompts on-chain gl.nondet.exec_prompt(prompt, response_format="json")
Equivalence Principle Validators agree on AI outputs gl.eq_principle.strict_eq(fn)
Sender address Who called the method gl.message.sender_address

Prompt Engineering for On-Chain LLMs (Pro Tips)

Writing prompts for intelligent contracts is different from using ChatGPT. Your prompts need to produce consistent, structured outputs that validators can agree on.

5 Rules for Bulletproof On-Chain Prompts:

  1. Always request JSON output—Use response_format="json" and specify the exact schema
  2. Use constrained labels—Give the LLM specific options instead of free-form answers
  3. Be explicit about format—Add "Respond ONLY with JSON, no extra text"
  4. Sort your keys—Use json.dumps(result, sort_keys=True) for deterministic ordering
  5. Keep it simple—The simpler the output, the higher the agreement rate

Bad prompt:

"Tell me about this claim and whether it's true"

Good prompt:

"Respond ONLY with JSON: {"verdict": "", "explanation": "<1 sentence>"}"


In Part 3, we're building the frontend—a React app that connects to your intelligent contract, lets users submit claims, and triggers AI fact-checks with the click of a button.

Next up: Part 3—Building the Frontend with Next.js & genlayer-js


This tutorial is part of the GenLayer Builder Program. Full source code: TruthPost repository

Follow the series:

  • Part 1: Introduction & Setup
  • Part 2: Writing the Intelligent Contract (you are here)
  • Part 3: Building the Frontend
  • Part 4: Testing & Deployment

Build the Frontend: Connecting React to Your AI-Powered Blockchain

Part 3: Next.js, MetaMask, and Making Magic Happen in the Browser

In Parts 1 and 2, we built an intelligent contract that can fetch web pages and run AI analysis on-chain. Now we're building the UI that brings it all to life.


Here's where it gets real.

You've got a smart contract that Googles things and runs AI. Now we need a way for actual humans to interact with it. Not through command line tools or developer consoles — through a clean, fast, modern web interface.

By the end of this part, you'll have a fully functional React app where users can:

  • Connect their MetaMask wallet to the GenLayer network
  • Submit claims for fact-checking
  • Trigger AI fact-checks with a button click and see verdicts appear in real-time
  • View a reputation leaderboard of active fact-checkers

The boilerplate already gives us the structure, wallet integration, and UI primitives. We just need to wire everything up for TruthPost.

Let's dive in.


The Frontend Stack (What We're Working With)

The boilerplate uses a modern React stack that'll feel familiar if you've built Next.js apps before:

Library What It Does
Next.js 15 React framework with App Router
genlayer-js GenLayer SDK — reads/writes to intelligent contracts
wagmi + viem Wallet management & Ethereum utilities
TanStack Query Server state management (caching, refetching)
Tailwind CSS Utility-first styling
Radix UI Accessible component primitives

How genlayer-js Works (The Bridge to Your Contract)

Before we write any code, let's understand the SDK. genlayer-js is how your frontend talks to GenLayer:

import { createClient } from "genlayer-js";
import { studionet } from "genlayer-js/chains";

// Create a client connected to GenLayer
const client = createClient({
  chain: studionet,              // Network: studionet, localnet, or testnetAsimov
  account: "0xYourAddress",      // Optional: for signing transactions
});

// Read from a contract (free, no gas)
const result = await client.readContract({
  address: "0xContractAddress",
  functionName: "get_claims",
  args: [],
});

// Write to a contract (costs gas, needs wallet)
const txHash = await client.writeContract({
  address: "0xContractAddress",
  functionName: "submit_claim",
  args: ["The sky is blue", "https://example.com"],
  value: BigInt(0),
});

// Wait for the transaction to be accepted
const receipt = await client.waitForTransactionReceipt({
  hash: txHash,
  status: "ACCEPTED",
});
Enter fullscreen mode Exit fullscreen mode

Key patterns to remember:

  • readContract calls @gl.public.view methods — free, no wallet needed
  • writeContract calls @gl.public.write methods — costs gas, requires a connected wallet
  • waitForTransactionReceipt polls until the transaction reaches consensus
  • When you provide an account address, genlayer-js uses MetaMask for signing

Step 1: Configure Your Environment

Copy the environment template and add your deployed contract address:

cd frontend
cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Edit frontend/.env:

NEXT_PUBLIC_GENLAYER_RPC_URL=https://studio.genlayer.com/api
NEXT_PUBLIC_GENLAYER_CHAIN_ID=61999
NEXT_PUBLIC_GENLAYER_CHAIN_NAME=GenLayer Studio
NEXT_PUBLIC_GENLAYER_SYMBOL=GEN
NEXT_PUBLIC_CONTRACT_ADDRESS=0xYOUR_DEPLOYED_CONTRACT_ADDRESS
Enter fullscreen mode Exit fullscreen mode

Replace 0xYOUR_DEPLOYED_CONTRACT_ADDRESS with the address you got when you deployed in Part 2.


Step 2: Create the Contract Interaction Layer

The boilerplate uses a class-based pattern to wrap genlayer-js calls with TypeScript types. Let's create one for TruthPost.

Define Types

Create frontend/lib/contracts/types.ts:

export interface Claim {
  id: string;
  text: string;
  verdict: string;          // "pending" | "true" | "false" | "partially_true"
  explanation: string;
  source_url: string;
  submitter: string;         // hex address
  has_been_checked: boolean;
}

export interface ReputationEntry {
  address: string;
  reputation: number;
}

export interface TransactionReceipt {
  status: string;
  hash: string;
  blockNumber?: number;
  [key: string]: any;
}
Enter fullscreen mode Exit fullscreen mode

Create the Contract Class

Create frontend/lib/contracts/TruthPost.ts:

import { createClient } from "genlayer-js";
import { studionet } from "genlayer-js/chains";
import type { Claim, ReputationEntry, TransactionReceipt } from "./types";

class TruthPost {
  private contractAddress: `0x${string}`;
  private client: ReturnType<typeof createClient>;

  constructor(
    contractAddress: string,
    address?: string | null,
    studioUrl?: string
  ) {
    this.contractAddress = contractAddress as `0x${string}`;

    const config: any = {
      chain: studionet,
    };

    // If a wallet address is provided, the SDK uses MetaMask for signing
    if (address) {
      config.account = address as `0x${string}`;
    }

    if (studioUrl) {
      config.endpoint = studioUrl;
    }

    this.client = createClient(config);
  }

  /**
   * Recreate client when user switches wallet accounts
   */
  updateAccount(address: string): void {
    this.client = createClient({
      chain: studionet,
      account: address as `0x${string}`,
    });
  }

  // ─── READ METHODS (View Functions) ─────────────────────────

  /**
   * Fetch all claims from the contract
   */
  async getClaims(): Promise<Claim[]> {
    const result: any = await this.client.readContract({
      address: this.contractAddress,
      functionName: "get_claims",
      args: [],
    });

    // genlayer-js returns Maps for TreeMap data — convert to array
    if (result instanceof Map) {
      return Array.from(result.entries()).map(([id, claimData]: any) => {
        // Each claim value is also a Map of field -> value
        const obj = claimData instanceof Map
          ? Object.fromEntries(claimData.entries())
          : claimData;
        return { id, ...obj } as Claim;
      });
    }

    return [];
  }

  /**
   * Get a specific claim by ID
   */
  async getClaim(claimId: string): Promise<Claim> {
    const result: any = await this.client.readContract({
      address: this.contractAddress,
      functionName: "get_claim",
      args: [claimId],
    });

    if (result instanceof Map) {
      return Object.fromEntries(result.entries()) as Claim;
    }
    return result as Claim;
  }

  /**
   * Get reputation for a specific user
   */
  async getUserReputation(address: string | null): Promise<number> {
    if (!address) return 0;

    const result = await this.client.readContract({
      address: this.contractAddress,
      functionName: "get_user_reputation",
      args: [address],
    });

    return Number(result) || 0;
  }

  /**
   * Get the reputation leaderboard
   */
  async getLeaderboard(): Promise<ReputationEntry[]> {
    const result: any = await this.client.readContract({
      address: this.contractAddress,
      functionName: "get_reputation",
      args: [],
    });

    if (result instanceof Map) {
      return Array.from(result.entries())
        .map(([address, rep]: any) => ({
          address,
          reputation: Number(rep),
        }))
        .sort((a, b) => b.reputation - a.reputation);
    }

    return [];
  }

  // ─── WRITE METHODS (State-Changing Functions) ──────────────

  /**
   * Submit a new claim to be fact-checked
   */
  async submitClaim(
    claimText: string,
    sourceUrl: string
  ): Promise<TransactionReceipt> {
    // writeContract sends a transaction signed by MetaMask
    const txHash = await this.client.writeContract({
      address: this.contractAddress,
      functionName: "submit_claim",
      args: [claimText, sourceUrl],
      value: BigInt(0),
    });

    // Wait for validators to reach consensus
    const receipt = await this.client.waitForTransactionReceipt({
      hash: txHash,
      status: "ACCEPTED" as any,
      retries: 24,        // Check up to 24 times
      interval: 5000,     // Every 5 seconds (2 min total)
    });

    return receipt as TransactionReceipt;
  }

  /**
   * Trigger AI fact-check for a pending claim
   * This is where the magic happens — the contract fetches web data
   * and uses AI to analyze the claim!
   */
  async resolveClaim(claimId: string): Promise<TransactionReceipt> {
    const txHash = await this.client.writeContract({
      address: this.contractAddress,
      functionName: "resolve_claim",
      args: [claimId],
      value: BigInt(0),
    });

    // This can take longer because the AI fact-check runs during consensus
    const receipt = await this.client.waitForTransactionReceipt({
      hash: txHash,
      status: "ACCEPTED" as any,
      retries: 24,
      interval: 5000,
    });

    return receipt as TransactionReceipt;
  }
}

export default TruthPost;
Enter fullscreen mode Exit fullscreen mode

Important pattern: Map conversion

GenLayer contracts use TreeMap for storage, which genlayer-js returns as JavaScript Map objects. You need to convert these to plain arrays/objects for React:

// TreeMap comes back as Map
if (result instanceof Map) {
  return Array.from(result.entries()).map(([key, value]) => ...);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Create React Hooks with TanStack Query

TanStack Query handles caching, refetching, and loading states. Let's create hooks for our contract.

Create frontend/lib/hooks/useTruthPost.ts:

"use client";

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import TruthPost from "../contracts/TruthPost";
import { getContractAddress, getStudioUrl } from "../genlayer/client";
import { useWallet } from "../genlayer/wallet";
import type { Claim, ReputationEntry } from "../contracts/types";

/**
 * Hook to get the TruthPost contract instance
 * Recreated when wallet address changes
 */
export function useTruthPostContract(): TruthPost | null {
  const { address } = useWallet();
  const contractAddress = getContractAddress();
  const studioUrl = getStudioUrl();

  return useMemo(() => {
    if (!contractAddress) return null;
    return new TruthPost(contractAddress, address, studioUrl);
  }, [contractAddress, address, studioUrl]);
}

/**
 * Hook to fetch all claims
 */
export function useClaims() {
  const contract = useTruthPostContract();

  return useQuery<Claim[], Error>({
    queryKey: ["claims"],
    queryFn: () => contract?.getClaims() ?? Promise.resolve([]),
    refetchOnWindowFocus: true,
    staleTime: 2000,
    enabled: !!contract,
  });
}

/**
 * Hook to fetch user reputation
 */
export function useUserReputation(address: string | null) {
  const contract = useTruthPostContract();

  return useQuery<number, Error>({
    queryKey: ["reputation", address],
    queryFn: () => contract?.getUserReputation(address) ?? Promise.resolve(0),
    enabled: !!address && !!contract,
    staleTime: 2000,
  });
}

/**
 * Hook to fetch the reputation leaderboard
 */
export function useLeaderboard() {
  const contract = useTruthPostContract();

  return useQuery<ReputationEntry[], Error>({
    queryKey: ["leaderboard"],
    queryFn: () => contract?.getLeaderboard() ?? Promise.resolve([]),
    refetchOnWindowFocus: true,
    staleTime: 2000,
    enabled: !!contract,
  });
}

/**
 * Hook to submit a new claim
 */
export function useSubmitClaim() {
  const contract = useTruthPostContract();
  const { address } = useWallet();
  const queryClient = useQueryClient();
  const [isSubmitting, setIsSubmitting] = useState(false);

  const mutation = useMutation({
    mutationFn: async ({
      claimText,
      sourceUrl,
    }: {
      claimText: string;
      sourceUrl: string;
    }) => {
      if (!contract) throw new Error("Contract not configured");
      if (!address) throw new Error("Wallet not connected");
      setIsSubmitting(true);
      return contract.submitClaim(claimText, sourceUrl);
    },
    onSuccess: () => {
      // Refresh all data after submitting a claim
      queryClient.invalidateQueries({ queryKey: ["claims"] });
      queryClient.invalidateQueries({ queryKey: ["reputation"] });
      queryClient.invalidateQueries({ queryKey: ["leaderboard"] });
      setIsSubmitting(false);
    },
    onError: (err: any) => {
      console.error("Error submitting claim:", err);
      setIsSubmitting(false);
    },
  });

  return {
    ...mutation,
    isSubmitting,
    submitClaim: mutation.mutate,
  };
}

/**
 * Hook to resolve (fact-check) a claim
 */
export function useResolveClaim() {
  const contract = useTruthPostContract();
  const { address } = useWallet();
  const queryClient = useQueryClient();
  const [isResolving, setIsResolving] = useState(false);
  const [resolvingClaimId, setResolvingClaimId] = useState<string | null>(null);

  const mutation = useMutation({
    mutationFn: async (claimId: string) => {
      if (!contract) throw new Error("Contract not configured");
      if (!address) throw new Error("Wallet not connected");
      setIsResolving(true);
      setResolvingClaimId(claimId);
      return contract.resolveClaim(claimId);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["claims"] });
      queryClient.invalidateQueries({ queryKey: ["reputation"] });
      queryClient.invalidateQueries({ queryKey: ["leaderboard"] });
      setIsResolving(false);
      setResolvingClaimId(null);
    },
    onError: (err: any) => {
      console.error("Error resolving claim:", err);
      setIsResolving(false);
      setResolvingClaimId(null);
    },
  });

  return {
    ...mutation,
    isResolving,
    resolvingClaimId,
    resolveClaim: mutation.mutate,
  };
}
Enter fullscreen mode Exit fullscreen mode

Why TanStack Query?

Blockchain reads are like API calls — they're async, can fail, and the data goes stale. TanStack Query gives us:

  • Automatic caching — don't re-fetch data unnecessarily
  • Loading/error statesisLoading, isError, error for free
  • Cache invalidation — after a write, we invalidate queries so the UI refreshes
  • Refetch on focus — data updates when the user comes back to the tab

Step 4: The Wallet Connection (Already Done!)

The boilerplate includes complete wallet integration with MetaMask. Here's what it provides:

WalletProvider (context) manages:

  • Connecting/disconnecting MetaMask
  • Listening for account and network changes
  • Auto-reconnecting on page refresh
  • Network switching to GenLayer

useWallet() hook gives you:

const {
  address,              // Current wallet address (or null)
  isConnected,          // Boolean: is wallet connected?
  isLoading,            // Boolean: connection in progress?
  isMetaMaskInstalled,  // Boolean: is MetaMask available?
  isOnCorrectNetwork,   // Boolean: on GenLayer network?
  connectWallet,        // Function: trigger MetaMask connection
  disconnectWallet,     // Function: clear wallet state
  switchWalletAccount,  // Function: show account picker
} = useWallet();
Enter fullscreen mode Exit fullscreen mode

You don't need to modify the wallet code. It works with any GenLayer contract out of the box.


Step 5: Build the Claims List Component

Time to build the UI. Let's create a component that displays all claims:

// frontend/components/ClaimsList.tsx
"use client";

import { useClaims, useResolveClaim } from "../lib/hooks/useTruthPost";
import { useWallet } from "../lib/genlayer/wallet";

function VerdictBadge({ verdict }: { verdict: string }) {
  const colors: Record<string, string> = {
    true: "bg-green-500/20 text-green-400 border-green-500/30",
    false: "bg-red-500/20 text-red-400 border-red-500/30",
    partially_true: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
    pending: "bg-gray-500/20 text-gray-400 border-gray-500/30",
  };

  const labels: Record<string, string> = {
    true: "True",
    false: "False",
    partially_true: "Partially True",
    pending: "Pending",
  };

  return (
    <span className={`px-2 py-1 rounded-full text-xs border ${colors[verdict] || colors.pending}`}>
      {labels[verdict] || verdict}
    </span>
  );
}

export default function ClaimsList() {
  const { data: claims, isLoading, error } = useClaims();
  const { resolveClaim, isResolving, resolvingClaimId } = useResolveClaim();
  const { address, isConnected } = useWallet();

  if (isLoading) return <div className="text-center p-8">Loading claims...</div>;
  if (error) return <div className="text-red-400 p-8">Error: {error.message}</div>;
  if (!claims?.length) return <div className="text-center p-8">No claims yet. Be the first!</div>;

  return (
    <div className="space-y-4">
      <h2 className="text-xl font-bold">Submitted Claims</h2>

      {claims.map((claim) => (
        <div key={claim.id} className="border border-white/10 rounded-lg p-4 space-y-2">
          {/* Claim text */}
          <p className="text-lg font-medium">{claim.text}</p>

          {/* Metadata row */}
          <div className="flex items-center gap-3 text-sm text-gray-400">
            <VerdictBadge verdict={claim.verdict} />
            <span>Source: {new URL(claim.source_url).hostname}</span>
            <span>By: {claim.submitter.slice(0, 6)}...{claim.submitter.slice(-4)}</span>
          </div>

          {/* AI explanation (shown after fact-check) */}
          {claim.has_been_checked && claim.explanation && (
            <p className="text-sm text-gray-300 bg-white/5 rounded p-3 mt-2">
              AI Analysis: {claim.explanation}
            </p>
          )}

          {/* Resolve button (only for pending claims) */}
          {!claim.has_been_checked && isConnected && (
            <button
              onClick={() => resolveClaim(claim.id)}
              disabled={isResolving}
              className="mt-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm disabled:opacity-50"
            >
              {isResolving && resolvingClaimId === claim.id
                ? "Fact-checking with AI..."
                : "Fact-Check This Claim"
              }
            </button>
          )}
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What's happening:

  1. useClaims() fetches all claims from the contract
  2. useResolveClaim() gives us the mutation to trigger a fact-check
  3. Each claim shows its text, verdict badge, source, and submitter
  4. Pending claims show a "Fact-Check This Claim" button
  5. When clicked, it triggers the on-chain AI fact-check
  6. While resolving, the button shows "Fact-checking with AI..." — because GenLayer is actually fetching the web and running an LLM!

Step 6: Build the Submit Claim Form

// frontend/components/SubmitClaimModal.tsx
"use client";

import { useState } from "react";
import { useSubmitClaim } from "../lib/hooks/useTruthPost";
import { useWallet } from "../lib/genlayer/wallet";

export default function SubmitClaimModal() {
  const [isOpen, setIsOpen] = useState(false);
  const [claimText, setClaimText] = useState("");
  const [sourceUrl, setSourceUrl] = useState("");
  const { submitClaim, isSubmitting } = useSubmitClaim();
  const { isConnected } = useWallet();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!claimText.trim() || !sourceUrl.trim()) return;

    submitClaim(
      { claimText: claimText.trim(), sourceUrl: sourceUrl.trim() },
      {
        onSuccess: () => {
          setClaimText("");
          setSourceUrl("");
          setIsOpen(false);
        },
      }
    );
  };

  if (!isConnected) return null;

  return (
    <>
      <button
        onClick={() => setIsOpen(true)}
        className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded font-medium"
      >
        Submit a Claim
      </button>

      {isOpen && (
        <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
          <div className="bg-gray-900 border border-white/10 rounded-xl p-6 w-full max-w-md">
            <h2 className="text-xl font-bold mb-4">Submit a Claim for Fact-Checking</h2>

            <form onSubmit={handleSubmit} className="space-y-4">
              <div>
                <label className="block text-sm text-gray-400 mb-1">Claim</label>
                <textarea
                  value={claimText}
                  onChange={(e) => setClaimText(e.target.value)}
                  placeholder='e.g., "The Great Wall of China is visible from space"'
                  className="w-full bg-white/5 border border-white/10 rounded p-3 text-sm"
                  rows={3}
                  required
                />
              </div>

              <div>
                <label className="block text-sm text-gray-400 mb-1">Source URL</label>
                <input
                  type="url"
                  value={sourceUrl}
                  onChange={(e) => setSourceUrl(e.target.value)}
                  placeholder="https://en.wikipedia.org/wiki/Great_Wall_of_China"
                  className="w-full bg-white/5 border border-white/10 rounded p-3 text-sm"
                  required
                />
                <p className="text-xs text-gray-500 mt-1">
                  The AI will fetch this URL to verify the claim
                </p>
              </div>

              <div className="flex gap-3 justify-end">
                <button
                  type="button"
                  onClick={() => setIsOpen(false)}
                  className="px-4 py-2 text-gray-400 hover:text-white"
                >
                  Cancel
                </button>
                <button
                  type="submit"
                  disabled={isSubmitting || !claimText.trim() || !sourceUrl.trim()}
                  className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded disabled:opacity-50"
                >
                  {isSubmitting ? "Submitting..." : "Submit Claim"}
                </button>
              </div>
            </form>
          </div>
        </div>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Wire It All Together

Update the main page to use our TruthPost components.

Edit frontend/app/page.tsx:

import ClaimsList from "../components/ClaimsList";
import SubmitClaimModal from "../components/SubmitClaimModal";

export default function Home() {
  return (
    <main className="min-h-screen p-8 max-w-4xl mx-auto">
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold mb-4">TruthPost</h1>
        <p className="text-gray-400 text-lg">
          Decentralized fact-checking powered by AI on GenLayer
        </p>
      </div>

      {/* Submit button + Claims list */}
      <div className="mb-6 flex justify-end">
        <SubmitClaimModal />
      </div>

      <ClaimsList />

      {/* How it works */}
      <div className="mt-16 border-t border-white/10 pt-8">
        <h2 className="text-2xl font-bold mb-6 text-center">How It Works</h2>
        <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
          <div className="text-center p-4">
            <div className="text-3xl mb-2">1</div>
            <h3 className="font-bold mb-1">Submit a Claim</h3>
            <p className="text-sm text-gray-400">
              Enter any factual claim and a source URL for verification
            </p>
          </div>
          <div className="text-center p-4">
            <div className="text-3xl mb-2">2</div>
            <h3 className="font-bold mb-1">AI Fact-Checks It</h3>
            <p className="text-sm text-gray-400">
              GenLayer fetches the source and uses AI to analyze the claim on-chain
            </p>
          </div>
          <div className="text-center p-4">
            <div className="text-3xl mb-2">3</div>
            <h3 className="font-bold mb-1">Validators Agree</h3>
            <p className="text-sm text-gray-400">
              Multiple validators independently verify through Optimistic Democracy
            </p>
          </div>
        </div>
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Fire It Up!

cd frontend
npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 and watch the magic happen.

Try it out:

  1. Click "Connect Wallet" in the navbar — MetaMask will prompt you to connect and switch to GenLayer
  2. Click "Submit a Claim" — try something like "Python was created by Guido van Rossum" with source https://en.wikipedia.org/wiki/Python_(programming_language)
  3. After the claim appears, click "Fact-Check This Claim"
  4. Wait ~30 seconds while GenLayer:
    • Fetches the Wikipedia page
    • Sends it to an LLM for analysis
    • Validators reach consensus on the verdict
  5. See the verdict appear: True with an AI-generated explanation!

Understanding the Data Flow

Let's trace what happens when a user submits a claim:

User clicks "Submit Claim"
  ↓
  SubmitClaimModal calls submitClaim()
    ↓
    useTruthPost hook calls contract.submitClaim()
      ↓
      genlayer-js calls writeContract()
        ↓
        MetaMask signs the transaction
          ↓
          Transaction sent to GenLayer network
            ↓
            Validators execute submit_claim() in the Python contract
              ↓
              Claim stored on-chain
                ↓
                waitForTransactionReceipt() resolves
                  ↓
                  TanStack Query invalidates cache
                    ↓
                    ClaimsList re-renders with new claim

User clicks "Fact-Check This Claim"
  ↓
  ClaimsList calls resolveClaim()
    ↓
    genlayer-js sends resolve_claim transaction
      ↓
      Leader validator runs _fact_check():
        ↓
        gl.nondet.web.render() fetches the URL
        ↓
        gl.nondet.exec_prompt() asks AI to analyze
        ↓
        Result: {"verdict": "true", "explanation": "..."}
      ↓
      Other validators do the same independently
      ↓
      gl.eq_principle.strict_eq() checks consensus
        ↓
        All agree → transaction accepted!
          ↓
          UI updates with verdict and explanation
Enter fullscreen mode Exit fullscreen mode

The Wallet Flow Explained

The boilerplate's wallet integration handles several important flows automatically:

Connecting:

  1. User clicks "Connect Wallet"
  2. MetaMask popup asks for permission
  3. App checks if user is on GenLayer network
  4. If not, prompts to add/switch to GenLayer
  5. Connection persisted (auto-reconnects on refresh)

Account switching:

  • MetaMask fires accountsChanged event
  • WalletProvider updates state automatically
  • Contract hooks recreate the genlayer-js client with new address
  • All queries refetch with new account context

Network validation:

  • App checks chainId matches GenLayer's (61999)
  • Shows warning if on wrong network
  • Prompts to switch automatically

Key Frontend Patterns Recap

Pattern What It Does File
Contract class Typed wrapper around genlayer-js lib/contracts/TruthPost.ts
Map conversion TreeMap → JavaScript arrays getClaims() method
TanStack Query hooks Caching, loading states, auto-refetch lib/hooks/useTruthPost.ts
Cache invalidation Refresh UI after writes onSuccess in mutations
Wallet context Global wallet state for all components lib/genlayer/WalletProvider.tsx
Conditional rendering Show/hide based on wallet state isConnected checks

In Part 4, we'll test this thing properly with gltest, deploy to the public testnet, and explore what else you can build on GenLayer.

Next up: Part 4 — Testing, Deployment & What to Build Next


This tutorial is part of the GenLayer Builder Program. Full source code: TruthPost repository

Ship It: Testing, Deployment, and What's Next

Part 4: Making Sure It Works, Going Live, and Building the Impossible

We've built TruthPost — an AI-powered fact-checker running on blockchain. Now we're testing it properly, deploying to the public testnet, and exploring what else becomes possible when smart contracts can think.


You did it. You built something that doesn't exist anywhere else in crypto.

A dApp where smart contracts access the internet, run AI models, and reach consensus on subjective outputs. On Ethereum? Impossible. On Solana? Can't be done. On GenLayer? Shipped.

But before you start showing this off to the world, let's make sure it actually works. In this final part, we'll:

  1. Write proper tests using gltest (GenLayer's testing framework)
  2. Update the deployment script for production
  3. Deploy to the GenLayer testnet
  4. Explore ideas for extending TruthPost
  5. Talk about what else you can build on GenLayer

Let's finish strong.


Testing Intelligent Contracts with gltest

GenLayer provides gltest — a testing framework that deploys your contract to a local GenLayer instance and runs real transactions against it. This means your tests exercise actual AI and web access, not mocks.

Important: Tests require GenLayer Studio to be running, because gltest sends real transactions to the local network.

Setting Up Tests

Make sure you have the Python dependencies installed:

pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Writing Test Fixtures

First, let's define the expected data structures our tests will validate.

Create test/truth_post_fixtures.py:

# Expected claim state after submission (before fact-check)
test_claim_pending = {
    "claim_1": {
        "id": "claim_1",
        "text": "Python was created by Guido van Rossum",
        "verdict": "pending",
        "explanation": "",
        "source_url": "https://en.wikipedia.org/wiki/Python_(programming_language)",
        "submitter": None,  # Will be set to default_account.address
        "has_been_checked": False,
    }
}
Enter fullscreen mode Exit fullscreen mode

Writing the Test Suite

Create test/test_truth_post.py:

from gltest import get_contract_factory, default_account
from gltest.helpers import load_fixture
from gltest.assertions import tx_execution_succeeded


def deploy_contract():
    """Deploy a fresh TruthPost contract and verify initial state."""
    factory = get_contract_factory("TruthPost")
    contract = factory.deploy()

    # Verify initial state is empty
    all_claims = contract.get_claims(args=[])
    assert all_claims == {}

    all_reputation = contract.get_reputation(args=[])
    assert all_reputation == {}

    return contract


def test_submit_claim():
    """Test that a user can submit a claim."""
    contract = load_fixture(deploy_contract)

    # Submit a claim
    result = contract.submit_claim(
        args=[
            "Python was created by Guido van Rossum",
            "https://en.wikipedia.org/wiki/Python_(programming_language)",
        ]
    )
    assert tx_execution_succeeded(result)

    # Verify claim was stored
    claims = contract.get_claims(args=[])
    assert "claim_1" in str(claims)


def test_resolve_claim_true():
    """Test that a factually true claim is correctly verified."""
    contract = load_fixture(deploy_contract)

    # Submit a claim that should be TRUE
    submit_result = contract.submit_claim(
        args=[
            "Python was created by Guido van Rossum",
            "https://en.wikipedia.org/wiki/Python_(programming_language)",
        ]
    )
    assert tx_execution_succeeded(submit_result)

    # Resolve (fact-check) the claim
    # This triggers web fetching + AI analysis + validator consensus!
    resolve_result = contract.resolve_claim(
        args=["claim_1"],
        wait_interval=10000,  # 10 seconds between checks
        wait_retries=15,      # Up to 15 retries (2.5 min total)
    )
    assert tx_execution_succeeded(resolve_result)

    # Verify the verdict
    claim = contract.get_claim(args=["claim_1"])
    assert claim["has_been_checked"] == True
    assert claim["verdict"] == "true"

    # Verify reputation was awarded
    reputation = contract.get_user_reputation(args=[default_account.address])
    assert reputation == 1


def test_resolve_claim_false():
    """Test that a factually false claim is correctly identified."""
    contract = load_fixture(deploy_contract)

    # Submit a claim that should be FALSE
    submit_result = contract.submit_claim(
        args=[
            "The Great Wall of China is visible from space with the naked eye",
            "https://en.wikipedia.org/wiki/Great_Wall_of_China",
        ]
    )
    assert tx_execution_succeeded(submit_result)

    # Resolve the claim
    resolve_result = contract.resolve_claim(
        args=["claim_1"],
        wait_interval=10000,
        wait_retries=15,
    )
    assert tx_execution_succeeded(resolve_result)

    # Verify it was marked as false
    claim = contract.get_claim(args=["claim_1"])
    assert claim["has_been_checked"] == True
    assert claim["verdict"] == "false"


def test_cannot_resolve_twice():
    """Test that a claim cannot be fact-checked twice."""
    contract = load_fixture(deploy_contract)

    # Submit and resolve
    contract.submit_claim(
        args=[
            "Python was created by Guido van Rossum",
            "https://en.wikipedia.org/wiki/Python_(programming_language)",
        ]
    )
    contract.resolve_claim(
        args=["claim_1"],
        wait_interval=10000,
        wait_retries=15,
    )

    # Try to resolve again — should fail
    try:
        contract.resolve_claim(
            args=["claim_1"],
            wait_interval=10000,
            wait_retries=15,
        )
        assert False, "Should have raised an exception"
    except Exception:
        pass  # Expected — claim already resolved
Enter fullscreen mode Exit fullscreen mode

Running Tests

gltest
Enter fullscreen mode Exit fullscreen mode

Note: Tests involving resolve_claim will take longer because GenLayer is actually:

  1. Fetching a web page
  2. Running an LLM prompt
  3. Having validators reach consensus

This is real AI execution, not a mock! Allow ~30-60 seconds per resolve test.

Understanding gltest Patterns

Pattern What It Does
get_contract_factory("Name") Gets a factory for deploying a contract by class name
factory.deploy() Deploys a fresh instance and returns a contract proxy
contract.method_name(args=[...]) Calls a contract method
load_fixture(fn) Caches the result of a deploy function across tests
tx_execution_succeeded(result) Asserts the transaction reached consensus
default_account The test account with .address property
wait_interval / wait_retries For non-deterministic calls that take time

Updating the Deployment Script

Update deploy/deployScript.ts to deploy TruthPost instead of the example contract:

import { readFileSync } from "fs";
import path from "path";
import {
  TransactionHash,
  TransactionStatus,
  GenLayerClient,
  DecodedDeployData,
  GenLayerChain,
} from "genlayer-js/types";
import { localnet } from "genlayer-js/chains";

export default async function main(client: GenLayerClient<any>) {
  // Point to our TruthPost contract
  const filePath = path.resolve(process.cwd(), "contracts/truth_post.py");

  try {
    const contractCode = new Uint8Array(readFileSync(filePath));

    await client.initializeConsensusSmartContract();

    const deployTransaction = await client.deployContract({
      code: contractCode,
      args: [],
    });

    const receipt = await client.waitForTransactionReceipt({
      hash: deployTransaction as TransactionHash,
      status: TransactionStatus.ACCEPTED,
      retries: 200,
    });

    if (
      receipt.status !== 5 &&
      receipt.status !== 6 &&
      receipt.statusName !== "ACCEPTED" &&
      receipt.statusName !== "FINALIZED"
    ) {
      throw new Error(`Deployment failed. Receipt: ${JSON.stringify(receipt)}`);
    }

    const deployedContractAddress =
      (client.chain as GenLayerChain).id === localnet.id
        ? receipt.data.contract_address
        : (receipt.txDataDecoded as DecodedDeployData)?.contractAddress;

    console.log(`Contract deployed at address: ${deployedContractAddress}`);
    console.log(`\nNext steps:`);
    console.log(`1. Copy the address above`);
    console.log(`2. Set NEXT_PUBLIC_CONTRACT_ADDRESS in frontend/.env`);
    console.log(`3. Run: cd frontend && npm run dev`);
  } catch (error) {
    throw new Error(`Error during deployment: ${error}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Deploying to Testnet

So far we've used studionet (the development environment). Let's deploy to the real GenLayer testnet where anyone can interact with your dApp.

Step 1: Switch Network

genlayer network
# Select "testnet" from the menu
Enter fullscreen mode Exit fullscreen mode

Step 2: Get Test Tokens

You'll need test GEN tokens for gas. Visit the GenLayer faucet to get some for your MetaMask address.

Step 3: Deploy

npm run deploy
Enter fullscreen mode Exit fullscreen mode

Step 4: Update Frontend Config

Edit frontend/.env:

NEXT_PUBLIC_GENLAYER_RPC_URL=<testnet RPC URL>
NEXT_PUBLIC_CONTRACT_ADDRESS=0xYOUR_NEW_TESTNET_ADDRESS
Enter fullscreen mode Exit fullscreen mode

Step 5: Run & Share

cd frontend
npm run dev
Enter fullscreen mode Exit fullscreen mode

Your dApp is now running against the public testnet! Anyone can connect their wallet and fact-check claims.


What You've Built — A Reality Check

Let's step back and appreciate what you've accomplished over this tutorial:

You built an AI-powered decentralized fact-checker that:

  • Runs on a blockchain (trustless, censorship-resistant)
  • Fetches real web data from inside a smart contract (no oracles!)
  • Uses AI to analyze claims (on-chain LLM access!)
  • Reaches consensus on subjective AI outputs (Equivalence Principle!)
  • Has a modern React frontend with wallet integration

This would be impossible on Ethereum, Solana, or any other blockchain. GenLayer is the first platform where this kind of application can exist.


Architecture Diagram (The Full Picture)

┌─────────────────────────────────────────────────────┐
│                   Frontend (Next.js)                 │
│                                                      │
│  ┌──────────┐  ┌──────────┐  ┌───────────────────┐  │
│  │ Claims   │  │ Submit   │  │  Wallet Provider  │  │
│  │ List     │  │ Modal    │  │  (MetaMask)       │  │
│  └────┬─────┘  └────┬─────┘  └────────┬──────────┘  │
│       │              │                 │              │
│  ┌────┴──────────────┴─────────────────┴──────────┐  │
│  │          TanStack Query Hooks                   │  │
│  │  useClaims() | useSubmitClaim() | useResolve()  │  │
│  └────────────────────┬────────────────────────────┘  │
│                       │                               │
│  ┌────────────────────┴────────────────────────────┐  │
│  │        TruthPost Contract Class                  │  │
│  │        (genlayer-js wrapper)                     │  │
│  └────────────────────┬────────────────────────────┘  │
└───────────────────────┼──────────────────────────────┘
                        │ RPC
┌───────────────────────┼──────────────────────────────┐
│              GenLayer Network                         │
│                       │                               │
│  ┌────────────────────┴────────────────────────────┐  │
│  │        TruthPost Contract (Python)               │  │
│  │                                                   │  │
│  │  submit_claim()  ──→  Store claim on-chain       │  │
│  │  resolve_claim() ──→  _fact_check()              │  │
│  │                       ├─ web.render() → Internet  │  │
│  │                       ├─ exec_prompt() → LLM     │  │
│  │                       └─ strict_eq() → Consensus │  │
│  └───────────────────────────────────────────────────┘  │
│                                                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐              │
│  │ Leader   │  │Validator │  │Validator │  ...          │
│  │ (runs    │  │ (verifies│  │ (verifies│              │
│  │  first)  │  │  result) │  │  result) │              │
│  └──────────┘  └──────────┘  └──────────┘              │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Ideas to Extend TruthPost

Now that you understand the fundamentals, here are ways to make TruthPost more powerful:

1. Add Staking

Make users stake GEN tokens when submitting claims. If the claim turns out to be intentionally misleading, they lose their stake:

@gl.public.write.payable
def submit_claim(self, claim_text: str, source_url: str) -> None:
    if gl.message.value < 100:
        raise Exception("Minimum stake: 100 GEN")
    # ... rest of logic
Enter fullscreen mode Exit fullscreen mode

2. Use Comparative Equivalence

Instead of strict equality, use comparative equivalence for more nuanced verdicts:

result = gl.eq_principle.prompt_comparative(
    check_claim,
    "Results are equivalent if they agree on the same verdict (true/false/partially_true) "
    "and the explanations convey the same core reasoning, even if worded differently."
)
Enter fullscreen mode Exit fullscreen mode

3. Multi-Source Verification

Fetch multiple sources and cross-reference:

def _multi_source_check(self, claim_text, urls):
    def check():
        results = []
        for url in urls:
            web_data = gl.nondet.web.render(url, mode="text")
            results.append(web_data)

        combined = "\n---\n".join(results)
        prompt = f"Based on these {len(urls)} sources, fact-check: {claim_text}\n{combined}"
        result = gl.nondet.exec_prompt(prompt, response_format="json")
        return json.dumps(result, sort_keys=True)

    return json.loads(gl.eq_principle.strict_eq(check))
Enter fullscreen mode Exit fullscreen mode

4. Dispute System

Let users challenge verdicts, triggering a re-check with different sources or additional validators.

5. Screenshot Verification

Use gl.nondet.web.render(url, mode="screenshot") to capture visual evidence and analyze it with gl.nondet.exec_prompt() using image support.


What Else Can You Build on GenLayer?

GenLayer opens up entire categories of applications that were impossible before. Here's what becomes possible:

Category Example GenLayer Feature Used
Dispute Resolution Arbitration between trustless parties LLM analyzes evidence, validators vote
P2P Betting Friends bet on subjective outcomes Web scraping for results, AI determines winners
Parametric Insurance Insurance based on real events Web access verifies claims automatically
Content Moderation Decentralized content policy enforcement LLM evaluates content against community rules
Prediction Markets Markets on real-world events Web + AI settle outcomes objectively
Reputation Systems On-chain skill/trust scoring AI evaluates contributions and work quality
Dynamic NFTs NFTs that evolve based on real-world data Web access feeds dynamic metadata
Oracle Replacement Any data from any website gl.nondet.web replaces Chainlink for many use cases

GenLayer vs. Traditional Blockchain (The Final Comparison)

Feature Ethereum/Solana GenLayer
Smart contract language Solidity/Rust Python
Internet access No (need oracles) Yes, native
AI/LLM access No Yes, native
Non-deterministic logic Impossible Yes, via Equivalence Principle
Consensus on subjective data Impossible Yes, via Optimistic Democracy
Cost of web data High (oracle fees) Included in gas

Resources for Your Next Steps

Resource Link
GenLayer Docs docs.genlayer.com
SDK API Reference sdk.genlayer.com
genlayer-js SDK docs.genlayer.com/api-references/genlayer-js
GenLayer Studio studio.genlayer.com
Contract Examples docs.genlayer.com/developers/intelligent-contracts/examples
Community Discord Check docs for invite link

What You've Learned (The Complete Journey)

You went from zero to a working GenLayer dApp in four parts:

Part 1: Understood GenLayer's breakthrough concepts — Optimistic Democracy and the Equivalence Principle — and set up your environment

Part 2: Wrote a Python intelligent contract that fetches web data and uses AI on-chain, doing things impossible on any other blockchain

Part 3: Built a React frontend connected via genlayer-js and MetaMask, with proper state management and wallet integration

Part 4: Tested with gltest, deployed to testnet, and explored what's next


The Bottom Line

GenLayer represents a fundamental shift in what's possible on blockchains.

Smart contracts can now think, read the internet, and make subjective decisions — all while maintaining the security and trustlessness that blockchain promises.

The question isn't "What can I build?" anymore.

It's "What couldn't I build before?"

  • Want to build a betting platform that automatically settles based on real-world events? Build it.
  • Want to create a dispute resolution system that uses AI to analyze evidence? Build it.
  • Want NFTs that evolve based on real-time data from any website? Build it.
  • Want insurance that pays out automatically when verifiable conditions are met? Build it.

GenLayer makes all of this possible. Not someday. Not with workarounds. Today.

Welcome to GenLayer. Go build something impossible.


This tutorial is part of the GenLayer Builder Program. Full source code: TruthPost repository

The GenLayer Cheat Sheet: Everything on One Page

Your quick reference for building Intelligent Contracts and dApps on GenLayer. Bookmark this. You'll need it.


CLI Commands (The Essentials)

genlayer network              # Switch networks (studionet / localnet / testnet)
npm run deploy                # Deploy your contract
npm run dev                   # Fire up the frontend
gltest                        # Run tests (needs GenLayer Studio running)
Enter fullscreen mode Exit fullscreen mode

Contract Skeleton (Start Here Every Time)

Every GenLayer contract follows this structure. Copy this, modify it, ship it:

from genlayer import *

@allow_storage            # Required for any custom type stored on-chain
@dataclass
class MyData:
    name: str
    value: u256

class MyContract(gl.Contract):
    # State — persisted on-chain between calls
    data: TreeMap[Address, MyData]
    count: u256

    def __init__(self):                    # Runs once, at deploy time
        self.count = 0

    @gl.public.view                        # Read-only — free, no gas
    def get_count(self) -> int:
        return self.count

    @gl.public.write                       # Modifies state — costs gas
    def increment(self) -> None:
        self.count += 1

    @gl.public.write.payable               # Accepts GEN tokens
    def pay(self) -> None:
        amount = gl.message.value
Enter fullscreen mode Exit fullscreen mode

Storage Types (Python → GenLayer Translation Guide)

Python Type GenLayer Equivalent Why the Change?
dict TreeMap[K, V] Blockchain-optimized (keys must support <)
list DynArray[T] Dynamic arrays that play nice with consensus
int u256, i64, etc. Fixed-size integers for determinism
custom class @allow_storage @dataclass Needs the decorator to persist on-chain

TreeMap Operations (Your New Best Friend)

self.data[key] = value                # Set
val = self.data[key]                  # Get (raises KeyError if missing)
val = self.data.get(key, default)     # Get with fallback
del self.data[key]                    # Delete
for k, v in self.data.items():        # Iterate
self.data.get_or_insert_default(key)  # Get or create with type default
Enter fullscreen mode Exit fullscreen mode

DynArray Operations (When You Need a List)

self.arr.append(item)                 # Add to end
self.arr.pop()                        # Remove last
self.arr[0]                           # Index access
len(self.arr)                         # Length
Enter fullscreen mode Exit fullscreen mode

Web Access (The Game Changer)

Your contracts can fetch live data from the internet. No oracles. No middleware. Just code.

# Rendered page as plain text (best for content extraction)
text = gl.nondet.web.render(url, mode="text")

# Raw HTML
html = gl.nondet.web.render(url, mode="html")

# Screenshot (returns Image object — can be passed to LLM)
img = gl.nondet.web.render(url, mode="screenshot")

# HTTP GET with headers
resp = gl.nondet.web.get(url, headers={"Authorization": "Bearer ..."})
# resp.status, resp.headers, resp.body

# HTTP POST
resp = gl.nondet.web.post(url, body="payload", headers={})
Enter fullscreen mode Exit fullscreen mode

LLM / AI Access (The Other Game Changer)

Your contracts can think. Run prompts through AI models and get structured responses.

# Free-form text response
answer = gl.nondet.exec_prompt("Summarize this article: ...")

# Structured JSON response (use this for reliable parsing)
result = gl.nondet.exec_prompt(
    "Classify this claim as true or false. "
    "Return ONLY JSON: {\"verdict\": \"<true|false>\"}",
    response_format="json"
)
# result is a Python dict — e.g., result["verdict"]

# With images (e.g., from a screenshot)
result = gl.nondet.exec_prompt(
    "Describe what you see in this image",
    images=[screenshot]
)
Enter fullscreen mode Exit fullscreen mode

Equivalence Principle (How Validators Agree on AI)

This is the mechanism that lets validators reach consensus on non-deterministic outputs. Pick your flavor:

Strict Equality (Start Here)

Outputs must be byte-for-byte identical. Perfect for constrained outputs.

def check():
    data = gl.nondet.web.render(url, mode="text")
    result = gl.nondet.exec_prompt(f"Analyze: {data}", response_format="json")
    return json.dumps(result, sort_keys=True)   # sort_keys is CRITICAL!

consensus_result = json.loads(gl.eq_principle.strict_eq(check))
Enter fullscreen mode Exit fullscreen mode

Comparative (For Fuzzy Matches)

An LLM judges whether two outputs are "close enough."

result = gl.eq_principle.prompt_comparative(
    my_fn,
    "Results are equivalent if ratings differ by less than 0.1"
)
Enter fullscreen mode Exit fullscreen mode

Non-Comparative (For Subjective Quality)

An LLM checks the leader's output against criteria. Other validators don't redo the work.

result = gl.eq_principle.prompt_non_comparative(
    task="Summarize the article",
    criteria="Summary must be accurate, concise, and under 100 words"
)
Enter fullscreen mode Exit fullscreen mode

Which One Should You Use?

Type Best For Validator Cost Agreement Rate
strict_eq Yes/no, categories, constrained JSON Lowest Highest ✓
prompt_comparative Numeric ranges, similar text Medium High
prompt_non_comparative Subjective quality, creative output Lowest Medium

Rule of thumb: Start with strict_eq. Only reach for the others when your output space is too wide for exact matching.


Common Patterns (Copy-Paste These)

Sender Address

sender = gl.message.sender_address          # Address object
sender_hex = gl.message.sender_address.as_hex  # "0x..." string
Enter fullscreen mode Exit fullscreen mode

Address Conversion

addr = Address("0x1234...")                 # From hex string
addr.as_hex                                 # To checksummed hex
addr.as_bytes                               # To bytes
Enter fullscreen mode Exit fullscreen mode

Contract Balance & Payments

self.balance                                # Contract's current GEN balance
gl.message.value                            # GEN sent with this transaction
Enter fullscreen mode Exit fullscreen mode

Reverting a Transaction

raise Exception("Claim has already been resolved")   # Reverts all state changes
Enter fullscreen mode Exit fullscreen mode

The Non-Deterministic Block Pattern (Most Important!)

This is the pattern. All web/AI calls must happen inside a function passed to an equivalence principle:

def fetch_and_analyze():
    page = gl.nondet.web.render(url, mode="text")
    result = gl.nondet.exec_prompt(
        f"Analyze this: {page}",
        response_format="json"
    )
    return json.dumps(result, sort_keys=True)  # ALWAYS sort keys!

consensus_result = json.loads(gl.eq_principle.strict_eq(fetch_and_analyze))
Enter fullscreen mode Exit fullscreen mode

Why? Because each validator needs to run this independently, and the equivalence principle compares their outputs.


Frontend Integration (genlayer-js)

Client Setup

import { createClient } from "genlayer-js";
import { studionet } from "genlayer-js/chains";

const client = createClient({
  chain: studionet,                       // or localnet, testnetAsimov
  account: "0xAddress" as `0x${string}`,  // required for write calls
});
Enter fullscreen mode Exit fullscreen mode

Read Contract (Free, No Gas)

const result = await client.readContract({
  address: contractAddress,
  functionName: "get_claims",
  args: [],
});
Enter fullscreen mode Exit fullscreen mode

Write Contract (Costs Gas)

const txHash = await client.writeContract({
  address: contractAddress,
  functionName: "submit_claim",
  args: ["The Earth is round", "https://en.wikipedia.org/wiki/Earth"],
  value: BigInt(0),
});

const receipt = await client.waitForTransactionReceipt({
  hash: txHash,
  status: "ACCEPTED",
  retries: 24,
  interval: 5000,
});
Enter fullscreen mode Exit fullscreen mode

Deploy Contract

await client.initializeConsensusSmartContract();
const txHash = await client.deployContract({
  code: contractCodeAsUint8Array,
  args: [],
});
Enter fullscreen mode Exit fullscreen mode

Converting TreeMap Results (Critical Pattern!)

genlayer-js returns Map objects for TreeMap storage. Convert them to plain JavaScript arrays:

if (result instanceof Map) {
  const items = Array.from(result.entries()).map(([key, value]) => {
    const obj = value instanceof Map
      ? Object.fromEntries(value.entries())
      : value;
    return { id: key, ...obj };
  });
}
Enter fullscreen mode Exit fullscreen mode

Testing with gltest (Making Sure It Works)

from gltest import get_contract_factory, default_account
from gltest.helpers import load_fixture
from gltest.assertions import tx_execution_succeeded

def deploy():
    factory = get_contract_factory("MyContract")
    return factory.deploy()

def test_basic_write():
    contract = load_fixture(deploy)       # Caches deploy across tests

    result = contract.my_method(args=["arg1", "arg2"])
    assert tx_execution_succeeded(result)

def test_basic_read():
    contract = load_fixture(deploy)

    data = contract.my_view_method(args=[])
    assert data == expected_value

def test_ai_call():
    contract = load_fixture(deploy)

    # AI/web calls are slow — configure longer polling
    result = contract.resolve(
        args=["claim_1"],
        wait_interval=10000,              # 10s between polls
        wait_retries=15,                  # up to 2.5 minutes total
    )
    assert tx_execution_succeeded(result)
Enter fullscreen mode Exit fullscreen mode

Run tests: gltest (requires GenLayer Studio to be running)


Prompt Engineering for On-Chain AI (5 Rules)

Your prompts need to produce consistent outputs that validators can agree on:

  1. Always use response_format="json" for machine-readable output
  2. Constrain the output space: "<true|false|partially_true>" beats free-form text
  3. Sort JSON keys: json.dumps(result, sort_keys=True) — absolutely essential for strict_eq
  4. Be explicit about format: "Respond ONLY with valid JSON, no extra text"
  5. Fewer fields = higher agreement: The smaller the output, the easier consensus

Good prompt:

"Classify as true/false. Return ONLY: {\"verdict\": \"\", \"reason\": \"\"}"

Bad prompt:

"What do you think about this claim?"


Environment Variables (Frontend Config)

# frontend/.env
NEXT_PUBLIC_GENLAYER_RPC_URL=https://studio.genlayer.com/api
NEXT_PUBLIC_GENLAYER_CHAIN_ID=61999
NEXT_PUBLIC_GENLAYER_CHAIN_NAME=GenLayer Studio
NEXT_PUBLIC_GENLAYER_SYMBOL=GEN
NEXT_PUBLIC_CONTRACT_ADDRESS=0xYourDeployedContractAddress
Enter fullscreen mode Exit fullscreen mode

Networks (Where to Deploy)

Network Chain ID RPC URL When to Use
studionet 61999 https://studio.genlayer.com/api Development (hosted Studio)
localnet -- http://localhost:8545 Development (local Studio)
testnet (Asimov) -- See docs Public testing before mainnet

Transaction Lifecycle (What's Happening Under the Hood)

Pending → Proposing → Committing → Revealing → Accepted → Finalized
                                                    ↓
                                                 Appeals? (escalating validators)
                                                    ↓
                                                Finalized (permanent)
Enter fullscreen mode Exit fullscreen mode

Resources (The Official Stuff)

Resource URL
SDK API Reference (complete) sdk.genlayer.com
Full Documentation docs.genlayer.com
genlayer-js SDK docs.genlayer.com/api-references/genlayer-js
GenLayer Studio studio.genlayer.com
Contract Examples docs.genlayer.com/.../examples

Quick Troubleshooting

Contract won't deploy?

  • Check that genlayer network is set correctly
  • Verify GenLayer Studio is running (if using localnet/studionet)
  • Make sure contract syntax is valid Python

Frontend can't connect?

  • Check contract address in .env
  • Verify MetaMask is on the right network
  • Check browser console for errors

Tests failing?

  • GenLayer Studio must be running for gltest
  • AI/web tests need longer wait_interval and wait_retries
  • Check that test data matches contract expectations

Validators disagreeing?

  • Use sort_keys=True in json.dumps()
  • Constrain LLM output space more tightly
  • Consider using prompt_comparative instead of strict_eq

Link to the truthpost app we built: TruthPost site

Top comments (0)