DEV Community

Cover image for From Zero to GenLayer: Build a P2P Betting dApp with Intelligent Contracts.
randyparrs
randyparrs

Posted on

From Zero to GenLayer: Build a P2P Betting dApp with Intelligent Contracts.

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

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

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

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

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

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

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

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

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

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

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

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,
});
Enter fullscreen mode Exit fullscreen mode

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

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,
});
Enter fullscreen mode Exit fullscreen mode

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

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)