DEV Community

onyi
onyi

Posted on

From Zero to GenLayer: Writing Your First Intelligent Contract in Python (Part 2/3)

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 a Bet object. TreeMap is 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 with str() 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)
Enter fullscreen mode Exit fullscreen mode

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_storage decorator on the Bet dataclass tells GenLayer that this class can be stored on-chain. Without it, you cannot put a custom object into a TreeMap.
  • Notice that creator, opponent, and winner are all str fields, not Address. We store addresses as plain strings, which simplifies comparisons and storage.
  • GenBets extends gl.Contract, which is the base class for all intelligent contracts. The two class-level type annotations (bet_counter and bets) declare the contract's persistent storage.
  • The __init__ method runs once when the contract is first deployed. We initialize the counter to zero. The TreeMap does 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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_bet on the resolved bet — you should get "Can only cancel open bets."
  • Switch back to the first account and try calling accept_bet on 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)