Recap
Welcome back. In Part 1 of this series, we covered what GenLayer is — an AI-native blockchain where "Intelligent Contracts" can call LLMs and fetch live web data right from on-chain code. We also got GenLayer Studio up and running so we have a local playground ready to go. If you missed it, go back and read Part 1 first. Now it is time to actually write some code.
What We're Building
We are building GenBets — a peer-to-peer betting contract where users can wager on subjective, real-world outcomes. Here is the flow: a creator posts a bet with a description (like "Inception will have above 80% on Rotten Tomatoes") and names an opponent. The opponent accepts the bet. Then anyone can trigger resolution, at which point the AI validators fetch a web page, read the actual data, reason about who won, and record the winner on-chain. No oracles. No manual judging. The AI handles it.
Contract Architecture
Before we dive into the code line by line, let us look at the big picture. The GenBets contract has six public methods:
| Method | Decorator | What It Does |
|---|---|---|
create_bet() |
@gl.public.write |
Creates a new bet with a named opponent |
accept_bet() |
@gl.public.write |
Opponent accepts the bet |
cancel_bet() |
@gl.public.write |
Creator cancels an open bet |
resolve_bet() |
@gl.public.write |
AI fetches web data, evaluates the outcome, records the winner |
get_bet() |
@gl.public.view |
Returns all details of a specific bet |
get_bet_count() |
@gl.public.view |
Returns how many bets have been created |
Let us talk about those decorators. They tell GenLayer how a method behaves:
-
@gl.public.write— This method modifies the contract's state. When a user calls a write method, the transaction goes through the validator consensus process. -
@gl.public.view— Read-only. It looks at the contract's data but changes nothing. These calls are free and fast.
The contract stores its data using two state variables:
-
bets: TreeMap[u256, Bet]— A key-value map where the key is a bet ID (an unsigned 256-bit integer) and the value is aBetobject.TreeMapis GenLayer's on-chain storage type, similar to a dictionary that persists between transactions. -
bet_counter: u256— A simple counter that increments every time a bet is created, giving each bet a unique ID.
Each bet is represented by the Bet dataclass, which holds the creator's address (as a string), the opponent's address (as a string), a human-readable description, the URL to check for resolution, the prompt that tells the AI how to judge, the current status ("open", "accepted", "cancelled", or "resolved"), and the winner (a string — either "creator", "opponent", or empty).
One important runtime value you will see throughout the code:
-
gl.message.sender_address— The wallet address of whoever is calling the method. This is how the contract knows who is making a request. The contract converts it to a string withstr()for comparison and storage.
Building Step by Step
Step 1: The Foundation
Let us start with the skeleton — imports, the data model, and the contract class.
# { "Depends": "py-genlayer:test" }
import json
from dataclasses import dataclass
from genlayer import *
@allow_storage
@dataclass
class Bet:
creator: str
opponent: str
description: str
resolution_url: str
resolution_prompt: str
status: str
winner: str
class GenBets(gl.Contract):
bet_counter: u256
bets: TreeMap[u256, Bet]
def __init__(self):
self.bet_counter = u256(0)
Walking through this:
- The comment on line 1 is a metadata hint for GenLayer's build system — it tells it which dependencies this contract needs.
- We import
json(standard Python) for potential use in data handling. -
from genlayer import *gives us everything GenLayer-specific:gl,u256,TreeMap, and the decorators. - The
@allow_storagedecorator on theBetdataclass tells GenLayer that this class can be stored on-chain. Without it, you cannot put a custom object into aTreeMap. - Notice that
creator,opponent, andwinnerare allstrfields, notAddress. We store addresses as plain strings, which simplifies comparisons and storage. -
GenBetsextendsgl.Contract, which is the base class for all intelligent contracts. The two class-level type annotations (bet_counterandbets) declare the contract's persistent storage. - The
__init__method runs once when the contract is first deployed. We initialize the counter to zero. TheTreeMapdoes not need explicit initialization — it starts empty.
Step 2: Creating a Bet
Now let us add the ability for someone to create a bet.
@gl.public.write
def create_bet(self, description: str, resolution_url: str, resolution_prompt: str, opponent: str) -> u256:
bet_id = self.bet_counter
self.bets[bet_id] = Bet(creator=str(gl.message.sender_address), opponent=opponent, description=description, resolution_url=resolution_url, resolution_prompt=resolution_prompt, status="open", winner="")
self.bet_counter = bet_id + u256(1)
return bet_id
This method is @gl.public.write — it modifies the contract's state but does not handle any token transfers.
We grab the current counter value as the new bet's ID, create a Bet object with all the details, and store it in the TreeMap. The creator field is set by converting gl.message.sender_address to a string. The opponent is passed in as a string (the opponent's address). The winner field starts as an empty string meaning "no winner yet." Finally, we bump the counter for the next bet and return the new bet's ID so the caller knows which bet they just created.
Notice that the caller provides four arguments (description, resolution_url, resolution_prompt, opponent), but we also capture one implicit value: gl.message.sender_address becomes the creator. The caller does not pass this explicitly — it comes from the transaction itself.
Step 3: Accepting a Bet
The opponent needs to accept the bet.
@gl.public.write
def accept_bet(self, bet_id: u256) -> None:
bet = self.bets[bet_id]
if bet.status != "open":
raise Exception("Bet is not open")
if str(gl.message.sender_address) == bet.creator:
raise Exception("Cannot accept your own bet")
if str(gl.message.sender_address) != bet.opponent:
raise Exception("Only the invited opponent can accept")
bet.status = "accepted"
self.bets[bet_id] = bet
Three validations protect against misuse. The bet must be open (not already accepted, cancelled, or resolved). You cannot accept your own bet. Only the specific opponent the creator named can accept — the contract compares str(gl.message.sender_address) against the stored opponent string.
If all checks pass, we update the status to "accepted" and write the updated bet back to storage.
Step 4: Cancelling a Bet
What if no one accepts your bet and you want to withdraw it?
@gl.public.write
def cancel_bet(self, bet_id: u256) -> None:
bet = self.bets[bet_id]
if bet.status != "open":
raise Exception("Can only cancel open bets")
if str(gl.message.sender_address) != bet.creator:
raise Exception("Only creator can cancel")
bet.status = "cancelled"
self.bets[bet_id] = bet
Only open bets can be cancelled (you cannot cancel a bet someone already accepted — that would be unfair). Only the creator can cancel. If those checks pass, we mark it as cancelled and save the updated bet.
Step 5: The Magic — Resolving a Bet
This is the heart of the contract and the part that makes GenLayer different from every other blockchain. Let us look at the full method first, then break it down.
@gl.public.write
def resolve_bet(self, bet_id: u256) -> str:
bet = self.bets[bet_id]
if bet.status != "accepted":
raise Exception("Bet must be in accepted status")
def nondet() -> str:
web_data = gl.nondet.web.render(bet.resolution_url, mode="text")
task = f"""You are an impartial judge evaluating a bet.
Bet description: {bet.description}
Resolution criteria: {bet.resolution_prompt}
Web page content:
{web_data[:3000]}
End of web page data.
Based on the data above, determine who wins this bet.
You must respond with exactly one word: either "creator" or "opponent".
Nothing else. Just one word.
"""
result = gl.nondet.exec_prompt(task).strip().lower()
if "creator" in result:
return "creator"
return "opponent"
winner = gl.eq_principle.strict_eq(nondet)
bet.winner = winner
bet.status = "resolved"
self.bets[bet_id] = bet
return winner
There is a lot happening here, so let us go piece by piece.
The closure pattern. The inner function nondet() is the non-deterministic part — the code that each validator will run independently. Everything inside this function can vary between validators (because web pages can return slightly different content and LLMs can give slightly different answers). Everything outside it is deterministic and runs the same way on every validator.
Fetching web data. gl.nondet.web.render(bet.resolution_url, mode="text") tells the validator's GenVM to actually visit the URL and render the page, then return the text content as a string. The mode="text" parameter means we want the readable text, not raw HTML. This is the "internet-native" part of GenLayer — your contract can read any web page. Note that render() returns a string directly — there is no need to call .body.decode() on the result.
Building the LLM prompt. We construct a prompt that gives the AI everything it needs: the bet description, the resolution criteria the creator specified, and the actual web page content. We truncate the page content to 3000 characters with web_data[:3000] to keep the prompt a reasonable size — web pages can be huge, and we only need the relevant data. Crucially, the prompt instructs the LLM to respond with exactly one word: either "creator" or "opponent." Nothing else.
Calling the LLM. gl.nondet.exec_prompt(task) sends the prompt to the LLM and returns a plain string. There is no response_format="json" here — we want a simple, single-word answer. The result is normalized with .strip().lower() to remove whitespace and ensure consistent casing. Then we check if "creator" appears in the result; if so, we return "creator", otherwise "opponent". This normalization step is critical for consensus.
Ensuring validator consensus with strict_eq. Here is where it gets interesting. Each validator runs nondet() independently — they each fetch the web page and query the LLM separately. But for the transaction to be finalized, the validators need to agree on the result. That is what gl.eq_principle.strict_eq(nondet) does.
The strict_eq function requires that every validator produces exactly the same string output. This is why we designed the prompt to return a single word and then normalize the response to one of exactly two possible values: "creator" or "opponent". No matter which LLM a validator uses, no matter how it phrases its reasoning internally, the final output of nondet() will always be one of these two strings. Since strict_eq compares the outputs character-by-character, this design guarantees that validators who reach the same conclusion will produce identical output. No JSON serialization tricks needed — just a clean, deterministic single-word result.
Recording the winner. The winner variable is a string — either "creator" or "opponent". We store it directly in bet.winner, update the status to "resolved", save the bet, and return the winner string.
Step 6: View Methods
Finally, two read-only methods to inspect the contract's state.
@gl.public.view
def get_bet(self, bet_id: u256) -> dict:
bet = self.bets[bet_id]
return {"id": int(bet_id), "creator": bet.creator, "opponent": bet.opponent, "description": bet.description, "resolution_url": bet.resolution_url, "resolution_prompt": bet.resolution_prompt, "status": bet.status, "winner": bet.winner}
@gl.public.view
def get_bet_count(self) -> u256:
return self.bet_counter
get_bet() takes a bet ID and returns a dictionary with all the bet's details. Since creator, opponent, and winner are already strings, no conversion is needed — we return them directly. get_bet_count() simply returns how many bets have been created — useful for iterating through all bets on the frontend.
These are @gl.public.view methods, meaning they do not modify any state. They are free to call and return instantly.
The Complete Contract
Here is the complete contract in one block — copy and paste this into Studio.
# { "Depends": "py-genlayer:test" }
import json
from dataclasses import dataclass
from genlayer import *
@allow_storage
@dataclass
class Bet:
creator: str
opponent: str
description: str
resolution_url: str
resolution_prompt: str
status: str
winner: str
class GenBets(gl.Contract):
bet_counter: u256
bets: TreeMap[u256, Bet]
def __init__(self):
self.bet_counter = u256(0)
@gl.public.write
def create_bet(self, description: str, resolution_url: str, resolution_prompt: str, opponent: str) -> u256:
bet_id = self.bet_counter
self.bets[bet_id] = Bet(creator=str(gl.message.sender_address), opponent=opponent, description=description, resolution_url=resolution_url, resolution_prompt=resolution_prompt, status="open", winner="")
self.bet_counter = bet_id + u256(1)
return bet_id
@gl.public.write
def accept_bet(self, bet_id: u256) -> None:
bet = self.bets[bet_id]
if bet.status != "open":
raise Exception("Bet is not open")
if str(gl.message.sender_address) == bet.creator:
raise Exception("Cannot accept your own bet")
if str(gl.message.sender_address) != bet.opponent:
raise Exception("Only the invited opponent can accept")
bet.status = "accepted"
self.bets[bet_id] = bet
@gl.public.write
def cancel_bet(self, bet_id: u256) -> None:
bet = self.bets[bet_id]
if bet.status != "open":
raise Exception("Can only cancel open bets")
if str(gl.message.sender_address) != bet.creator:
raise Exception("Only creator can cancel")
bet.status = "cancelled"
self.bets[bet_id] = bet
@gl.public.write
def resolve_bet(self, bet_id: u256) -> str:
bet = self.bets[bet_id]
if bet.status != "accepted":
raise Exception("Bet must be in accepted status")
def nondet() -> str:
web_data = gl.nondet.web.render(bet.resolution_url, mode="text")
task = f"""You are an impartial judge evaluating a bet.
Bet description: {bet.description}
Resolution criteria: {bet.resolution_prompt}
Web page content:
{web_data[:3000]}
End of web page data.
Based on the data above, determine who wins this bet.
You must respond with exactly one word: either "creator" or "opponent".
Nothing else. Just one word.
"""
result = gl.nondet.exec_prompt(task).strip().lower()
if "creator" in result:
return "creator"
return "opponent"
winner = gl.eq_principle.strict_eq(nondet)
bet.winner = winner
bet.status = "resolved"
self.bets[bet_id] = bet
return winner
@gl.public.view
def get_bet(self, bet_id: u256) -> dict:
bet = self.bets[bet_id]
return {"id": int(bet_id), "creator": bet.creator, "opponent": bet.opponent, "description": bet.description, "resolution_url": bet.resolution_url, "resolution_prompt": bet.resolution_prompt, "status": bet.status, "winner": bet.winner}
@gl.public.view
def get_bet_count(self) -> u256:
return self.bet_counter
Deploy and Test in Studio
Let us take this contract for a spin.
Deploy the contract. Open GenLayer Studio (either the local version at localhost:8080 or the hosted version at studio.genlayer.com). Paste the complete contract code into the editor and click Deploy. Once it deploys successfully, note the contract address — you will need it later.
Create a bet. With your first account selected, call create_bet with these parameters:
-
description:"Inception will have above 80% on Rotten Tomatoes" -
resolution_url:"https://www.rottentomatoes.com/m/inception" -
resolution_prompt:"Check if the Tomatometer score is above 80%. If yes, creator wins. If no, opponent wins." -
opponent: the address of your second Studio account (copy it from the account dropdown)
The transaction should succeed and return 0 (the first bet's ID).
Accept the bet. Switch to your second account in Studio. Call accept_bet with bet_id set to 0. You should see the transaction succeed.
Resolve the bet. Now call resolve_bet with bet_id set to 0. This is where it gets interesting — watch the transaction logs. You will see each validator independently fetch the Rotten Tomatoes page, extract the score, and reason about whether it is above 80%. The validators compare their results, reach consensus, and the winner is recorded. The method returns the winner as a string — either "creator" or "opponent."
Inspect the result. Call get_bet with bet_id set to 0. You will see the full bet details including the status (now "resolved") and the winner (either "creator" or "opponent").
Try the error cases. These are worth testing to make sure the validations work:
- Try calling
cancel_beton the resolved bet — you should get "Can only cancel open bets." - Switch back to the first account and try calling
accept_beton your own bet — you should get "Cannot accept your own bet." - Create a new bet and try accepting it from an account that is not the named opponent — you should get "Only the invited opponent can accept."
These guardrails ensure the contract behaves correctly in every scenario.
What's Next
In Part 3, we will build a Next.js frontend that connects to our contract using genlayer-js, letting users create, accept, and resolve bets through a polished web interface. We will cover wallet connection with MetaMask, reading contract state with TanStack Query, and sending transactions — everything you need to turn this contract into a real application users can interact with.
Top comments (0)