I came across GenLayer a couple of months ago and two things caught my attention immediately. The first was that smart contracts here can actually read the internet on their own, no oracles, no middleware, just the contract fetching live data as part of its logic. The second was that AI models participate directly in reaching consensus, meaning the network does not just execute code, it reasons about it. I had been building on other chains for a while and nothing had made me stop and rethink how blockchains actually work the way GenLayer did. That combination of web access and AI consensus felt like it opened up a completely different category of use cases, which is what pushed me to start building and documenting everything I learned along the way.
Bitcoin gave us trustless money. Ethereum gave us trustless computation. GenLayer gives us something new: trustless decision-making. Smart contracts that can read the web, call AI models, and reason about real-world events without any centralized intermediary.
In this tutorial you will build a P2P betting dApp from scratch. Two friends place a bet on a real-world event. When the event concludes, the Intelligent Contract fetches live data from the web, calls an LLM to determine the winner, and transfers the funds, all secured through Optimistic Democracy, GenLayer's consensus mechanism for non-deterministic operations.
By the end you will understand how Intelligent Contracts work, how the Equivalence Principle enables non-deterministic consensus, how to use GenLayer Studio, and how to connect a React frontend using the genlayer-js SDK.
Part 1: Understanding the Core Concepts
What Is an Intelligent Contract?
Traditional smart contracts are deterministic. Every node runs the same code and always gets the same result. This is perfect for token transfers but makes it impossible to interact with the real world. An Intelligent Contract breaks this limitation by wrapping non-deterministic operations like web fetches and LLM calls inside special non-deterministic blocks that are validated through Optimistic Democracy.
Optimistic Democracy:
When a transaction triggers a non-deterministic operation, multiple validators independently execute it. Each runs their own LLM and applies the Equivalence Principle to check whether their result matches the leader's proposal. If the majority agrees the results are equivalent (not necessarily identical), the transaction finalizes. If not, an appeal is triggered and the validator set doubles.
The Equivalence Principle:
This is what makes non-deterministic consensus possible. You define a leader function that fetches data and calls the LLM, and a validator function that independently re-runs the same process and compares results. For simple cases like a binary winner decision the validator requires an exact match. For numeric values like confidence scores you allow a small tolerance range. You implement this using gl.vm.run_nondet_unsafe with your own leader_fn and validator_fn.
One critical rule: non-deterministic operations like web fetches and LLM calls must always happen inside the leader function passed to gl.vm.run_nondet_unsafe. Calling them outside this context will throw an error.
Part 2: Setting Up GenLayer Studio
GenLayer Studio is your local development environment. It runs 5 simulated validator nodes in Docker, lets you deploy contracts, execute transactions, and watch Optimistic Democracy reach consensus in real time.
npm install -g genlayer
genlayer init
genlayer up
Open your browser at https://studio.genlayer.com You will see the Studio interface with five validator nodes, each configured with a different LLM provider. This is intentional. The Equivalence Principle is designed to work across different models, not the same one.
Part 3: Writing the Intelligent Contract
The official syntax for Intelligent Contracts uses from genlayer import * and extends gl.Contract. Storage variables are declared as class-level type annotations. Public methods use @gl.public.write or @gl.public.view decorators. Non-deterministic operations happen inside a leader_fn passed to gl.vm.run_nondet_unsafe.
Let's build the contract in pieces so each section is easy to follow.
3.1 The File Header and Storage
Every Intelligent Contract starts with a dependency comment and the import. Storage variables are declared as class-level type annotations and are automatically persisted on-chain by the GenVM.
# { "Depends": "py-genlayer:test" }
from genlayer import *
import json
import typing
class P2PBet(gl.Contract):
# Storage variables declared as type annotations at class level
# The GenVM automatically persists these on-chain
creator: str
opponent: str
event_description: str
creator_prediction: str
opponent_prediction: str
resolution_url: str
creator_funded: bool
opponent_funded: bool
wager_amount: int
resolved: bool
winner: str
score: str
Notice there are no __init__ calls here yet. Storage variables in GenLayer are declared at class level, not inside the constructor. This is different from regular Python classes.
3.2 The Constructor
The constructor receives the initial parameters and sets up the contract state. The caller's address is available via gl.message.sender_address.
def __init__(
self,
opponent: str,
event_description: str,
creator_prediction: str,
opponent_prediction: str,
resolution_url: str,
):
# gl.message.sender_address gives us the deployer's wallet address
self.creator = str(gl.message.sender_address)
self.opponent = opponent
self.event_description = event_description
self.creator_prediction = creator_prediction
self.opponent_prediction = opponent_prediction
self.resolution_url = resolution_url
self.creator_funded = False
self.opponent_funded = False
self.wager_amount = 0
self.resolved = False
self.winner = ""
self.score = ""
3.3 The Fund Method
The @gl.public.write.payable decorator marks this method as one that can receive GEN tokens. The amount sent is available via gl.message.value.
@gl.public.write.payable
def fund_bet(self) -> str:
assert not self.resolved, "Bet is already resolved"
amount = gl.message.value
if gl.message.sender_address == self.creator:
assert not self.creator_funded, "Creator already funded"
assert amount > 0, "Must send tokens to fund"
self.creator_funded = True
self.wager_amount = amount
return f"Creator funded {amount} tokens"
elif gl.message.sender_address == self.opponent:
assert not self.opponent_funded, "Opponent already funded"
assert amount == self.wager_amount, "Must match the creator's wager"
self.opponent_funded = True
return f"Opponent funded {amount} tokens"
raise Exception("You are not a participant in this bet")
3.4 The Resolve Method / Where the Magic Happens
This is the most important part of the contract. The resolve method uses gl.vm.run_nondet_unsafe with a leader_fn that fetches web data and calls the LLM, and a validator_fn that independently re-runs the same process and compares results. This is the correct pattern for GenLayer Testnet Bradbury.
@gl.public.write
def resolve(self) -> str:
if self.resolved:
return "Already resolved"
assert self.creator_funded and self.opponent_funded, \
"Both parties must fund before resolution"
# Capture storage variables before entering the non-deterministic block.
# Storage is NOT accessible from inside the inner functions.
resolution_url = self.resolution_url
event_description = self.event_description
creator_prediction = self.creator_prediction
opponent_prediction = self.opponent_prediction
creator = self.creator
opponent = self.opponent
Now the non-deterministic block itself. The leader function runs on the leader validator and the validator function runs independently on each of the other validators:
def leader_fn():
# Each validator independently fetches this URL
response = gl.nondet.web.get(resolution_url)
web_data = response.body.decode("utf-8")
task = f"""You are an impartial judge for a P2P bet.
Event: {event_description}
Creator ({creator[:8]}...) predicted: {creator_prediction}
Opponent ({opponent[:8]}...) predicted: {opponent_prediction}
Web page data from {resolution_url}:
{web_data[:3000]}
Based only on the web data above, determine who won the bet.
Respond ONLY with valid JSON (no markdown, no extra text):
{{
"winner": "{creator}" or "{opponent}" or "draw" or "unresolved",
"winner_label": "creator" or "opponent" or "draw" or "unresolved",
"score": "e.g. 2-1 or N/A",
"reasoning": "one sentence"
}}"""
result = gl.nondet.exec_prompt(task) \
.replace("```
json", "") \
.replace("
```", "")
# sort_keys=True ensures the JSON string is identical
# across validators even if key order differs
return json.dumps(json.loads(result), sort_keys=True)
def validator_fn(leader_result) -> bool:
if not isinstance(leader_result, gl.vm.Return):
return False
try:
validator_raw = leader_fn()
leader_data = json.loads(leader_result.calldata)
validator_data = json.loads(validator_raw)
# winner_label must match exactly
return leader_data["winner_label"] == validator_data["winner_label"]
except Exception:
return False
Finally, pass the functions to gl.vm.run_nondet_unsafe and handle the result:
# run_nondet_unsafe coordinates the leader and validators
# through Optimistic Democracy consensus
result_json = json.loads(gl.vm.run_nondet_unsafe(leader_fn, validator_fn))
winner_label = result_json.get("winner_label", "unresolved")
if winner_label not in ("unresolved", "draw"):
self.resolved = True
self.winner = result_json.get("winner", "")
self.score = result_json.get("score", "N/A")
total = self.wager_amount * 2
gl.transfer(self.winner, total)
return json.dumps(result_json)
The contract deployed and running in GenLayer Studio with gl.vm.run_nondet_unsafe visible on line 90 Consensus FINALIZED shown in the logs at the bottom
3.5 The Read Method
Read methods use @gl.public.view. They are free, require no transaction, and return data instantly.
@gl.public.view
def get_state(self) -> dict:
return {
"creator": self.creator,
"opponent": self.opponent,
"event_description": self.event_description,
"creator_prediction": self.creator_prediction,
"opponent_prediction": self.opponent_prediction,
"creator_funded": self.creator_funded,
"opponent_funded": self.opponent_funded,
"wager_amount": self.wager_amount,
"resolved": self.resolved,
"winner": self.winner,
"score": self.score,
}
Part 4: Testing in GenLayer Studio
First create a new file in Studio called p2p_bet.py and paste the full contract code. Then click the Deploy button on the left panel. Before the deployment executes you will see a form asking for the constructor parameters. Fill them in like this:
opponent: paste your own Studio wallet address shown in the top right corner of the screen
event_description: Who won the 2022 FIFA World Cup?
creator_prediction: Argentina won the 2022 FIFA World Cup
opponent_prediction: France won the 2022 FIFA World Cup
resolution_url: https://en.wikipedia.org/wiki/2022_FIFA_World_Cup_Final
Click Deploy and wait for the transaction to show FINALIZED in the Transactions panel on the left.
These values give the contract a real verifiable event with a clear outcome so you can see the AI judge working correctly.
Once deployed, call fund_bet from both the creator and opponent accounts using the value field to send tokens. Then call resolve and watch the validator logs. You will see each of the 5 validators independently fetch the URL and call their LLM. The validator function then compares their sorted JSON outputs and reaches consensus through Optimistic Democracy.
The contract state after resolution / Resolved: True, Winner: creator
Five validators independently reaching consensus through Optimistic Democracy / all SUCCESS and Agree
Part 5: Connecting the Frontend with genlayer-js
The official SDK is genlayer-js. Install it with:
npm install genlayer-js
Setting Up the Client
import { simulator } from 'genlayer-js/chains';
import { createClient, createAccount } from 'genlayer-js';
const account = createAccount();
const client = createClient({
chain: simulator,
account: account,
});
export const CONTRACT_ADDRESS = '0xYourContractAddressHere';
Reading Contract State
Functions decorated with @gl.public.view are free reads with no transaction required.
const state = await client.readContract({
address: CONTRACT_ADDRESS,
functionName: 'get_state',
args: [],
});
console.log(state.resolved, state.winner, state.score);
Funding the Bet
import { TransactionStatus } from 'genlayer-js/types';
const txHash = await client.writeContract({
address: CONTRACT_ADDRESS,
functionName: 'fund_bet',
args: [],
value: BigInt(10) * BigInt(10 ** 18), // 10 GEN in wei
});
const receipt = await client.waitForTransactionReceipt({
hash: txHash,
status: TransactionStatus.FINALIZED,
});
Triggering the AI Resolution
const resolveTx = await client.writeContract({
address: CONTRACT_ADDRESS,
functionName: 'resolve',
args: [],
});
// LLM calls across 5 validators can take around 60 seconds
const resolveReceipt = await client.waitForTransactionReceipt({
hash: resolveTx,
status: TransactionStatus.FINALIZED,
interval: 5_000,
retries: 30,
});
const finalState = await client.readContract({
address: CONTRACT_ADDRESS,
functionName: 'get_state',
args: [],
});
console.log('Winner:', finalState.winner);
console.log('Score:', finalState.score);
Part 6: Deploying to Testnet Bradbury
When you are ready to go public, switch from the local simulator to Testnet Bradbury:
import { testnetBradbury } from 'genlayer-js/chains';
import { createClient, createAccount } from 'genlayer-js';
const account = createAccount();
const client = createClient({
chain: testnetBradbury,
account: account,
});
Deploy via the CLI:
genlayer network set testnetBradbury
genlayer deploy p2p_bet.py \
--args '["0xOpponentAddress", "Real Madrid vs Barcelona", "Real Madrid wins", "Barcelona wins", "https://www.bbc.com/sport/football"]'
What You Built
In this tutorial you wrote a complete Intelligent Contract using the official gl.Contract pattern with correct storage declarations and public method decorators. You used gl.nondet.web.get() inside a leader function to fetch live data, called the LLM with gl.nondet.exec_prompt(), and used gl.vm.run_nondet_unsafe with a custom validator function to achieve reliable consensus across all validators. You deployed and tested the contract in GenLayer Studio, watched Optimistic Democracy reach consensus in real time, and connected a React frontend using readContract, writeContract, and waitForTransactionReceipt from the genlayer-js SDK.
The key insight is this: you never needed an oracle, a trusted API, or a centralized server. The contract itself fetched the data and reasoned about it, and five independent validators verified that reasoning was correct.
That is what makes GenLayer different from anything that came before it.
Join the GenLayer Community
GenLayer is being built in the open, and the community is where everything happens. Whether you want to share what you built, ask questions, or just follow along, come join us.
Discord: The main hub for builders, validators, and the core team. Drop by #dev-chat if you have questions about your contract.
discord.gg/8Jm4v89VAu
X (Twitter): Follow for announcements, new tutorials, and community highlights.
x.com/GenLayer
Website: Learn more about what GenLayer is building and the vision behind it.
genlayer.com
Documentation: Everything you need to keep building. Official contract examples, SDK reference, and more.
docs.genlayer.com



Top comments (0)