A hands-on guide to Intelligent Contracts, Optimistic Democracy, and building your first AI-powered decentralized application.
What You'll Learn Part 1 - Understanding GenLayer's Core Ideas
- What is GenLayer?
- Optimistic Democracy Consensus
- The Equivalence Principle Part 2 - Setting Up Your Environment
- Installing the GenLayer CLI
- Using GenLayer Studio Part 3 - Writing the Intelligent Contract (Python)
- Designing the Dispute Contract
- Full Contract Code
- Code Walkthrough
- Common Pitfalls I Encountered Part 4 - Deploying & Testing in Studio Part 5 - Building the Frontend with genlayer-js
- Project Setup
- Connecting to the Hosted Studio
- Frontend Code (Updated & Working)
- Troubleshooting Real Issues Part 6 - Where to Go From Here
Welcome! In this tutorial, you'll go from knowing nothing about GenLayer to having a fully working Dispute Resolution dApp - a mini application where two parties submit their side of a disagreement, and GenLayer's AI-powered validators autonomously decide who's right.
This project is perfect for learning GenLayer because it exercises every key feature: non-deterministic AI reasoning, the Equivalence Principle, and a frontend that interacts with the contract via genlayer-js.
Prerequisites: Basic Python knowledge, familiarity with JavaScript/TypeScript, and comfort using a terminal. No blockchain experience is required.
¬ PART 1
What is GenLayer?
Traditional blockchains are deterministic - the same input always produces the same output. That's great for math-based operations like transferring tokens, but it means smart contracts can't reason about subjective, real-world situations. They can't read a website, interpret a clause in natural language, or judge whether a task was completed satisfactorily.
GenLayer changes this. It's the first blockchain designed to handle non-deterministic operations natively. Its smart contracts - called Intelligent Contracts - are written in Python and can call LLMs (like GPT or LLaMA), fetch live web data, and make subjective decisions, all while maintaining blockchain-grade trust and security.
Think of it this way: Bitcoin gave us trustless money, Ethereum gave us trustless computation, and GenLayer gives us trustless decision-making.
Optimistic Democracy Consensus
How does GenLayer reach agreement when AI outputs are inherently variable? Through a consensus mechanism called Optimistic Democracy, which is an enhanced Delegated Proof of Stake model. Here's how it works:
KEY INSIGHT: Each validator on GenLayer runs its own AI model. When a transaction contains a non-deterministic component - like interpreting a dispute - each validator processes it independently. They don't need to produce identical outputs. They just need to produce equivalent outputs. That's where the Equivalence Principle comes in.
If there's a disagreement among validators, an appeal process kicks in: more validators are recruited to re-evaluate, and the dispute is settled by a larger jury. This is inspired by Condorcet's Jury Theorem - the idea that a majority of independent, reasonably accurate decision-makers will almost certainly reach the correct conclusion.
The Equivalence Principle
The Equivalence Principle is the mechanism that lets validators agree on non-deterministic results. As a developer, you define what "equivalent" means for your contract. GenLayer provides three built-in strategies:
- Strict Equality - gl.eq_principle_strict_eq(fn)
Validators must produce the exact same output. Best for deterministic-like operations - for example, checking whether a webpage contains a specific keyword (returns True or False).
Comparative - gl.eq_principle_prompt_comparative(fn, principle)
Both the leader and validators perform the same task, then an LLM compares their results using the principle you provide. Example principle: "Both answers should identify the same winning party and give similar reasoning."Non-Comparative - gl.eq_principle_prompt_non_comparative(fn, task=…, criteria=…)
Only the leader performs the task. Validators then evaluate the leader's output against your criteria without re-doing the work. This is faster and cheaper - ideal for subjective outputs like text summaries or, in our case, dispute rulings.
In this Dispute Resolution dApp, I'll use the comparative principle: both leader and validators will independently analyze the dispute, and I'll define that their rulings are "equivalent" if they pick the same winner and provide logically consistent reasoning.
¬PART 2
GenLayer provides two ways to develop: a local environment via the CLI, or the hosted GenLayer Studio at studio.genlayer.com. For this tutorial, I used the hosted Studio - it's the fastest way to get started with zero Docker setup.
Option A: Hosted Studio (What I Used)
Simply visit studio.genlayer.com in your browser. No installation required. You get a code editor, deployment tools, and transaction logs immediately.
Option B: Local Development
If you prefer running locally, you'll need Docker 26+, Node.js 18+, and Python 3.10+:
Install the CLI globally
npm install -g genlayer
Initialize your local environment
genlayer init
During initialization, you'll be prompted to select your preferred LLM provider(s) and enter API keys. The setup spins up a local GenLayer network with 5 validators. Once complete, the Studio opens at http://localhost:3000/.
¬ PART 3
Here's the idea for our dApp: two parties have a disagreement. Each submits their side of the story. The contract uses an LLM to analyze both arguments and deliver a ruling - all on-chain, all trustless. No judge, no middleman, no bias.
Let's think through the design:
State: We need to store the dispute topic, both parties' arguments, the ruling, and a status field.
Write methods: submit_argument() for each party, and resolve_dispute() to trigger AI-powered resolution.
Read methods: get_dispute() to view the current state.
Equivalence: When resolving, we'll use eq_principle_prompt_comparative so that multiple validators independently analyze the dispute and agree on the outcome.
Full Contract Code
{ "Depends": "py-genlayer:test" }
from genlayer import *
class DisputeResolver(gl.Contract): # ── State variables ── topic: str argument_a: str argument_b: str ruling: str status: str # "open", "pending", "resolved"
def __init__(self, topic: str):
"""Deploy the contract with a dispute topic."""
self.topic = topic
self.argument_a = ""
self.argument_b = ""
self.ruling = ""
self.status = "open"
@gl.public.write
def submit_argument(self, argument: str):
"""Either party submits their side of the dispute."""
if self.status != "open":
raise Exception("Dispute is no longer accepting arguments")
if self.argument_a == "":
self.argument_a = argument
elif self.argument_b == "":
self.argument_b = argument
self.status = "pending"
else:
raise Exception("Both arguments already submitted")
@gl.public.write
def resolve_dispute(self):
"""Trigger AI-powered dispute resolution."""
if self.status != "pending":
raise Exception("Dispute is not ready for resolution")
topic = self.topic
arg_a = self.argument_a
arg_b = self.argument_b
def analyze_dispute():
prompt = f"""You are an impartial arbitrator. Analyze this dispute and provide a fair ruling.
DISPUTE TOPIC: {topic}
PARTY A's ARGUMENT: {arg_a}
PARTY B's ARGUMENT: {arg_b}
Provide your ruling in this exact format: WINNER: [Party A or Party B or Draw] REASONING: [Your 2–3 sentence explanation]"""
result = gl.exec_prompt(prompt)
return result
# Comparative equivalence: both leader and validators
# independently analyze, then compare results
self.ruling = gl.eq_principle_prompt_comparative(
analyze_dispute,
"""The rulings are equivalent if they identify the same
winning party (Party A, Party B, or Draw) and the reasoning is logically consistent, even if worded differently.""" ) self.status = "resolved"
@gl.public.view
def get_dispute(self) -> dict:
"""Return the full dispute state."""
return {
"topic": self.topic,
"argument_a": self.argument_a,
"argument_b": self.argument_b,
"ruling": self.ruling,
"status": self.status,
}
problems i encountered
When I first wrote this contract, I ran into two issues that aren't obvious from the docs:
Don't use @gl.contract decorator - the class just needs to extend gl.Contract. Adding the decorator caused deployment errors.
Don't use Address type fields - I originally had party_a: Address and party_b: Address, but these caused schema parsing errors in Studio. Sticking to str for all state variables works reliably.
These are the kinds of gotchas you only discover by building - and exactly why hands-on tutorials matter.
Code Walkthrough
The dependency header # { "Depends": "py-genlayer:test" } tells the GenVM which SDK version to use. Every Intelligent Contract needs this.
The class declaration - DisputeResolver extends gl.Contract, which is the base class that ensures the contract runs correctly inside GenLayer's execution environment. State variables are declared as type-annotated class attributes (like topic: str); GenLayer automatically handles their on-chain storage.
The init method runs once at deployment. It takes the dispute topic as a constructor argument and initializes all state.
submit_argument() is decorated with @gl.public.write, meaning it modifies state and requires a transaction. The logic is simple: first caller becomes Party A, second becomes Party B, and after both submit, the status moves to "pending".
resolve_dispute() is where the magic happens. Notice the pattern:
We define an inner function analyze_dispute() that calls gl.exec_prompt() - this sends a prompt to whatever LLM the validator is running.
We wrap that function with gl.eq_principle_prompt_comparative(), passing our equivalence principle as the second argument.
The leader validator runs analyze_dispute() and proposes a result. Other validators also run it independently, then compare their output to the leader's using the principle we defined.
If the majority agrees the results are equivalent (same winner, consistent logic), the transaction is accepted and the ruling is stored on-chain.
WHY THIS PATTERN MATTERS: The inner-function-plus-equivalence-wrapper pattern is the core of every non-deterministic operation in GenLayer. All calls to gl.exec_prompt() or gl.get_webpage() must happen inside a function passed to an eq_principle method. This is what allows multiple validators to independently verify the result. Think of it as GenLayer's version of "trust, but verify."
get_dispute() is a read-only view method (no transaction needed) that returns the full dispute state as a dictionary.
¬ PART 4
Now let's see our contract in action. Open GenLayer Studio (either local at http://localhost:3000/ or hosted at studio.genlayer.com).
Step 1
Load the Contract In the Studio's code editor, paste the full contract code from above. The editor supports Python syntax highlighting and will flag basic errors.
Step 2
Deploy In the deploy section, our contract takes one constructor argument - the dispute topic. Enter something like:
"Who makes better pizza: New York or Chicago?"
Click Deploy. You'll see the transaction process through the validators. Once finalized, note your contract address - you'll need it for the frontend. The address appears in the left sidebar (it looks like 0x33…A538).
Step 3
Submit Arguments In the "Write Methods" section, expand submit_argument and call it twice:
First call (Party A): "New York pizza is superior because the thin, foldable crust allows the quality of ingredients to shine. The high-gluten bread flour creates the perfect crispy-yet-chewy texture."
Second call (Party B): "Chicago deep-dish is the true pizza because it is a complete meal. The buttery crust, layered cheese, and chunky tomato sauce create a richer, more satisfying experience."
Step 4
Verify the State Call get_dispute() in the "Read Methods" section. You should see both arguments stored and the status as "pending".
Step 5
Resolve the Dispute Call resolve_dispute() in Write Methods. Watch the logs - you'll see the leader validator send the prompt to its LLM, generate a ruling, and then the other validators independently do the same and compare results. This can take 30–60 seconds on the hosted Studio.
Step 6: Read the Result Call get_dispute() again. You should now see the AI-generated ruling and the status set to "resolved".
Debugging Tips
If a transaction fails, check the Studio's log panel at the bottom. Common issues:
Transaction stuck at "PROPOSING" - the hosted Studio can be slow. Wait 30–60 seconds.
"Both arguments already submitted" - the contract already has two arguments. Deploy a fresh instance.
Syntax errors appear on a red panel - check for Python indentation issues.
¬PART 5
Now let's build a web frontend so real users can interact with our contract. We'll use the GenLayer project boilerplate and genlayer-js - the official TypeScript SDK.
Step 1: Clone the Boilerplate
git clone https://github.com/genlayerlabs/genlayer-project-boilerplate
cd genlayer-project-boilerplate/frontend npm install
Windows users:
Use PowerShell.
The boilerplate structure has frontend/app/page.tsx (no src folder).
Step 2: Configure the Environment
Linux/Mac
cp .env.example .env
Windows PowerShell
copy .env.example .env
Open .env and set your values:
NEXT_PUBLIC_GENLAYER_RPC_URL=https://studio.genlayer.com/api NEXT_PUBLIC_CONTRACT_ADDRESS=0xYOUR_DEPLOYED_CONTRACT_ADDRESS
IMPORTANT NOTES:
The RPC URL for the hosted Studio must include /api at the end.
Get your contract address from Studio's sidebar - it's the short hex address shown next to "Deployed at", NOT the long transaction hash.
Common mistake: Transaction hashes are 64+ hex characters. Contract addresses are 40 hex characters (after 0x). If your address looks too long, you've copied the wrong thing.
Step 3: Understanding genlayer-js
The genlayer-js SDK follows a client-based pattern:
import { createClient, createAccount } from 'genlayer-js'; import { TransactionStatus } from 'genlayer-js/types';
// Create an account and client const account = createAccount(); const client = createClient({ chain: { id: 61999, name: "genlayer-studio", … }, account: account, });
// READ from a contract (free, no transaction) const dispute = await client.readContract({ address: contractAddress, functionName: 'get_dispute', args: [], });
// WRITE to a contract (sends a transaction) const hash = await client.writeContract({ address: contractAddress, functionName: 'submit_argument', args: ['My argument text here'], });
// Wait for confirmation const receipt = await client.waitForTransactionReceipt({ hash: hash, status: TransactionStatus.ACCEPTED, });
Key patterns:
readContract() is free and instant - it queries current state without a transaction.
writeContract() sends a transaction and returns a hash immediately.
waitForTransactionReceipt() polls until the transaction reaches your desired status. Use ACCEPTED (not FINALIZED) for the hosted Studio - it's much faster.
Step 4: The Frontend Code (Updated & Working)
This is the real, tested code that actually works with the hosted GenLayer Studio. Replace the contents of frontend/app/page.tsx with this:
"use client";
import { useState, useEffect, useCallback } from "react"; import { createClient, createAccount } from "genlayer-js"; import { TransactionStatus } from "genlayer-js/types";
const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS!; const RPC_URL = process.env.NEXT_PUBLIC_GENLAYER_RPC_URL!;
interface DisputeState { topic: string; argument_a: string; argument_b: string; ruling: string; status: string; }
export default function DisputePage() { // Create the GenLayer client with a custom chain config // pointing to the hosted Studio RPC const [client] = useState(() => { const account = createAccount(); return createClient({ chain: { id: 61999, name: "genlayer-studio", nativeCurrency: { name: "GEN", symbol: "GEN", decimals: 18 }, rpcUrls: { default: { http: [RPC_URL] }, }, }, account: account, }); });
const [dispute, setDispute] = useState<DisputeState | null>(null);
const [argument, setArgument] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Fetch dispute state via direct JSON-RPC call
// We use a direct fetch because the hosted Studio's gen_call
// method returns hex-encoded data that needs manual decoding
const fetchDispute = useCallback(async () => {
try {
const response = await fetch(RPC_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "gen_call",
params: [{
type: "read",
to: CONTRACT_ADDRESS,
from: "0x0000000000000000000000000000000000000000",
data: "0xd6940e066d6574686f645c6765745f6469737075746500",
transaction_hash_variant: "latest-nonfinal",
}],
id: 1,
}),
});
const json = await response.json();
if (json.result) {
// Decode hex-encoded response to text
const hex = json.result.startsWith("0x")
? json.result.slice(2) : json.result;
const bytes = new Uint8Array(
hex.match(/.{1,2}/g)!.map((b: string) => parseInt(b, 16))
);
const text = new TextDecoder().decode(bytes);
// Extract fields using regex
// (the response uses a binary serialization format, not JSON)
const topicMatch = text.match(/topic[^\w]*([\s\S]*?)(?=argument_a)/);
const argAMatch = text.match(/argument_a[^\w]*([\s\S]*?)(?=argument_b)/);
const argBMatch = text.match(/argument_b[^\w]*([\s\S]*?)(?=ruling)/);
const rulingMatch = text.match(/ruling[^\w]*([\s\S]*?)(?=status)/);
const statusMatch = text.match(/status[^\w]*([\s\S]*?)$/);
setDispute({
topic: topicMatch
? topicMatch[1].replace(/[\x00-\x1f]/g, "").trim() : "",
argument_a: argAMatch
? argAMatch[1].replace(/[\x00-\x1f]/g, "").trim() : "",
argument_b: argBMatch
? argBMatch[1].replace(/[\x00-\x1f]/g, "").trim() : "",
ruling: rulingMatch
? rulingMatch[1].replace(/[\x00-\x1f]/g, "").trim() : "",
status: statusMatch
? statusMatch[1].replace(/[\x00-\x1f]/g, "").trim() : "open",
});
setError("");
}
} catch (err: any) {
setError("Failed to load dispute: " + err.message);
}
}, []);
useEffect(() => { fetchDispute(); }, [fetchDispute]);
// Submit an argument (uses genlayer-js writeContract)
async function handleSubmitArgument() {
if (!argument.trim()) return;
setLoading(true);
setError("");
try {
const txHash = await client.writeContract({
address: CONTRACT_ADDRESS,
functionName: "submit_argument",
args: [argument],
value: 0,
});
await client.waitForTransactionReceipt({
hash: txHash,
status: TransactionStatus.ACCEPTED,
});
setArgument("");
await fetchDispute();
} catch (err: any) {
setError("Failed to submit: " + err.message);
}
setLoading(false);
}
// Trigger AI-powered resolution
async function handleResolve() {
setLoading(true);
setError("");
try {
const txHash = await client.writeContract({
address: CONTRACT_ADDRESS,
functionName: "resolve_dispute",
args: [],
value: 0,
});
await client.waitForTransactionReceipt({
hash: txHash,
status: TransactionStatus.ACCEPTED,
});
await fetchDispute();
} catch (err: any) {
setError("Resolution failed: " + err.message);
}
setLoading(false);
}
// Loading state
if (!dispute) {
return (
<div style={{ padding: "40px", fontFamily: "sans-serif" }}>
<p>Loading dispute...</p>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}
// Main UI
return (
<div style={{
maxWidth: 700, margin: "0 auto",
padding: "40px 20px", fontFamily: "sans-serif"
}}>
<h1>⚖️ Dispute Resolution</h1>
<h2>{dispute.topic}</h2>
<p>
Status:{" "}
<span style={{
padding: "4px 12px", borderRadius: "12px",
fontSize: "14px", fontWeight: "bold", color: "#fff",
background: dispute.status === "open" ? "#3b82f6"
: dispute.status === "pending" ? "#f59e0b" : "#22c55e",
}}>
{dispute.status.toUpperCase()}
</span>
</p>
{dispute.argument_a && (
<div style={{
background: "#f0f4ff", padding: "16px",
borderRadius: "8px", margin: "16px 0"
}}>
<strong>Party A says:</strong>
<p>{dispute.argument_a}</p>
</div>
)}
{dispute.argument_b && (
<div style={{
background: "#fff7ed", padding: "16px",
borderRadius: "8px", margin: "16px 0"
}}>
<strong>Party B says:</strong>
<p>{dispute.argument_b}</p>
</div>
)}
{dispute.status === "open" && (
<div style={{ margin: "24px 0" }}>
<h3>Submit Your Argument</h3>
<textarea value={argument}
onChange={(e) => setArgument(e.target.value)}
placeholder="Type your side of the dispute here..."
rows={5}
style={{
width: "100%", padding: "12px", fontSize: "16px",
borderRadius: "8px", border: "1px solid #ccc",
}}
/>
<button onClick={handleSubmitArgument}
disabled={loading || !argument.trim()}
style={{
marginTop: "12px", padding: "12px 24px",
fontSize: "16px",
background: loading ? "#ccc" : "#3b82f6",
color: "#fff", border: "none", borderRadius: "8px",
cursor: loading ? "not-allowed" : "pointer",
}}>
{loading ? "Submitting..." : "Submit Argument"}
</button>
</div>
)}
{dispute.status === "pending" && (
<div style={{ margin: "24px 0" }}>
<p>Both sides have submitted. Ready for AI resolution!</p>
<button onClick={handleResolve} disabled={loading}
style={{
padding: "16px 32px", fontSize: "18px",
background: loading ? "#ccc" : "#7c3aed",
color: "#fff", border: "none", borderRadius: "8px",
cursor: loading ? "not-allowed" : "pointer",
}}>
{loading ? "⏳ AI is deliberating..." : "⚖️ Resolve Dispute"}
</button>
</div>
)}
{dispute.status === "resolved" && (
<div style={{
background: "#f0fdf4", border: "2px solid #22c55e",
padding: "24px", borderRadius: "12px", margin: "24px 0",
}}>
<h2>📜 Ruling</h2>
<p style={{ whiteSpace: "pre-wrap" }}>{dispute.ruling}</p>
</div>
)}
{error && (
<p style={{ color: "red", marginTop: "16px" }}>{error}</p>
)}
</div>
);
}
Why Direct RPC Instead of readContract()?
You might notice that fetchDispute() uses a raw fetch() call to the RPC endpoint instead of client.readContract(). Here's why:
When connecting to the hosted GenLayer Studio, the SDK's readContract() returned an empty object {}. After debugging with browser DevTools, I discovered that the hosted Studio's gen_call method returns hex-encoded binary data that the SDK wasn't automatically decoding.
By capturing the exact RPC payload that Studio itself uses (via the Network tab in DevTools), I was able to replicate the call format and manually decode the hex response. The data field "0xd6940e06…" is the encoded call to the get_dispute function.
This is a great example of real-world debugging - sometimes the SDK doesn't handle every edge case, and you need to go lower-level. The writeContract() function works fine through the SDK for sending transactions.
Step 5: Run the Frontend
npm run dev
Open http://localhost:3000 in your browser.
Troubleshooting Real Issues I Encountered
"Unable to acquire lock" when running npm run dev:
Another dev server instance is running.
Fix it:
Windows PowerShell
taskkill /F /IM node.exe Remove-Item .next\dev\lock -Force npm run dev
Linux/Mac
killall node rm -f .next/dev/lock npm run dev
"Incorrect address format" error: You pasted a transaction hash instead of a contract address. Go to Studio, find the short address shown next to your deployed contract in the sidebar, and copy that instead.
readContract() returning empty {}: This is the main issue with the hosted Studio. The frontend code above uses the direct RPC workaround. If you're running a local Studio, the standard readContract() approach should work fine.
"Transaction status is not FINALIZED" timeout: The hosted Studio can be slow to finalize. Use TransactionStatus.ACCEPTED instead of TransactionStatus.FINALIZED for faster confirmation.
Duplicate page files causing errors: If you accidentally have both page.ts and page.tsx in the app/ folder, Next.js will throw "Duplicate page detected". Delete the .ts file and keep only page.tsx.
.env changes not taking effect: After editing .env, you must restart the dev server (Ctrl+C then npm run dev). Also do a hard refresh in the browser with Ctrl+Shift+R.
¬ PART 6
Congratulations - you've built a fully functional Dispute Resolution dApp on GenLayer! Let's recap what you've learned:
Optimistic Democracy - how validators with diverse AI models reach consensus on subjective outcomes
The Equivalence Principle - the three strategies for defining what "agreement" means for your contract
Intelligent Contracts - writing Python contracts that call LLMs and make AI-powered decisions
GenLayer Studio - deploying, testing, and debugging contracts interactively
genlayer-js - building a frontend that reads from and writes to your contract
Real-world debugging - working through RPC format issues, chain configuration, and SDK edge cases
Ideas to Extend This Project
Add web evidence: Use gl.get_webpage() inside the resolution function to let the contract fetch real-world evidence (news articles, records) to inform its ruling.
Multi-round disputes: Allow parties to submit rebuttals before final resolution.
Token staking: Require both parties to stake GEN tokens, with the winner receiving the loser's stake.
Non-comparative evaluation: Try switching to eq_principle_prompt_non_comparative for faster resolution - the validators evaluate the leader's ruling instead of running their own analysis.
Multiple disputes: Use TreeMap to store multiple disputes in a single contract.
Resources
GenLayer: the intelligence layer of the Internet - Documentation
GenLayer the intelligence layer of the Internet - Documentation.docs.genlayer.com
GitHub - genlayerlabs/genlayer-project-boilerplate
Contribute to genlayerlabs/genlayer-project-boilerplate development by creating an account on GitHub.github.com
https://github.com/genlayerlabs/genlayer-js
GenLayer - The Intelligence Layer of the Internet
GenLayer is a new L2 Blockchain that shatters the bounds of Smart Contracts by making them AI-Powered and connected to…studio.genlayer.com
https://sdk.genlayer.com








Top comments (0)