From 95c46400a8b059bce436fb5ad794ac2e66094543 Mon Sep 17 00:00:00 2001 From: Lucas Leclerc Date: Sat, 23 Aug 2025 22:12:11 +0200 Subject: [PATCH 1/5] feat(examples): first optimistic oracle implem (without token mecanism) --- examples/gno.land/r/intermarch3/oo/README.md | 126 ++++++++ examples/gno.land/r/intermarch3/oo/court.gno | 258 ++++++++++++++++ .../gno.land/r/intermarch3/oo/gnomod.toml | 2 + examples/gno.land/r/intermarch3/oo/oracle.gno | 276 ++++++++++++++++++ examples/gno.land/r/intermarch3/oo/render.gno | 162 ++++++++++ .../gno.land/r/intermarch3/oo/resolver.gno | 60 ++++ 6 files changed, 884 insertions(+) create mode 100644 examples/gno.land/r/intermarch3/oo/README.md create mode 100644 examples/gno.land/r/intermarch3/oo/court.gno create mode 100644 examples/gno.land/r/intermarch3/oo/gnomod.toml create mode 100644 examples/gno.land/r/intermarch3/oo/oracle.gno create mode 100644 examples/gno.land/r/intermarch3/oo/render.gno create mode 100644 examples/gno.land/r/intermarch3/oo/resolver.gno diff --git a/examples/gno.land/r/intermarch3/oo/README.md b/examples/gno.land/r/intermarch3/oo/README.md new file mode 100644 index 00000000000..4474ea7b5e7 --- /dev/null +++ b/examples/gno.land/r/intermarch3/oo/README.md @@ -0,0 +1,126 @@ +# Gno Optimistic Oracle (OO) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +An Optimistic Oracle (OO) built on Gno.land. This system is designed to bring external data onto the blockchain by leveraging game-theoretic incentives. It assumes data is correct unless disputed, hence the term "optimistic." +This implementation is inspired by the [UMA Optimistic Oracle](https://uma.xyz/) but adapted for the Gno ecosystem. + +## Table of Contents +- [Core Concepts](#core-concepts) +- [How It Works: The Lifecycle of a Data Request](#how-it-works-the-lifecycle-of-a-data-request) +- [Architecture](#architecture) +- [User Roles](#user-roles) +- [Usage Example](#usage-example) +- [Developer](#developer) + +## Core Concepts + +The Gno Optimistic Oracle operates on the principle that data proposed to the oracle is assumed to be true. A bond is required for any new proposition. This proposition enters a "liveness" period where anyone can dispute it by posting an equal bond. + +- **Happy Path**: If no one disputes the data within the liveness period, it is considered resolved and accepted as truth. The proposer's bond is returned along with a reward. +- **Unhappy Path (Dispute)**: If the data is disputed, the Gno community is called upon to vote on the correct outcome. This is handled by the `court.gno` contract. Token holders vote, and the outcome is decided by the total token weight backing each value. The winner's bond is returned, and they receive a portion of the loser's slashed bond. + +## How It Works: The Lifecycle of a Data Request + +The entire process, from requesting data to its final resolution, follows a clear, multi-step path. + +### 1. Data Request (`RequestData`) +A user or a contract initiates a request for data by calling `RequestData`. +- **Ancillary Data**: A clear, human-readable question (e.g., "What was the price of ETH/USD at block X?"). +- **Type**: The request can be a `Yes/No` question (represented by 0 and 1) or a `Numeric` value. +- **Reward**: The requester must lock a `RequesterReward` in GNOT to incentivize a proposer to provide the data. +- **Deadline**: The requester sets a deadline by which the data must be proposed, otherwise they can retrieve their locked reward. + +### 2. Value Proposal (`ProposeValue`) +A **Proposer** provides an answer to the request. +- They call `ProposeValue` with the proposed answer. +- They must post a `Bond` in GNOT, which is held in escrow. +- This action starts the **Resolution Time**, a liveness window during which the proposal can be disputed. + +### 3. The Liveness Period +Once a value is proposed, a countdown begins. During this period, anyone can challenge the proposed value. + +- **If Undisputed**: If the `ResolutionTime` expires without any disputes, the request is considered final. Anyone can call `ResolveRequest`. The `ProposedValue` becomes the `WinningValue`. The Proposer gets their bond back, plus the `RequesterReward`. +- **If Disputed**: If another user believes the proposed value is incorrect, they can challenge it. + +### 4. Dispute (`DisputeData`) +A **Disputer** can challenge the Proposer's value. +- They must call `DisputeData` before the `ResolutionTime` ends. +- They must also post a `Bond` equal to the Proposer's bond. +- This action pauses the request's resolution and initiates a formal dispute, handled by the `court.gno` contract. + +### 5. Voting (`VoteOnDispute`) +The dispute is now open for voting by all GNOT holders. The system uses a **commit-reveal scheme** to prevent vote-copying. + +- **Commit Phase**: During the `DisputeDuration`, voters submit a hash of their vote (`SHA256(value + salt)`) by calling `VoteOnDispute`. They must also pay a small `VotePrice` fee. // todo +- **Reveal Phase**: After the commit phase ends, the `RevealDuration` begins. Voters must call `RevealVote`, submitting their original `value` and `salt`. The contract verifies that the hash matches the one submitted during the commit phase. + +### 6. Dispute Resolution (`ResolveDispute`) +Once the reveal period is over, anyone can call `ResolveDispute`. +- The `resolver.gno` contract tallies the votes. The winning value is the one with the highest cumulative token weight from voters. +- The `WinningValue` is set in the original `DataRequest`. +- **Slashing & Rewards**: The party (Proposer or Disputer) that lost the vote has their bond slashed. The winning party gets their bond back, and the slashed bond is distributed among the voters who voted for the winning outcome. + +## Architecture + +The oracle is composed of three main contracts: + +- `oracle.gno`: Manages the data request lifecycle (request, propose, dispute, resolve). It is the main entry point for users. +- `court.gno`: Handles the entire dispute resolution process, including the commit-reveal voting scheme. +- `resolver.gno`: Contains the business logic for tallying votes and determining the winning value of a dispute. It supports both Yes/No and Numeric resolutions. + +## User Roles + +- **Requester**: The user or contract that needs external data. They create the request and fund the reward. +- **Proposer**: The user who provides the initial answer to a data request and posts a bond. +- **Disputer**: A user who challenges a proposed value and posts a bond to initiate a vote. +- **Voter**: A GNOT holder who participates in a dispute by voting on the correct outcome. + +## Usage Example + +Here is a full workflow using `gnokey`. + +**1. Request Data** +```bash +# Ask a Yes/No question: "Will ETH be below $4000 ?" (replace DEADLINE_TIMESTAMP with a future unix timestamp more than 24h from now) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "RequestData" -args "Will ETH be below 4000$ ?" -args "true" -args "DEADLINE_TIMESTAMP" --gas-fee 1000000ugnot --gas-wanted 5000000 --send "1000000ugnot" --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +``` + +**2. Propose a Value** +```bash +# Propose "Yes" (value 1) (replace ID with the actual ID returned from the RequestData call) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "ProposeValue" -args "ID" -args "1" --gas-fee 1000000ugnot --gas-wanted 10000000 --send "2000000ugnot" --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +``` + +**3. Dispute the Value** +```bash +# Dispute the proposal (replace ID with the actual ID) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "DisputeData" -args "ID" --gas-fee 1000000ugnot --gas-wanted 5000000 --send "2000000ugnot" --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +``` + +**4. Vote on the Dispute** +First, generate a hash locally. Let's vote "No" (value 0) with salt "mysecret". +Hash: `sha256("0" + "mysecret")` -> `a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21` +```bash +# Commit the vote (replace ID with the actual ID) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "VoteOnDispute" -args "ID" -args "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21" --gas-fee 1000000ugnot --gas-wanted 5000000 --send "1000000ugnot" --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +``` + +**5. Reveal the Vote** +```bash +# Reveal the vote after the voting period ends (replace ID with the actual ID) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "RevealVote" -args "ID" -args "0" -args "mysecret" --gas-fee 1000000ugnot --gas-wanted 10000000 --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +``` + +**6. Resolve the Dispute** +```bash +# After the reveal period, anyone can trigger the final resolution (replace ID with the actual ID). +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "ResolveDispute" -args "ID" --gas-fee 1000000ugnot --gas-wanted 10000000 --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +``` + +**Warning**: When testing with `gnodev`, ensure to make transactions between waiting periods as `gnodev` does create blocks only when a transaction is made and the oracle relies on block timestamps. + +## Developer + +| [
Lucas Leclerc](https://github.com/intermarch3) | +| :---: | \ No newline at end of file diff --git a/examples/gno.land/r/intermarch3/oo/court.gno b/examples/gno.land/r/intermarch3/oo/court.gno new file mode 100644 index 00000000000..b1b1a836e69 --- /dev/null +++ b/examples/gno.land/r/intermarch3/oo/court.gno @@ -0,0 +1,258 @@ +package oo + +import ( + "time" + "std" + "strconv" + + "crypto/sha256" + "encoding/hex" +) + +type Vote struct { + RequestId string + Voter std.Address + TokenAmount int64 + Hash string + Value int64 + Revealed bool +} + +type Voter struct { + HasVoted bool + VoteIndex int64 +} + +type Dispute struct { + RequestId string + Votes []Vote + NbResolvedVotes int64 + Voters map[std.Address]Voter + IsResolved bool + WinningValue int64 + EndTime time.Time + EndRevealTime time.Time +} + +var ( + Disputes map[string]Dispute + DisputeDuration int64 = 2 * int64(time.Minute.Seconds()) + RevealDuration int64 = 2 * int64(time.Minute.Seconds()) + VotePrice int64 = 1 * int64(1_000_000) // in GNOT +) + +func init() { + Disputes = make(map[string]Dispute) +} + +func initiateDispute(id string) { + if _, exists := Disputes[id]; exists { + panic("Error: Dispute for this request already exists.") + } + dispute := Dispute{ + RequestId: id, + Votes: []Vote{}, + Voters: make(map[std.Address]Voter), + IsResolved: false, + EndTime: time.Now().Add(time.Duration(DisputeDuration) * time.Second), + EndRevealTime: time.Now().Add(time.Duration(DisputeDuration + RevealDuration) * time.Second), + } + Disputes[id] = dispute + std.Emit("DisputeInitiated", "id", id) +} + +// -- PUBLIC FUNCTIONS -- + +// VoteOnDispute allows a user to commit a vote during a dispute. +// @param id - The ID of the dispute (same as Request ID). +// @param hash - The SHA256 hash of the vote value and a secret salt. +func VoteOnDispute(cur realm, id string, hash string) { + dispute, exists := Disputes[id] + if !exists { + panic("Error: No dispute with this ID exists.") + } + r, _ := Requests[id] + if r.Proposer == std.OriginCaller() || r.Disputer == std.OriginCaller() { + panic("Error: Proposer and Disputer cannot vote in this dispute.") + } + if dispute.IsResolved { + panic("Error: Dispute is already resolved.") + } + if time.Now().After(dispute.EndTime) { + panic("Error: Vote period has ended.") + } + // TODO: check that voter has at least VotePrice GNOT to stake + // get voter token balance + vote := Vote{ + RequestId: id, + Voter: std.OriginCaller(), + TokenAmount: 1, // TODO: actually get voter's token balance + Hash: hash, + Revealed: false, + } + if dispute.Voters[vote.Voter].HasVoted { + panic("Error: Voter has already voted in this dispute.") + } + dispute.Votes = append(dispute.Votes, vote) + dispute.Voters[vote.Voter] = Voter{HasVoted: true, VoteIndex: int64(len(dispute.Votes) - 1)} + Disputes[id] = dispute + std.Emit("VoteSubmitted", "id", id, "voter", vote.Voter.String()) +} + +// RevealVote allows a user to reveal their vote after the voting period has ended. +// @param id - The ID of the dispute (same as Request ID). +// @param value - The actual value of the vote. +// @param salt - The secret salt used to generate the vote hash. +func RevealVote(cur realm, id string, value int64, salt string) { + dispute, exists := Disputes[id] + if !exists { + panic("Error: No dispute with this ID exists.") + } + if dispute.IsResolved { + panic("Error: Dispute is resolved.") + } + if time.Now().Before(dispute.EndTime) { + panic("Error: Vote period has not ended yet.") + } + if time.Now().After(dispute.EndRevealTime) { + panic("Error: Reveal period has ended.") + } + voter := dispute.Voters[std.OriginCaller()] + if !voter.HasVoted { + panic("Error: Voter did not participate in this dispute.") + } + vote := dispute.Votes[voter.VoteIndex] + if vote.Revealed { + panic("Error: Vote already revealed.") + } + + // Verify the hash + res := sha256.Sum256([]byte(strconv.FormatInt(value, 10) + salt)) + expectedHash := hex.EncodeToString(res[:]) + if (vote.Hash != expectedHash) { + panic("Error: Hash does not match the revealed value and salt.") + } + vote.Value = value + vote.Revealed = true + dispute.NbResolvedVotes += 1 + dispute.Votes[voter.VoteIndex] = vote + Disputes[id] = dispute + std.Emit("VoteRevealed", "id", id, "voter", vote.Voter.String(), "value", strconv.Itoa(int(value))) +} + +// ResolveDispute finalizes a dispute after the reveal period, tallying votes and setting the winning value. +// @param id - The ID of the dispute to resolve (same as Request ID). +func ResolveDispute(cur realm, id string) { + dispute, exists := Disputes[id] + if !exists { + panic("Error: No dispute with this ID exists.") + } + if dispute.IsResolved { + panic("Error: Dispute is already resolved.") + } + if time.Now().Before(dispute.EndTime) { + panic("Error: Dispute period has not ended yet.") + } + val := resolve(id) + dispute.WinningValue = val + dispute.IsResolved = true + Disputes[id] = dispute + // Update the original request with the winning value + request, exists := Requests[id] + + request.ProposedValue = val + request.State = "Resolved" + Requests[id] = request + std.Emit("DisputeResolved", "id", id, "winningValue", strconv.Itoa(int(val))) + std.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(val))) + // TODO: Distribuate bond + reward between winning voters and if disputer win, give a part to him +} + +// -- admin functions -- + +// SetDisputeDuration sets the duration (in seconds) for the voting period. +// @param duration - The new dispute duration in seconds. +func SetDisputeDuration(_ realm, duration int64) { + if std.OriginCaller() == admin { + DisputeDuration = duration * int64(time.Second) + } else { + panic("Error: Only admin can set dispute duration.") + } +} + +// SetRevealDuration sets the duration (in seconds) for the reveal period. +// @param duration - The new reveal duration in seconds. +func SetRevealDuration(_ realm, duration int64) { + if std.OriginCaller() == admin { + RevealDuration = duration * int64(time.Second) + } else { + panic("Error: Only admin can set reveal duration.") + } +} + +// SetVotePrice sets the price (in ugnot) to cast a vote. +// @param price - The new vote price. +func SetVotePrice(_ realm, price int64) { + if std.OriginCaller() == admin { + VotePrice = price + } else { + panic("Error: Only admin can set vote price.") + } +} + +// -- view functions -- + +// GetDispute returns the details of a specific dispute. +// @param id - The ID of the dispute (same as Request ID). +func GetDispute(_ realm, id string) Dispute { + dispute, exists := Disputes[id] + if !exists { + panic("Error: No dispute with this ID exists.") + } + return dispute +} + +// GetDisputeDuration returns the current dispute duration. +func GetDisputeDuration(_ realm) int64 { + return DisputeDuration +} + +// GetVotePrice returns the current vote price. +func GetVotePrice(_ realm) int64 { + return VotePrice +} + +// GetDisputeEndTime returns the end time of the voting period for a specific dispute. +// @param id - The ID of the dispute (same as Request ID). +func GetDisputeEndTime(_ realm, id string) time.Time { + dispute, exists := Disputes[id] + if !exists { + panic("Error: No dispute with this ID exists.") + } + return dispute.EndTime +} + +// GetDisputeVotesAmount returns the total number of votes cast in a dispute. +// @param id - The ID of the dispute (same as Request ID). +func GetDisputeVotesAmount(_ realm, id string) int64 { + dispute, exists := Disputes[id] + if !exists { + panic("Error: No dispute with this ID exists.") + } + return int64(len(dispute.Votes)) +} + +// GetRevealEndTime returns the end time of the reveal period for a specific dispute. +// @param id - The ID of the dispute (same as Request ID). +func GetRevealEndTime(_ realm, id string) time.Time { + dispute, exists := Disputes[id] + if !exists { + panic("Error: No dispute with this ID exists.") + } + return dispute.EndRevealTime +} + +// GetRevealDuration returns the current reveal duration. +func GetRevealDuration(_ realm) int64 { + return RevealDuration +} \ No newline at end of file diff --git a/examples/gno.land/r/intermarch3/oo/gnomod.toml b/examples/gno.land/r/intermarch3/oo/gnomod.toml new file mode 100644 index 00000000000..b61f9ddeb2d --- /dev/null +++ b/examples/gno.land/r/intermarch3/oo/gnomod.toml @@ -0,0 +1,2 @@ +module = "gno.land/r/intermarch3/oo" +gno = "0.9" diff --git a/examples/gno.land/r/intermarch3/oo/oracle.gno b/examples/gno.land/r/intermarch3/oo/oracle.gno new file mode 100644 index 00000000000..accfebfc992 --- /dev/null +++ b/examples/gno.land/r/intermarch3/oo/oracle.gno @@ -0,0 +1,276 @@ +package oo + +import ( + "time" + "std" + "strconv" +) + +type DataRequest struct { + Id string + Creator std.Address + Timestamp time.Time + AncillaryData string + YesNoQuestion bool + ProposedValue int64 + Proposer std.Address + ProposerBond int64 + Disputer std.Address + DisputerBond int64 + ResolutionTime time.Time + WinningValue int64 + State string // "Requested", "Proposed", "Disputed", "Resolved" +} + +var ( + Requests map[string]DataRequest + + admin = std.Address("g1qdwemgqgqm7s42gy6xcj7f22uhfdzr82st3sy3") + ResolutionTime int64 = 2 * int64(time.Minute.Seconds()) + RequesterReward int64 = 1 * int64(1_000_000) // in GNOT + Bond int64 = 2 * int64(1_000_000) // in GNOT +) + +func init() { + Requests = make(map[string]DataRequest) +} + +// 6 first chars of sha256(ancillaryData + timestamp) +func getUniqueId(ancillaryData string, timestamp int64) string { + data := ancillaryData + strconv.Itoa(timestamp) + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:])[:6] +} + +// -- PUBLIC FUNCTIONS -- + +// RequestData allows a user to request data from the oracle. +// `RequesterReward` value needs to be sent to the contract as a reward +// You need to ask a question that can be answered with a single number like a yes/no question (0 or 1) or a specific value (e.g. ETH/USD price). +// @param ancillaryData - The human-readable question for the oracle. +// @param yesNoQuestion - True if the question is a Yes/No question. +// @param deadline - A Unix timestamp for when the request should be fulfilled (need to be more than now + 24h). +func RequestData(cur realm, ancillaryData string, yesNoQuestion bool, deadline int64) string { + id := getUniqueId(ancillaryData, deadline) + if _, exists := Requests[id]; exists { + panic("Error: Request with this ID already exists.") + } + if ancillaryData == "" { + panic("Error: Ancillary data cannot be empty.") + } + if deadline < time.Now().Add(24 * time.Hour).Unix() { + panic("Error: Deadline must be at least 24 hours in the future.") + } + + // TODO: check that x GNOT is sent as reward + request := DataRequest{ + Id: id, + Timestamp: time.Now(), + AncillaryData: ancillaryData, + YesNoQuestion: yesNoQuestion, + State: "Requested", + } + Requests[id] = request + std.Emit("DataRequested", "id", id, "timestamp", request.Timestamp.String(), "ancillaryData", ancillaryData) + return id +} + +// ProposeValue allows a user to propose a value for a requested data point. +// `Bond` value needs to be sent to the contract as a bond. +// @param id - The ID of the data request. +// @param proposedValue - The proposed answer to the question. +func ProposeValue(cur realm, id string, proposedValue int64) { + request, exists := Requests[id] + if !exists { + panic("Error: No request with this ID exists.") + } + if request.State != "Requested" { + panic("Error: Request is not in 'Requested' state.") + } + if request.YesNoQuestion && proposedValue != 0 && proposedValue != 1 { + panic("Error: Proposed value must be 0 or 1 for yes/no questions.") + } + // TODO: check that proposerBond is x GNOT + request.ProposedValue = proposedValue + request.Proposer = std.OriginCaller() + request.ProposerBond = 1 // TODO: check actual bond sent + request.ResolutionTime = time.Now().Add(time.Duration(ResolutionTime) * time.Second) + request.State = "Proposed" + Requests[id] = request + std.Emit("ValueProposed", "id", id, "proposedValue", strconv.Itoa(int(proposedValue)), "proposer", request.Proposer.String(), "resolutionTime", request.ResolutionTime.String()) +} + +// DisputeData allows a user to dispute a proposed value. +// `Bond` value needs to be sent to the contract as a bond. +// @param id - The ID of the data request to dispute. +func DisputeData(cur realm, id string) string { + request, exists := Requests[id] + if !exists { + panic("Error: No request with this ID exists.") + } + if request.Proposer == std.OriginCaller() { + panic("Error: Proposer cannot dispute their own proposal.") + } + if request.State != "Proposed" { + panic("Error: Request is not in 'Proposed' state.") + } + if time.Now().After(request.ResolutionTime) { + panic("Error: Dispute period has ended.") + } + request.Disputer = std.OriginCaller() + request.DisputerBond = 1 // TODO: check actual bond sent + request.State = "Disputed" + Requests[id] = request + initiateDispute(id) + std.Emit("DataDisputed", "id", id, "disputer", request.Disputer.String()) + return "Time: " + time.Now().String() +} + +// ResolveRequest finalizes an undisputed request after the resolution period has passed. +// @param id - The ID of the data request to resolve. +func ResolveRequest(cur realm, id string) { + request, exists := Requests[id] + if !exists { + panic("Error: No request with this ID exists.") + } + if request.State == "Disputed" { + panic("Error: Request is in 'Disputed' state.") + } + if request.State == "Proposed" && request.ResolutionTime.After(time.Now()) { + panic("Error: Resolution period has not ended yet.") + } + if request.State == "Requested" { + panic("Error: Request has not been proposed yet.") + } + if request.State == "Resolved" { + panic("Error: Request is already resolved.") + } + request.State = "Resolved" + request.WinningValue = request.ProposedValue + Requests[id] = request + std.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(request.WinningValue))) + // TODO: Return bonds + reward to proposer +} + +// RequestResult returns the winning value of a resolved request. +// @param id - The ID of the data request. +func RequestResult(cur realm, id string) int64 { + request, exists := Requests[id] + if !exists { + panic("Error: No request with this ID exists.") + } + if request.State != "Resolved" { + panic("Error: Request is not resolved yet.") + } + return request.WinningValue +} + +// RequesterRetreiveFund allows the original requester to get their reward back if the deadline passed without a proposal. +// @param id - The ID of the data request. +func RequesterRetreiveFund(cur realm, id string) { + request, exists := Requests[id] + if !exists { + panic("Error: No request with this ID exists.") + } + if request.State != "Requested" { + panic("Error: cannot retreive fund as requests fulfilled.") + } + if request.Creator != std.OriginCaller() { + panic("Error: Only the creator of the request can retreive the fund.") + } + if request.Deadline > time.Now().Unix() { + panic("Error: Cannot retreive fund before the deadline.") + } + // TODO: return the fund to the requester +} + + +// -- ADMIN FUNCTIONS -- + + +// SetResolutionDuration sets the duration (in seconds) for the resolution period. +// @param duration - The new resolution duration in seconds. +func SetResolutionDuration(_ realm, duration int64) { + if std.OriginCaller() == admin { + ResolutionTime = duration + std.Emit("ResolutionTimeSet", "duration", strconv.Itoa(int(duration))) + } else { + panic("Error: Only the admin can set the resolution time.") + } +} + +// SetRequesterReward sets the reward amount for a successful proposal. +// @param reward - The new reward amount in ugnot. +func SetRequesterReward(_ realm, reward int64) { + if std.OriginCaller() == admin { + RequesterReward = reward + std.Emit("RequesterRewardSet", "reward", strconv.Itoa(int(reward))) + } else { + panic("Error: Only the admin can set the requester reward.") + } +} + +// SetBond sets the bond amount required for proposals and disputes. +// @param bond - The new bond amount in ugnot. +func SetBond(_ realm, bond int64) { + if std.OriginCaller() == admin { + Bond = bond + std.Emit("BondSet", "bond", strconv.Itoa(int(bond))) + } else { + panic("Error: Only the admin can set the proposer bond.") + } +} + +// ChangeAdmin transfers admin privileges to a new address. +// @param newAdmin - The address of the new admin. +func ChangeAdmin(_ realm, newAdmin std.Address) { + if std.OriginCaller() == admin { + admin = newAdmin + std.Emit("AdminChanged", "newAdmin", newAdmin.String()) + } else { + panic("Error: Only the admin can change the admin.") + } +} + +// -- VIEW FUNCTIONS -- + +// GetRequest returns the details of a specific data request. +// @param id - The ID of the data request. +func GetRequest(_ realm, id string) DataRequest { + request, exists := Requests[id] + if !exists { + panic("Error: No request with this ID exists.") + } + return request +} + +// GetBond returns the current bond amount. +func GetBond(_ realm) int64 { + return Bond +} + +// GetResolutionTime returns the current resolution time duration. +func GetResolutionTime(_ realm) int64 { + return ResolutionTime +} + +// GetRequesterReward returns the current requester reward amount. +func GetRequesterReward(_ realm) int64 { + return RequesterReward +} + +// GetRequestState returns the current state of a specific data request. +// @param id - The ID of the data request. +func GetRequestState(_ realm, id string) string { + request, exists := Requests[id] + if !exists { + panic("Error: No request with this ID exists.") + } + return request.State +} + +// GetAdmin returns the current admin address. +func GetAdmin(_ realm) std.Address { + return admin +} + diff --git a/examples/gno.land/r/intermarch3/oo/render.gno b/examples/gno.land/r/intermarch3/oo/render.gno new file mode 100644 index 00000000000..661c78609fc --- /dev/null +++ b/examples/gno.land/r/intermarch3/oo/render.gno @@ -0,0 +1,162 @@ +package oo + +import ( + "gno.land/p/moul/md" + "gno.land/p/moul/realmpath" + "gno.land/p/moul/txlink" + "gno.land/p/demo/ufmt" + "std" + "strconv" + "time" +) + +const DateFormat = "January 2 2006, 03:04:04 PM" + +func Render(path string) string { + req := realmpath.Parse(path) + if req.Path == "" { + return renderHome() + } + return renderRequestPage(req.Path) +} + +func renderHome() string { + msg := md.H1("OO Home") + msg += md.Paragraph("Welcome to the first Optimistic Oracle on GnoLand! This project is developed by @intermarch3.") + msg += md.Link("Request Data", ufmt.Sprintf("%s", txlink.NewLink("RequestData").AddArgs("id", "YOUR_ID", "ancillaryData", "YOUR_QUESTION", "yesNoQuestion", "true or false").SetSend(strconv.Itoa(int(RequesterReward))).URL())) + "\n\n(Note: Requesting data need to pay a reward of " + strconv.Itoa(int(RequesterReward / 1_000_000)) + " GNOT to the proposer).\n\n" + msg += md.H2("Current Requests :") + if len(Requests) == 0 { + msg += md.Paragraph("No current requests.") + return msg + } + var table []string + table = append(table, md.Bold("Question"), md.Bold("Proposed Value"), md.Bold("State"), md.Bold("See more")) + for _, req := range Requests { + if req.State == "Resolved" { + continue + } + table = append(table, renderRequest(req)...) + } + msg += md.ColumnsN(table, 4, false) + return msg +} + +func renderRequestPage(id string) string { + req, exists := Requests[id] + if !exists { + return md.H1("Error: No request with this ID exists.") + } + msg := md.H1("Question: " + req.AncillaryData) + msg += md.H2("Details:\n\n") + msg += md.H3("Request ID: " + req.Id) + "\n\n" + msg += md.H3("Requested at: " + req.Timestamp.Format(DateFormat)) + "\n\n" + if req.State == "Requested" && req.Deadline.Unix() < time.Now().Unix() { + msg += md.H3("Deadline Missed: requests not fulfilled") + "\n\n" + return msg + } + msg += md.H3("State: " + req.State) + "\n\n" + if req.State == "Requested" { + msg += md.Paragraph("This request has not been proposed yet.") + "\n\n" + msg += md.H3("The reward for the proposer is " + strconv.Itoa(int(RequesterReward / 1_000_000)) + " GNOT.") + "\n\n" + msg += renderAction(req) + return msg + } + if req.State == "Resolved" { + msg += md.H3("Winning Value: " + rendervalue(req, req.WinningValue)) + "\n\n" + dispute, exist := Disputes[req.Id] + if !exist { + msg += md.Paragraph("Value Proposed by: " + req.Proposer.String()) + "\n\n" + return msg + } + msg += md.Paragraph("Number of Votes: " + strconv.Itoa(len(dispute.Votes)) + "\n\n") + msg += md.Paragraph("Reveal Votes: \n\n") + if dispute.NbResolvedVotes > 0 { + msg += renderRevealedVotes(dispute) + "\n\n" + } else { + msg += md.Paragraph("No votes revealed. Proposed value win by default\n\n") + } + return msg + } + if req.State == "Proposed" { + msg += md.Paragraph("Proposed by: " + req.Proposer.String()) + "\n\n" + msg += md.H3("Proposed Value: " + rendervalue(req, req.ProposedValue)) + "\n\n" + msg += md.H3("End Resolution Time: " + req.ResolutionTime.Format(DateFormat)) + "\n\n" + } + if req.State == "Disputed" { + dispute, exists := Disputes[req.Id] + if exists { + msg += md.H2("Dispute Details:\n\n") + msg += md.Paragraph("End of Voting Time: " + dispute.EndTime.Format(DateFormat) + "\n\n") + msg += md.Paragraph("End of Reveal Time: " + dispute.EndRevealTime.Format(DateFormat)) + msg += md.Paragraph("Number of Votes: " + strconv.Itoa(len(dispute.Votes)) + "\n\n") + msg += md.Paragraph("Reveal Votes: \n\n") + if dispute.NbResolvedVotes > 0 { + msg += renderRevealedVotes(dispute) + "\n\n" + } else { + msg += md.Paragraph("No votes revealed yet.\n\n") + } + } else { + msg += md.Paragraph("No dispute details available.\n\n") + } + } + msg += renderAction(req) + return msg +} + +func renderRevealedVotes(dispute Dispute) string { + var table []string + table = append(table, md.Bold("Voter"), md.Bold("Vote"), md.Bold("Token Amount")) + for _, vote := range dispute.Votes { + if vote.Revealed { + table = append(table, md.Paragraph(vote.Voter.String()), md.Paragraph(rendervalue(Requests[dispute.RequestId], vote.Value)), md.Paragraph(strconv.Itoa(int(vote.TokenAmount)))) + } + } + return md.ColumnsN(table, 3, false) +} + +func rendervalue(req DataRequest, val int64) string { + if req.YesNoQuestion { + if val == 1 { + return "Yes" + } else if val == 0 { + return "No" + } else { + return "Invalid value for Yes/No question" + } + } else { + return strconv.Itoa(int(val)) + } +} + +func renderRequest(req DataRequest) []string { + var table []string + table = append(table, md.Paragraph(req.AncillaryData)) + if req.State == "Requested" { + table = append(table, md.Paragraph("N/A")) + } else { + table = append(table, md.Paragraph(rendervalue(req, req.ProposedValue))) + } + table = append(table, md.Paragraph(req.State)) + table = append(table, md.Link("Click here", std.CurrentRealm().PkgPath()[8:] + ":" + req.Id)) + return table +} + +func renderAction(req DataRequest) string { + action := md.H2("Actions:\n\n") + if (req.State == "Requested") { + action += md.Link("Propose a value", ufmt.Sprintf("%s", txlink.NewLink("ProposeData").AddArgs("id", req.Id, "proposedValue", "YOUR_VALUE_HERE").SetSend(strconv.Itoa(int(Bond))).URL())) + "\n\n(Note: Proposing need to bond " + strconv.Itoa(int(Bond / 1_000_000)) + " GNOT)" + } else if (req.State == "Proposed") { + action += md.Link("Dispute value", ufmt.Sprintf("%s", txlink.NewLink("DisputeData").AddArgs("id", req.Id).SetSend(strconv.Itoa(int(Bond))).URL())) + "\n\n(Note: Disputing need to bond " + strconv.Itoa(int(Bond / 1_000_000)) + " GNOT)" + } else if (req.State == "Disputed" && !Disputes[req.Id].IsResolved) { + if Disputes[req.Id].EndTime.Before(time.Now()) { + action += md.Link("Reveal vote", ufmt.Sprintf("%s", txlink.NewLink("RevealVote").AddArgs("id", req.Id, "value", "YOUR_VALUE_HERE", "salt", "YOUR_SALT_HERE").URL())) + "\n\n(Note: You must reveal your vote before the reveal period ends at " + Disputes[req.Id].EndRevealTime.UTC().Format(time.UnixDate) + ")" + } else { + action += md.Link("Vote", ufmt.Sprintf("%s", txlink.NewLink("SubmitVote").AddArgs("id", req.Id, "hash", "YOUR_HASH_HERE").URL())) + "\n\n(Note: Voting costs " + strconv.Itoa(int(VotePrice / 1_000_000)) + " GNOT and requires you to hash your vote with a salt. Use a tool to generate the sha256 hash and keep your salt safe to reveal your vote)" + } + } else if req.State == "Disputed" && Disputes[req.Id].EndTime.Before(time.Now()) { + action += md.Link("Resolve dispute", ufmt.Sprintf("%s", txlink.NewLink("ResolveDispute").AddArgs("id", req.Id).URL())) + "\n\n" + } else if req.State == "Proposed" && Requests[req.Id].ResolutionTime.Before(time.Now()) { + action += md.Link("Resolve request", ufmt.Sprintf("%s", txlink.NewLink("ResolveRequest").AddArgs("id", req.Id).URL())) + "\n\n" + } + return action +} diff --git a/examples/gno.land/r/intermarch3/oo/resolver.gno b/examples/gno.land/r/intermarch3/oo/resolver.gno new file mode 100644 index 00000000000..9b8f1543647 --- /dev/null +++ b/examples/gno.land/r/intermarch3/oo/resolver.gno @@ -0,0 +1,60 @@ +package oo + +func resolve(id string) int64 { + dispute, _ := Disputes[id] + if dispute.NbResolvedVotes == 0 { + // If no one voted or reveal their vote, the proposed value wins by default + return Requests[id].ProposedValue + } else { + if Requests[id].YesNoQuestion { + return resolveYesNo(dispute) + } else { + return resolveNumeric(dispute) + } + } +} + +func resolveYesNo(dispute Dispute) int64{ + yesVotes := int64(0) + noVotes := int64(0) + for _, vote := range dispute.Votes { + if vote.Revealed { + if vote.Value == 1 { + yesVotes += vote.TokenAmount + } else if vote.Value == 0 { + noVotes += vote.TokenAmount + } + } + } + var winningValue int64 + if yesVotes > noVotes { + winningValue = 1 + } else { + winningValue = 0 + } + return winningValue +} + +// resolveNumeric determines the winning value for numeric disputes based on the highest total token weight. +// Not the best algorithm, but gas effective and fast. +func resolveNumeric(dispute Dispute) int64 { + // Collect unique values and aggregate weights + valueWeights := make(map[int64]int64) + for _, vote := range dispute.Votes { + if vote.Revealed { + valueWeights[vote.Value] += vote.TokenAmount + } + } + + // Find the value with the most token weight + var winningValue int64 + var maxWeight int64 + + for value, weight := range valueWeights { + if weight > maxWeight { + maxWeight = weight + winningValue = value + } + } + return winningValue +} From 1ca71d8d3a9b7ad79644b0686769928216a74f5c Mon Sep 17 00:00:00 2001 From: Lucas Leclerc Date: Sun, 24 Aug 2025 12:54:20 +0200 Subject: [PATCH 2/5] feat(examples): add tokenomics mecanism to the oo --- examples/gno.land/r/intermarch3/oo/README.md | 56 ++++++++++----- examples/gno.land/r/intermarch3/oo/court.gno | 65 +++++++++++++---- examples/gno.land/r/intermarch3/oo/oracle.gno | 70 ++++++++++++++++--- examples/gno.land/r/intermarch3/oo/render.gno | 31 ++++++-- .../gno.land/r/intermarch3/oo/resolver.gno | 30 ++++++++ examples/gno.land/r/intermarch3/oo/token.gno | 34 +++++++++ 6 files changed, 239 insertions(+), 47 deletions(-) create mode 100644 examples/gno.land/r/intermarch3/oo/token.gno diff --git a/examples/gno.land/r/intermarch3/oo/README.md b/examples/gno.land/r/intermarch3/oo/README.md index 4474ea7b5e7..98cafb034fc 100644 --- a/examples/gno.land/r/intermarch3/oo/README.md +++ b/examples/gno.land/r/intermarch3/oo/README.md @@ -1,8 +1,6 @@ # Gno Optimistic Oracle (OO) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -An Optimistic Oracle (OO) built on Gno.land. This system is designed to bring external data onto the blockchain by leveraging game-theoretic incentives. It assumes data is correct unless disputed, hence the term "optimistic." +An Optimistic Oracle (OO) built on Gno.land. This system is designed to bring external data onto the blockchain by leveraging game-theoretic incentives. It assumes data is correct unless disputed, hence the term "optimistic." This implementation is inspired by the [UMA Optimistic Oracle](https://uma.xyz/) but adapted for the Gno ecosystem. ## Table of Contents @@ -10,6 +8,7 @@ This implementation is inspired by the [UMA Optimistic Oracle](https://uma.xyz/) - [How It Works: The Lifecycle of a Data Request](#how-it-works-the-lifecycle-of-a-data-request) - [Architecture](#architecture) - [User Roles](#user-roles) +- [Tokenomics](#tokenomics) - [Usage Example](#usage-example) - [Developer](#developer) @@ -18,7 +17,7 @@ This implementation is inspired by the [UMA Optimistic Oracle](https://uma.xyz/) The Gno Optimistic Oracle operates on the principle that data proposed to the oracle is assumed to be true. A bond is required for any new proposition. This proposition enters a "liveness" period where anyone can dispute it by posting an equal bond. - **Happy Path**: If no one disputes the data within the liveness period, it is considered resolved and accepted as truth. The proposer's bond is returned along with a reward. -- **Unhappy Path (Dispute)**: If the data is disputed, the Gno community is called upon to vote on the correct outcome. This is handled by the `court.gno` contract. Token holders vote, and the outcome is decided by the total token weight backing each value. The winner's bond is returned, and they receive a portion of the loser's slashed bond. +- **Unhappy Path (Dispute)**: If the data is disputed, Oracle Token Holders can vote on the correct outcome. This is handled by the `court.gno` contract. Token holders vote, and the outcome is decided by the total token weight backing each value. The winner's bond is returned, and they receive a portion of the loser's slashed bond. ## How It Works: The Lifecycle of a Data Request @@ -50,7 +49,7 @@ A **Disputer** can challenge the Proposer's value. - This action pauses the request's resolution and initiates a formal dispute, handled by the `court.gno` contract. ### 5. Voting (`VoteOnDispute`) -The dispute is now open for voting by all GNOT holders. The system uses a **commit-reveal scheme** to prevent vote-copying. +The dispute is now open for voting by all Oracle Soulbound Token holders. The system uses a **commit-reveal scheme** to prevent vote-copying. - **Commit Phase**: During the `DisputeDuration`, voters submit a hash of their vote (`SHA256(value + salt)`) by calling `VoteOnDispute`. They must also pay a small `VotePrice` fee. // todo - **Reveal Phase**: After the commit phase ends, the `RevealDuration` begins. Voters must call `RevealVote`, submitting their original `value` and `salt`. The contract verifies that the hash matches the one submitted during the commit phase. @@ -59,7 +58,7 @@ The dispute is now open for voting by all GNOT holders. The system uses a **comm Once the reveal period is over, anyone can call `ResolveDispute`. - The `resolver.gno` contract tallies the votes. The winning value is the one with the highest cumulative token weight from voters. - The `WinningValue` is set in the original `DataRequest`. -- **Slashing & Rewards**: The party (Proposer or Disputer) that lost the vote has their bond slashed. The winning party gets their bond back, and the slashed bond is distributed among the voters who voted for the winning outcome. +- **Slashing & Rewards**: The party (Proposer or Disputer) that lost the vote has their bond slashed. The winning party gets their bond back, and the slashed bond is distributed among the voters who voted for the winning outcome (voters who vote incorrectly lose 25% of their Oracle Token balance). ## Architecture @@ -74,51 +73,76 @@ The oracle is composed of three main contracts: - **Requester**: The user or contract that needs external data. They create the request and fund the reward. - **Proposer**: The user who provides the initial answer to a data request and posts a bond. - **Disputer**: A user who challenges a proposed value and posts a bond to initiate a vote. -- **Voter**: A GNOT holder who participates in a dispute by voting on the correct outcome. +- **Voter**: An Oracle Soulbound Token holder who participates in a dispute by voting on the correct outcome. + +## Tokenomics + +The Oracle Soulbound Token (OOT) is a non-transferable token that represents voting power in the oracle system. +- **Acquisition**: Users can acquire OOT by calling `BuyInitialVoteToken` and paying a fee in GNOT. This action mints one OOT to the caller's address (can only buy 1 initial token). +Vote token holders can participate in disputes and earn rewards by voting correctly. By voting correctly, they can earn a portion of the slashed bonds from losing parties and gain 2 Vote tokens, incentivizing accurate and honest participation in the oracle system and increasing their voting power. +- **GNOT Usage**: The reward and the Bond (in GNOT Token) need to be less than the bond to avoid trying to game the system by creating disputes just to earn tokens, as they would lose more from the bond than the reward. +This design is not final and can be adjusted based on community feedback and economic analysis. ## Usage Example -Here is a full workflow using `gnokey`. +Here is a full workflow using `gnokey`. +**0. Buy Vote Token for Voter Role** +```bash +# Buy Oracle Soulbound Token (replace with your key name) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "BuyInitialVoteToken" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "1000000ugnot" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + **1. Request Data** ```bash # Ask a Yes/No question: "Will ETH be below $4000 ?" (replace DEADLINE_TIMESTAMP with a future unix timestamp more than 24h from now) -gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "RequestData" -args "Will ETH be below 4000$ ?" -args "true" -args "DEADLINE_TIMESTAMP" --gas-fee 1000000ugnot --gas-wanted 5000000 --send "1000000ugnot" --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "RequestData" -args "ETH below 4000$ ?" -args "true" -args "DEADLINE_TIMESTAMP" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "1000000ugnot" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" ``` **2. Propose a Value** ```bash # Propose "Yes" (value 1) (replace ID with the actual ID returned from the RequestData call) -gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "ProposeValue" -args "ID" -args "1" --gas-fee 1000000ugnot --gas-wanted 10000000 --send "2000000ugnot" --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "ProposeValue" -args "ID" -args "0" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "2000000ugnot" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +**If no one disputes within the liveness period, anyone can resolve the request:** +```bash +# Resolve the request (replace ID with the actual ID) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "ResolveRequest" -args "ID" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" ``` **3. Dispute the Value** ```bash # Dispute the proposal (replace ID with the actual ID) -gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "DisputeData" -args "ID" --gas-fee 1000000ugnot --gas-wanted 5000000 --send "2000000ugnot" --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "DisputeData" -args "ID" -gas-fee 1000000ugnot -gas-wanted 5000000 -send "2000000ugnot" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" ``` **4. Vote on the Dispute** First, generate a hash locally. Let's vote "No" (value 0) with salt "mysecret". -Hash: `sha256("0" + "mysecret")` -> `a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21` +Hash: `sha256("0" + "test")` -> `a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21` ```bash # Commit the vote (replace ID with the actual ID) -gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "VoteOnDispute" -args "ID" -args "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21" --gas-fee 1000000ugnot --gas-wanted 5000000 --send "1000000ugnot" --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +ggnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "VoteOnDispute" -args "ID" -args "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21" -gas-fee 1000000ugnot -gas-wanted 5000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" ``` **5. Reveal the Vote** ```bash # Reveal the vote after the voting period ends (replace ID with the actual ID) -gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "RevealVote" -args "ID" -args "0" -args "mysecret" --gas-fee 1000000ugnot --gas-wanted 10000000 --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "RevealVote" -args "ID" -args "0" -args "test" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" ``` **6. Resolve the Dispute** ```bash # After the reveal period, anyone can trigger the final resolution (replace ID with the actual ID). -gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "ResolveDispute" -args "ID" --gas-fee 1000000ugnot --gas-wanted 10000000 --broadcast true --chainid "dev" --remote "tcp://127.0.0.1:26657" +gnokey maketx call -pkgpath "gno.land/r/intermarch3/oo" -func "ResolveDispute" -args "ID" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" ``` -**Warning**: When testing with `gnodev`, ensure to make transactions between waiting periods as `gnodev` does create blocks only when a transaction is made and the oracle relies on block timestamps. +When testing with `gnodev` locally, ensure to make transactions between waiting periods as `gnodev` only creates blocks when a transaction is made, and the oracle relies on current block timestamps. + +## Warning + +This is a simplified example for educational purposes. In a production environment, consider additional security measures, optimizations, and edge cases. + ## Developer diff --git a/examples/gno.land/r/intermarch3/oo/court.gno b/examples/gno.land/r/intermarch3/oo/court.gno index b1b1a836e69..29f32d72fe5 100644 --- a/examples/gno.land/r/intermarch3/oo/court.gno +++ b/examples/gno.land/r/intermarch3/oo/court.gno @@ -38,11 +38,13 @@ var ( Disputes map[string]Dispute DisputeDuration int64 = 2 * int64(time.Minute.Seconds()) RevealDuration int64 = 2 * int64(time.Minute.Seconds()) - VotePrice int64 = 1 * int64(1_000_000) // in GNOT + VoteTokenPrice int64 = 1 * int64(1_000_000) // in GNOT + VoteToken *OOT ) func init() { Disputes = make(map[string]Dispute) + VoteToken = newOOToken("Gno Optimistic Oracle Token", "goot", 6) } func initiateDispute(id string) { @@ -63,6 +65,29 @@ func initiateDispute(id string) { // -- PUBLIC FUNCTIONS -- +// BuyInitialVoteToken allows a user to buy their first vote token by sending VoteTokenPrice amount of ugnot. +func BuyInitialVoteToken(_ realm) { + caller := std.OriginCaller() + coins := std.OriginSend() + expected := std.Coin{Denom: "ugnot", Amount: VoteTokenPrice} + if len(coins) != 0 && !coins[0].IsEqual(expected) { + panic("Error: Must send exactly " + strconv.Itoa(int(VoteTokenPrice / 1_000_000)) + " gnot to get a vote token.") + } + + balance := VoteToken.BalanceOf(caller) + if balance > 0 { + panic("Error: You already have a vote token.") + } + + VoteToken.Mint(caller, 1) + std.Emit("VoteTokenPurchased", "voter", caller.String()) +} + +// BalanceOfVoteToken returns the number of vote tokens held by the caller. +func BalanceOfVoteToken(_ realm) int64 { + return VoteToken.balanceOf(std.PreviousRealm().Address()) +} + // VoteOnDispute allows a user to commit a vote during a dispute. // @param id - The ID of the dispute (same as Request ID). // @param hash - The SHA256 hash of the vote value and a secret salt. @@ -72,7 +97,7 @@ func VoteOnDispute(cur realm, id string, hash string) { panic("Error: No dispute with this ID exists.") } r, _ := Requests[id] - if r.Proposer == std.OriginCaller() || r.Disputer == std.OriginCaller() { + if r.Proposer == std.PreviousRealm().Address() || r.Disputer == std.PreviousRealm().Address() { panic("Error: Proposer and Disputer cannot vote in this dispute.") } if dispute.IsResolved { @@ -81,12 +106,15 @@ func VoteOnDispute(cur realm, id string, hash string) { if time.Now().After(dispute.EndTime) { panic("Error: Vote period has ended.") } - // TODO: check that voter has at least VotePrice GNOT to stake - // get voter token balance + amount := VoteToken.BalanceOf(std.PreviousRealm().Address()) + if amount < 1 { + panic("Error: You need at least 1 vote token to vote.") + } + vote := Vote{ RequestId: id, - Voter: std.OriginCaller(), - TokenAmount: 1, // TODO: actually get voter's token balance + Voter: std.PreviousRealm().Address(), + TokenAmount: amount, Hash: hash, Revealed: false, } @@ -117,7 +145,7 @@ func RevealVote(cur realm, id string, value int64, salt string) { if time.Now().After(dispute.EndRevealTime) { panic("Error: Reveal period has ended.") } - voter := dispute.Voters[std.OriginCaller()] + voter := dispute.Voters[std.PreviousRealm().Address()] if !voter.HasVoted { panic("Error: Voter did not participate in this dispute.") } @@ -165,7 +193,16 @@ func ResolveDispute(cur realm, id string) { Requests[id] = request std.Emit("DisputeResolved", "id", id, "winningValue", strconv.Itoa(int(val))) std.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(val))) - // TODO: Distribuate bond + reward between winning voters and if disputer win, give a part to him + + var winner std.Address + if val != request.ProposedValue { + // Refund + reward the disputer if the dispute changed the value + winner = request.Disputer + } else { + // Refund + reward the proposer if the dispute did not change the value + winner = request.Proposer + } + Bank.SendCoins(std.CurrentRealm().Address(), winner, std.Coins{std.Coin{Denom: "ugnot", Amount: Bond + RequesterReward}}) } // -- admin functions -- @@ -190,11 +227,11 @@ func SetRevealDuration(_ realm, duration int64) { } } -// SetVotePrice sets the price (in ugnot) to cast a vote. +// SetVoteTokenPrice sets the price (in ugnot) to cast a vote. // @param price - The new vote price. -func SetVotePrice(_ realm, price int64) { +func SetVoteTokenPrice(_ realm, price int64) { if std.OriginCaller() == admin { - VotePrice = price + VoteTokenPrice = price } else { panic("Error: Only admin can set vote price.") } @@ -217,9 +254,9 @@ func GetDisputeDuration(_ realm) int64 { return DisputeDuration } -// GetVotePrice returns the current vote price. -func GetVotePrice(_ realm) int64 { - return VotePrice +// GetVoteTokenPrice returns the current vote price. +func GetVoteTokenPrice(_ realm) int64 { + return VoteTokenPrice } // GetDisputeEndTime returns the end time of the voting period for a specific dispute. diff --git a/examples/gno.land/r/intermarch3/oo/oracle.gno b/examples/gno.land/r/intermarch3/oo/oracle.gno index accfebfc992..bdad654e5b0 100644 --- a/examples/gno.land/r/intermarch3/oo/oracle.gno +++ b/examples/gno.land/r/intermarch3/oo/oracle.gno @@ -4,6 +4,8 @@ import ( "time" "std" "strconv" + "crypto/sha256" + "encoding/hex" ) type DataRequest struct { @@ -20,6 +22,7 @@ type DataRequest struct { ResolutionTime time.Time WinningValue int64 State string // "Requested", "Proposed", "Disputed", "Resolved" + Deadline time.Time } var ( @@ -29,15 +32,17 @@ var ( ResolutionTime int64 = 2 * int64(time.Minute.Seconds()) RequesterReward int64 = 1 * int64(1_000_000) // in GNOT Bond int64 = 2 * int64(1_000_000) // in GNOT + Bank std.Banker ) func init() { Requests = make(map[string]DataRequest) + Bank = std.NewBanker(std.BankerTypeRealmSend) } // 6 first chars of sha256(ancillaryData + timestamp) func getUniqueId(ancillaryData string, timestamp int64) string { - data := ancillaryData + strconv.Itoa(timestamp) + data := ancillaryData + strconv.Itoa(int(timestamp)) + strconv.Itoa(int(std.ChainHeight())) hash := sha256.Sum256([]byte(data)) return hex.EncodeToString(hash[:])[:6] } @@ -63,12 +68,20 @@ func RequestData(cur realm, ancillaryData string, yesNoQuestion bool, deadline i } // TODO: check that x GNOT is sent as reward + coins := std.OriginSend() + expected := std.Coin{Denom: "ugnot", Amount: RequesterReward} + if len(coins) == 0 || !coins[0].IsEqual(expected) { + panic("Error: Incorrect reward amount sent. Required: " + strconv.FormatInt(RequesterReward, 10) + " ugnot.") + } + request := DataRequest{ Id: id, Timestamp: time.Now(), AncillaryData: ancillaryData, YesNoQuestion: yesNoQuestion, State: "Requested", + Deadline: time.Unix(deadline, 0), + Creator: std.PreviousRealm().Address(), } Requests[id] = request std.Emit("DataRequested", "id", id, "timestamp", request.Timestamp.String(), "ancillaryData", ancillaryData) @@ -90,10 +103,20 @@ func ProposeValue(cur realm, id string, proposedValue int64) { if request.YesNoQuestion && proposedValue != 0 && proposedValue != 1 { panic("Error: Proposed value must be 0 or 1 for yes/no questions.") } + if time.Now().After(request.Deadline) { + panic("Error: Deadline for proposal has passed.") + } + // TODO: check that proposerBond is x GNOT + coins := std.OriginSend() + expected := std.Coin{Denom: "ugnot", Amount: Bond} + if len(coins) == 0 || !coins[0].IsEqual(expected) { + panic("Error: Incorrect bond amount sent. Required: " + strconv.FormatInt(Bond, 10) + " ugnot") + } + request.ProposedValue = proposedValue - request.Proposer = std.OriginCaller() - request.ProposerBond = 1 // TODO: check actual bond sent + request.Proposer = std.PreviousRealm().Address() + request.ProposerBond = Bond request.ResolutionTime = time.Now().Add(time.Duration(ResolutionTime) * time.Second) request.State = "Proposed" Requests[id] = request @@ -108,7 +131,7 @@ func DisputeData(cur realm, id string) string { if !exists { panic("Error: No request with this ID exists.") } - if request.Proposer == std.OriginCaller() { + if request.Proposer == std.PreviousRealm().Address() { panic("Error: Proposer cannot dispute their own proposal.") } if request.State != "Proposed" { @@ -117,8 +140,16 @@ func DisputeData(cur realm, id string) string { if time.Now().After(request.ResolutionTime) { panic("Error: Dispute period has ended.") } - request.Disputer = std.OriginCaller() - request.DisputerBond = 1 // TODO: check actual bond sent + + // Vérifie que la caution a bien été envoyée. + coins := std.OriginSend() + expected := std.Coin{Denom: "ugnot", Amount: Bond} + if len(coins) == 0 || !coins[0].IsEqual(expected) { + panic("Error: Incorrect bond amount sent. Required: " + strconv.FormatInt(Bond, 10) + " ugnot") + } + + request.Disputer = std.PreviousRealm().Address() + request.DisputerBond = Bond request.State = "Disputed" Requests[id] = request initiateDispute(id) @@ -148,8 +179,15 @@ func ResolveRequest(cur realm, id string) { request.State = "Resolved" request.WinningValue = request.ProposedValue Requests[id] = request - std.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(request.WinningValue))) + // TODO: Return bonds + reward to proposer + from := std.CurrentRealm().Address() + to := request.Proposer + totalPayout := request.ProposerBond + RequesterReward + payout := std.Coins{std.Coin{Denom: "ugnot", Amount: totalPayout}} + Bank.SendCoins(from, to, payout) + + std.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(request.WinningValue))) } // RequestResult returns the winning value of a resolved request. @@ -160,7 +198,7 @@ func RequestResult(cur realm, id string) int64 { panic("Error: No request with this ID exists.") } if request.State != "Resolved" { - panic("Error: Request is not resolved yet.") + panic("Error: Request is not resolved.") } return request.WinningValue } @@ -175,13 +213,23 @@ func RequesterRetreiveFund(cur realm, id string) { if request.State != "Requested" { panic("Error: cannot retreive fund as requests fulfilled.") } - if request.Creator != std.OriginCaller() { + if request.Creator != std.PreviousRealm().Address() { panic("Error: Only the creator of the request can retreive the fund.") } - if request.Deadline > time.Now().Unix() { + if request.Deadline.After(time.Now()) { panic("Error: Cannot retreive fund before the deadline.") } - // TODO: return the fund to the requester + + // Rembourser la récompense au créateur de la requête + from := std.CurrentRealm().Address() + to := request.Creator + refund := std.Coins{std.Coin{Denom: "ugnot", Amount: RequesterReward}} + Bank.SendCoins(from, to, refund) + + // Marquer la requête comme expirée pour éviter un double remboursement + request.State = "Expired" + Requests[id] = request + std.Emit("RequestExpired", "id", id) } diff --git a/examples/gno.land/r/intermarch3/oo/render.gno b/examples/gno.land/r/intermarch3/oo/render.gno index 661c78609fc..1ad44bffbdb 100644 --- a/examples/gno.land/r/intermarch3/oo/render.gno +++ b/examples/gno.land/r/intermarch3/oo/render.gno @@ -22,13 +22,11 @@ func Render(path string) string { func renderHome() string { msg := md.H1("OO Home") - msg += md.Paragraph("Welcome to the first Optimistic Oracle on GnoLand! This project is developed by @intermarch3.") + msg += md.Paragraph("Welcome to the first Optimistic Oracle on GnoLand! This project is developed by " + md.Link("@intermarch3", "/r/intermarch3/home")) + msg += md.Paragraph("For more information, visit the " + md.Link("Readme", std.CurrentRealm().PkgPath()[8:] + "$source") + ".") msg += md.Link("Request Data", ufmt.Sprintf("%s", txlink.NewLink("RequestData").AddArgs("id", "YOUR_ID", "ancillaryData", "YOUR_QUESTION", "yesNoQuestion", "true or false").SetSend(strconv.Itoa(int(RequesterReward))).URL())) + "\n\n(Note: Requesting data need to pay a reward of " + strconv.Itoa(int(RequesterReward / 1_000_000)) + " GNOT to the proposer).\n\n" msg += md.H2("Current Requests :") - if len(Requests) == 0 { - msg += md.Paragraph("No current requests.") - return msg - } + // List current requests var table []string table = append(table, md.Bold("Question"), md.Bold("Proposed Value"), md.Bold("State"), md.Bold("See more")) for _, req := range Requests { @@ -38,6 +36,27 @@ func renderHome() string { table = append(table, renderRequest(req)...) } msg += md.ColumnsN(table, 4, false) + if len(table) == 4 { + msg += md.Paragraph("No current requests.") + } + + msg += md.HorizontalRule() + // List last 5 resolved requests + msg += md.H2("Last 5 Resolved Requests :") + var resolved []string + resolved = append(resolved, md.Bold("Question"), md.Bold("Winning Value"), md.Bold("Proposer"), md.Bold("See more")) + for _, req := range Requests { + if req.State == "Resolved" { + resolved = append(resolved, md.Paragraph(req.AncillaryData)) + resolved = append(resolved, md.Paragraph(rendervalue(req, req.WinningValue))) + resolved = append(resolved, md.Paragraph(req.Proposer.String())) + resolved = append(resolved, md.Link("Click here", std.CurrentRealm().PkgPath()[8:] + ":" + req.Id)) + } + } + msg += md.ColumnsN(resolved, 4, false) + if len(resolved) == 4 { + msg += md.Paragraph("No resolved requests yet.") + } return msg } @@ -151,7 +170,7 @@ func renderAction(req DataRequest) string { if Disputes[req.Id].EndTime.Before(time.Now()) { action += md.Link("Reveal vote", ufmt.Sprintf("%s", txlink.NewLink("RevealVote").AddArgs("id", req.Id, "value", "YOUR_VALUE_HERE", "salt", "YOUR_SALT_HERE").URL())) + "\n\n(Note: You must reveal your vote before the reveal period ends at " + Disputes[req.Id].EndRevealTime.UTC().Format(time.UnixDate) + ")" } else { - action += md.Link("Vote", ufmt.Sprintf("%s", txlink.NewLink("SubmitVote").AddArgs("id", req.Id, "hash", "YOUR_HASH_HERE").URL())) + "\n\n(Note: Voting costs " + strconv.Itoa(int(VotePrice / 1_000_000)) + " GNOT and requires you to hash your vote with a salt. Use a tool to generate the sha256 hash and keep your salt safe to reveal your vote)" + action += md.Link("Vote", ufmt.Sprintf("%s", txlink.NewLink("SubmitVote").AddArgs("id", req.Id, "hash", "YOUR_HASH_HERE").URL())) + "\n\n(Note: Voting need to hold a Vote Token and requires you to hash your vote with a salt. Use a tool to generate the sha256 hash and keep your salt safe to reveal your vote)" } } else if req.State == "Disputed" && Disputes[req.Id].EndTime.Before(time.Now()) { action += md.Link("Resolve dispute", ufmt.Sprintf("%s", txlink.NewLink("ResolveDispute").AddArgs("id", req.Id).URL())) + "\n\n" diff --git a/examples/gno.land/r/intermarch3/oo/resolver.gno b/examples/gno.land/r/intermarch3/oo/resolver.gno index 9b8f1543647..95f1e332c3e 100644 --- a/examples/gno.land/r/intermarch3/oo/resolver.gno +++ b/examples/gno.land/r/intermarch3/oo/resolver.gno @@ -1,5 +1,9 @@ package oo +import ( + "std" +) + func resolve(id string) int64 { dispute, _ := Disputes[id] if dispute.NbResolvedVotes == 0 { @@ -27,11 +31,15 @@ func resolveYesNo(dispute Dispute) int64{ } } var winningValue int64 + var weight int64 if yesVotes > noVotes { winningValue = 1 + weight = yesVotes } else { winningValue = 0 + weight = noVotes } + rewardAndSlachVoters(dispute, winningValue, weight) return winningValue } @@ -56,5 +64,27 @@ func resolveNumeric(dispute Dispute) int64 { winningValue = value } } + rewardAndSlachVoters(dispute, winningValue, maxWeight) return winningValue } + +func rewardAndSlachVoters(dispute Dispute, winningValue, totalWeight int64) { + for _, vote := range dispute.Votes { + if vote.Revealed { + if vote.Value == winningValue { + // Reward winning voters + VoteToken.mint(vote.Voter, 2) + reward := Bond * (vote.TokenAmount / totalWeight) + if (reward != 0) { + Bank.SendCoins(std.CurrentRealm().Address(), vote.Voter, std.Coins{std.Coin{Denom: "ugnot", Amount: reward}}) + } + std.Emit("VoterRewarded", "voter", vote.Voter.String()) + } else { + // Slash losing voters + slash := vote.TokenAmount / 4 // 25% slash + VoteToken.burn(vote.Voter, slash) + std.Emit("VoterSlashed", "voter", vote.Voter.String()) + } + } + } +} diff --git a/examples/gno.land/r/intermarch3/oo/token.gno b/examples/gno.land/r/intermarch3/oo/token.gno new file mode 100644 index 00000000000..5188bf1ad88 --- /dev/null +++ b/examples/gno.land/r/intermarch3/oo/token.gno @@ -0,0 +1,34 @@ +package oo + +import ( + "std" + "gno.land/p/demo/grc/grc20" +) + +type OOT struct { + *grc20.Token + *grc20.PrivateLedger +} + + +func newOOToken(name, symbol string, decimals int) *OOT { + token := &OOT{} + t, adm := grc20.NewToken(name, symbol, decimals) + token.Token = t + token.PrivateLedger = adm + return token +} + +// Mint create new tokens to an address. +func (t *OOT) mint(to std.Address, amount int64) { + t.PrivateLedger.Mint(to, amount) +} + +// Burn destroy tokens from an address. +func (t *OOT) burn(from std.Address, amount int64) { + t.PrivateLedger.Burn(from, amount) +} + +func (t *OOT) balanceOf(addr std.Address) int64 { + return t.Token.BalanceOf(addr) +} From 184b69763e8f8a7cba023510f73119660171dfb8 Mon Sep 17 00:00:00 2001 From: Lucas Leclerc Date: Sun, 24 Aug 2025 12:54:52 +0200 Subject: [PATCH 3/5] feat(example): intermarch3 home page --- .../gno.land/r/intermarch3/home/gnomod.toml | 2 + examples/gno.land/r/intermarch3/home/home.gno | 94 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 examples/gno.land/r/intermarch3/home/gnomod.toml create mode 100644 examples/gno.land/r/intermarch3/home/home.gno diff --git a/examples/gno.land/r/intermarch3/home/gnomod.toml b/examples/gno.land/r/intermarch3/home/gnomod.toml new file mode 100644 index 00000000000..a2e9b3a3325 --- /dev/null +++ b/examples/gno.land/r/intermarch3/home/gnomod.toml @@ -0,0 +1,2 @@ +module = "gno.land/r/intermarch3/home" +gno = "0.9" diff --git a/examples/gno.land/r/intermarch3/home/home.gno b/examples/gno.land/r/intermarch3/home/home.gno new file mode 100644 index 00000000000..d7362525e0f --- /dev/null +++ b/examples/gno.land/r/intermarch3/home/home.gno @@ -0,0 +1,94 @@ +package home + +import ( + "gno.land/p/moul/md" + "gno.land/r/leon/hor" + "std" +) + +var ( + admin = std.Address("g1qdwemgqgqm7s42gy6xcj7f22uhfdzr82st3sy3") + project = md.Paragraph("Discover the first Optimistic Oracle on Gnoland.") + + md.Paragraph("This project is a proof of concept for an oracle system that leverages community participation and token incentives to provide reliable off-chain data to smart contracts. It is inspired by UMA's Optimistic Oracle but adapted for the Gno ecosystem.") + + md.Paragraph("More about the project: " + md.Link("Here", "gno.land/r/intermarch3/oo$source")) + title = "The home realm of Intermarch3." + githubUsername = "intermarch3" + pocInnovationName = "PoCInnovation" + art = ` + += += + @@@@@@@@@@@@@@@@* + @@@@@@@@@@@@@@@@@@% + @@@@@@@@@@@@@@@@@@@@+ + :@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@- + ....+@@@@@@@@@@@@@@@@@@@@@@%:... + :@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@- + + + %@@@: =@@@# + %@@@@@* #@@@@@* + =@@@@@ @@@@@: + ' ' ` +) + +func init() { + hor.Register(cross, "Intermarch3 Home Realm", title) +} + +func renderProject() string { + out := md.H2("⚒ Project highlight") + out += md.Paragraph(project) + return out +} + +func renderArt() string { + out := md.H2("🎨 Art") + out += md.CodeBlock(art) + return out +} + +func renderBody() string { + return md.Columns([]string{ + renderProject(), + renderArt(), + }, false) +} + +func renderTitle() string { + return md.H1(title) +} + +func renderFooter() string { + out := md.HorizontalRule() + out += md.BulletList([]string{ + md.Link("Home", "home"), + md.Link("GitHub: @"+githubUsername, "https://github.com/"+githubUsername), + md.Link("PoC-Innovation", "https://github.com/"+pocInnovationName), + }) + return out +} + +func Render(_ string) string { + out := renderTitle() + out += md.Paragraph("Welcome to the home realm of Intermarch3, a developer and passionate about blockchain.") + "\n\n" + out += renderBody() + out += renderFooter() + + return out +} + +// -- Update functions -- + +func UpdateProject(_ realm, newProject string) { + if std.PreviousRealm().Address() != admin { + panic("only admin can update the project description") + } + project = newProject +} + +func UpdateArt(_ realm, newArt string) { + if std.PreviousRealm().Address() != admin { + panic("only admin can update the art") + } + art = newArt +} From 2e704ccab01d92aa8a78e3febfdf552a54c30084 Mon Sep 17 00:00:00 2001 From: Lucas Leclerc Date: Sun, 24 Aug 2025 14:14:42 +0200 Subject: [PATCH 4/5] refactor(examples): fix format --- examples/gno.land/r/intermarch3/home/home.gno | 15 ++-- examples/gno.land/r/intermarch3/oo/court.gno | 88 +++++++++---------- examples/gno.land/r/intermarch3/oo/oracle.gno | 45 +++++----- examples/gno.land/r/intermarch3/oo/render.gno | 47 +++++----- .../gno.land/r/intermarch3/oo/resolver.gno | 8 +- examples/gno.land/r/intermarch3/oo/token.gno | 2 +- 6 files changed, 102 insertions(+), 103 deletions(-) diff --git a/examples/gno.land/r/intermarch3/home/home.gno b/examples/gno.land/r/intermarch3/home/home.gno index d7362525e0f..f66c8177e32 100644 --- a/examples/gno.land/r/intermarch3/home/home.gno +++ b/examples/gno.land/r/intermarch3/home/home.gno @@ -1,20 +1,21 @@ package home import ( + "std" + "gno.land/p/moul/md" "gno.land/r/leon/hor" - "std" ) var ( - admin = std.Address("g1qdwemgqgqm7s42gy6xcj7f22uhfdzr82st3sy3") + admin = std.Address("g1qdwemgqgqm7s42gy6xcj7f22uhfdzr82st3sy3") project = md.Paragraph("Discover the first Optimistic Oracle on Gnoland.") + - md.Paragraph("This project is a proof of concept for an oracle system that leverages community participation and token incentives to provide reliable off-chain data to smart contracts. It is inspired by UMA's Optimistic Oracle but adapted for the Gno ecosystem.") + - md.Paragraph("More about the project: " + md.Link("Here", "gno.land/r/intermarch3/oo$source")) - title = "The home realm of Intermarch3." - githubUsername = "intermarch3" + md.Paragraph("This project is a proof of concept for an oracle system that leverages community participation and token incentives to provide reliable off-chain data to smart contracts. It is inspired by UMA's Optimistic Oracle but adapted for the Gno ecosystem.") + + md.Paragraph("More about the project: "+md.Link("Here", "gno.land/r/intermarch3/oo$source")) + title = "The home realm of Intermarch3." + githubUsername = "intermarch3" pocInnovationName = "PoCInnovation" - art = ` + art = ` += += @@@@@@@@@@@@@@@@* @@@@@@@@@@@@@@@@@@% diff --git a/examples/gno.land/r/intermarch3/oo/court.gno b/examples/gno.land/r/intermarch3/oo/court.gno index 29f32d72fe5..10dff56793b 100644 --- a/examples/gno.land/r/intermarch3/oo/court.gno +++ b/examples/gno.land/r/intermarch3/oo/court.gno @@ -1,45 +1,45 @@ package oo import ( - "time" "std" "strconv" + "time" "crypto/sha256" "encoding/hex" ) type Vote struct { - RequestId string - Voter std.Address + RequestId string + Voter std.Address TokenAmount int64 - Hash string - Value int64 - Revealed bool + Hash string + Value int64 + Revealed bool } type Voter struct { - HasVoted bool + HasVoted bool VoteIndex int64 } type Dispute struct { - RequestId string - Votes []Vote + RequestId string + Votes []Vote NbResolvedVotes int64 - Voters map[std.Address]Voter - IsResolved bool - WinningValue int64 - EndTime time.Time - EndRevealTime time.Time + Voters map[std.Address]Voter + IsResolved bool + WinningValue int64 + EndTime time.Time + EndRevealTime time.Time } var ( - Disputes map[string]Dispute + Disputes map[string]Dispute DisputeDuration int64 = 2 * int64(time.Minute.Seconds()) - RevealDuration int64 = 2 * int64(time.Minute.Seconds()) - VoteTokenPrice int64 = 1 * int64(1_000_000) // in GNOT - VoteToken *OOT + RevealDuration int64 = 2 * int64(time.Minute.Seconds()) + VoteTokenPrice int64 = 1 * int64(1_000_000) // in GNOT + VoteToken *OOT ) func init() { @@ -52,12 +52,12 @@ func initiateDispute(id string) { panic("Error: Dispute for this request already exists.") } dispute := Dispute{ - RequestId: id, - Votes: []Vote{}, - Voters: make(map[std.Address]Voter), - IsResolved: false, - EndTime: time.Now().Add(time.Duration(DisputeDuration) * time.Second), - EndRevealTime: time.Now().Add(time.Duration(DisputeDuration + RevealDuration) * time.Second), + RequestId: id, + Votes: []Vote{}, + Voters: make(map[std.Address]Voter), + IsResolved: false, + EndTime: time.Now().Add(time.Duration(DisputeDuration) * time.Second), + EndRevealTime: time.Now().Add(time.Duration(DisputeDuration+RevealDuration) * time.Second), } Disputes[id] = dispute std.Emit("DisputeInitiated", "id", id) @@ -67,20 +67,20 @@ func initiateDispute(id string) { // BuyInitialVoteToken allows a user to buy their first vote token by sending VoteTokenPrice amount of ugnot. func BuyInitialVoteToken(_ realm) { - caller := std.OriginCaller() - coins := std.OriginSend() - expected := std.Coin{Denom: "ugnot", Amount: VoteTokenPrice} - if len(coins) != 0 && !coins[0].IsEqual(expected) { - panic("Error: Must send exactly " + strconv.Itoa(int(VoteTokenPrice / 1_000_000)) + " gnot to get a vote token.") - } - - balance := VoteToken.BalanceOf(caller) - if balance > 0 { - panic("Error: You already have a vote token.") - } - - VoteToken.Mint(caller, 1) - std.Emit("VoteTokenPurchased", "voter", caller.String()) + caller := std.OriginCaller() + coins := std.OriginSend() + expected := std.Coin{Denom: "ugnot", Amount: VoteTokenPrice} + if len(coins) != 0 && !coins[0].IsEqual(expected) { + panic("Error: Must send exactly " + strconv.Itoa(int(VoteTokenPrice/1_000_000)) + " gnot to get a vote token.") + } + + balance := VoteToken.BalanceOf(caller) + if balance > 0 { + panic("Error: You already have a vote token.") + } + + VoteToken.Mint(caller, 1) + std.Emit("VoteTokenPurchased", "voter", caller.String()) } // BalanceOfVoteToken returns the number of vote tokens held by the caller. @@ -112,11 +112,11 @@ func VoteOnDispute(cur realm, id string, hash string) { } vote := Vote{ - RequestId: id, - Voter: std.PreviousRealm().Address(), + RequestId: id, + Voter: std.PreviousRealm().Address(), TokenAmount: amount, - Hash: hash, - Revealed: false, + Hash: hash, + Revealed: false, } if dispute.Voters[vote.Voter].HasVoted { panic("Error: Voter has already voted in this dispute.") @@ -157,7 +157,7 @@ func RevealVote(cur realm, id string, value int64, salt string) { // Verify the hash res := sha256.Sum256([]byte(strconv.FormatInt(value, 10) + salt)) expectedHash := hex.EncodeToString(res[:]) - if (vote.Hash != expectedHash) { + if vote.Hash != expectedHash { panic("Error: Hash does not match the revealed value and salt.") } vote.Value = value @@ -292,4 +292,4 @@ func GetRevealEndTime(_ realm, id string) time.Time { // GetRevealDuration returns the current reveal duration. func GetRevealDuration(_ realm) int64 { return RevealDuration -} \ No newline at end of file +} diff --git a/examples/gno.land/r/intermarch3/oo/oracle.gno b/examples/gno.land/r/intermarch3/oo/oracle.gno index bdad654e5b0..e2345c18955 100644 --- a/examples/gno.land/r/intermarch3/oo/oracle.gno +++ b/examples/gno.land/r/intermarch3/oo/oracle.gno @@ -1,38 +1,38 @@ package oo import ( - "time" - "std" - "strconv" "crypto/sha256" "encoding/hex" + "std" + "strconv" + "time" ) type DataRequest struct { - Id string - Creator std.Address - Timestamp time.Time - AncillaryData string - YesNoQuestion bool - ProposedValue int64 - Proposer std.Address - ProposerBond int64 - Disputer std.Address - DisputerBond int64 + Id string + Creator std.Address + Timestamp time.Time + AncillaryData string + YesNoQuestion bool + ProposedValue int64 + Proposer std.Address + ProposerBond int64 + Disputer std.Address + DisputerBond int64 ResolutionTime time.Time - WinningValue int64 - State string // "Requested", "Proposed", "Disputed", "Resolved" - Deadline time.Time + WinningValue int64 + State string // "Requested", "Proposed", "Disputed", "Resolved" + Deadline time.Time } var ( Requests map[string]DataRequest - admin = std.Address("g1qdwemgqgqm7s42gy6xcj7f22uhfdzr82st3sy3") - ResolutionTime int64 = 2 * int64(time.Minute.Seconds()) + admin = std.Address("g1qdwemgqgqm7s42gy6xcj7f22uhfdzr82st3sy3") + ResolutionTime int64 = 2 * int64(time.Minute.Seconds()) RequesterReward int64 = 1 * int64(1_000_000) // in GNOT - Bond int64 = 2 * int64(1_000_000) // in GNOT - Bank std.Banker + Bond int64 = 2 * int64(1_000_000) // in GNOT + Bank std.Banker ) func init() { @@ -63,7 +63,7 @@ func RequestData(cur realm, ancillaryData string, yesNoQuestion bool, deadline i if ancillaryData == "" { panic("Error: Ancillary data cannot be empty.") } - if deadline < time.Now().Add(24 * time.Hour).Unix() { + if deadline < time.Now().Add(24*time.Hour).Unix() { panic("Error: Deadline must be at least 24 hours in the future.") } @@ -232,10 +232,8 @@ func RequesterRetreiveFund(cur realm, id string) { std.Emit("RequestExpired", "id", id) } - // -- ADMIN FUNCTIONS -- - // SetResolutionDuration sets the duration (in seconds) for the resolution period. // @param duration - The new resolution duration in seconds. func SetResolutionDuration(_ realm, duration int64) { @@ -321,4 +319,3 @@ func GetRequestState(_ realm, id string) string { func GetAdmin(_ realm) std.Address { return admin } - diff --git a/examples/gno.land/r/intermarch3/oo/render.gno b/examples/gno.land/r/intermarch3/oo/render.gno index 1ad44bffbdb..92eb820c665 100644 --- a/examples/gno.land/r/intermarch3/oo/render.gno +++ b/examples/gno.land/r/intermarch3/oo/render.gno @@ -1,13 +1,14 @@ package oo import ( - "gno.land/p/moul/md" - "gno.land/p/moul/realmpath" - "gno.land/p/moul/txlink" - "gno.land/p/demo/ufmt" "std" "strconv" "time" + + "gno.land/p/demo/ufmt" + "gno.land/p/moul/md" + "gno.land/p/moul/realmpath" + "gno.land/p/moul/txlink" ) const DateFormat = "January 2 2006, 03:04:04 PM" @@ -23,8 +24,8 @@ func Render(path string) string { func renderHome() string { msg := md.H1("OO Home") msg += md.Paragraph("Welcome to the first Optimistic Oracle on GnoLand! This project is developed by " + md.Link("@intermarch3", "/r/intermarch3/home")) - msg += md.Paragraph("For more information, visit the " + md.Link("Readme", std.CurrentRealm().PkgPath()[8:] + "$source") + ".") - msg += md.Link("Request Data", ufmt.Sprintf("%s", txlink.NewLink("RequestData").AddArgs("id", "YOUR_ID", "ancillaryData", "YOUR_QUESTION", "yesNoQuestion", "true or false").SetSend(strconv.Itoa(int(RequesterReward))).URL())) + "\n\n(Note: Requesting data need to pay a reward of " + strconv.Itoa(int(RequesterReward / 1_000_000)) + " GNOT to the proposer).\n\n" + msg += md.Paragraph("For more information, visit the " + md.Link("Readme", std.CurrentRealm().PkgPath()[8:]+"$source") + ".") + msg += md.Link("Request Data", ufmt.Sprintf("%s", txlink.NewLink("RequestData").AddArgs("id", "YOUR_ID", "ancillaryData", "YOUR_QUESTION", "yesNoQuestion", "true or false").SetSend(strconv.Itoa(int(RequesterReward))).URL())) + "\n\n(Note: Requesting data need to pay a reward of " + strconv.Itoa(int(RequesterReward/1_000_000)) + " GNOT to the proposer).\n\n" msg += md.H2("Current Requests :") // List current requests var table []string @@ -50,7 +51,7 @@ func renderHome() string { resolved = append(resolved, md.Paragraph(req.AncillaryData)) resolved = append(resolved, md.Paragraph(rendervalue(req, req.WinningValue))) resolved = append(resolved, md.Paragraph(req.Proposer.String())) - resolved = append(resolved, md.Link("Click here", std.CurrentRealm().PkgPath()[8:] + ":" + req.Id)) + resolved = append(resolved, md.Link("Click here", std.CurrentRealm().PkgPath()[8:]+":"+req.Id)) } } msg += md.ColumnsN(resolved, 4, false) @@ -67,24 +68,24 @@ func renderRequestPage(id string) string { } msg := md.H1("Question: " + req.AncillaryData) msg += md.H2("Details:\n\n") - msg += md.H3("Request ID: " + req.Id) + "\n\n" - msg += md.H3("Requested at: " + req.Timestamp.Format(DateFormat)) + "\n\n" + msg += md.H3("Request ID: "+req.Id) + "\n\n" + msg += md.H3("Requested at: "+req.Timestamp.Format(DateFormat)) + "\n\n" if req.State == "Requested" && req.Deadline.Unix() < time.Now().Unix() { msg += md.H3("Deadline Missed: requests not fulfilled") + "\n\n" return msg - } - msg += md.H3("State: " + req.State) + "\n\n" + } + msg += md.H3("State: "+req.State) + "\n\n" if req.State == "Requested" { msg += md.Paragraph("This request has not been proposed yet.") + "\n\n" - msg += md.H3("The reward for the proposer is " + strconv.Itoa(int(RequesterReward / 1_000_000)) + " GNOT.") + "\n\n" + msg += md.H3("The reward for the proposer is "+strconv.Itoa(int(RequesterReward/1_000_000))+" GNOT.") + "\n\n" msg += renderAction(req) return msg } if req.State == "Resolved" { - msg += md.H3("Winning Value: " + rendervalue(req, req.WinningValue)) + "\n\n" + msg += md.H3("Winning Value: "+rendervalue(req, req.WinningValue)) + "\n\n" dispute, exist := Disputes[req.Id] if !exist { - msg += md.Paragraph("Value Proposed by: " + req.Proposer.String()) + "\n\n" + msg += md.Paragraph("Value Proposed by: "+req.Proposer.String()) + "\n\n" return msg } msg += md.Paragraph("Number of Votes: " + strconv.Itoa(len(dispute.Votes)) + "\n\n") @@ -97,9 +98,9 @@ func renderRequestPage(id string) string { return msg } if req.State == "Proposed" { - msg += md.Paragraph("Proposed by: " + req.Proposer.String()) + "\n\n" - msg += md.H3("Proposed Value: " + rendervalue(req, req.ProposedValue)) + "\n\n" - msg += md.H3("End Resolution Time: " + req.ResolutionTime.Format(DateFormat)) + "\n\n" + msg += md.Paragraph("Proposed by: "+req.Proposer.String()) + "\n\n" + msg += md.H3("Proposed Value: "+rendervalue(req, req.ProposedValue)) + "\n\n" + msg += md.H3("End Resolution Time: "+req.ResolutionTime.Format(DateFormat)) + "\n\n" } if req.State == "Disputed" { dispute, exists := Disputes[req.Id] @@ -156,17 +157,17 @@ func renderRequest(req DataRequest) []string { table = append(table, md.Paragraph(rendervalue(req, req.ProposedValue))) } table = append(table, md.Paragraph(req.State)) - table = append(table, md.Link("Click here", std.CurrentRealm().PkgPath()[8:] + ":" + req.Id)) + table = append(table, md.Link("Click here", std.CurrentRealm().PkgPath()[8:]+":"+req.Id)) return table } func renderAction(req DataRequest) string { action := md.H2("Actions:\n\n") - if (req.State == "Requested") { - action += md.Link("Propose a value", ufmt.Sprintf("%s", txlink.NewLink("ProposeData").AddArgs("id", req.Id, "proposedValue", "YOUR_VALUE_HERE").SetSend(strconv.Itoa(int(Bond))).URL())) + "\n\n(Note: Proposing need to bond " + strconv.Itoa(int(Bond / 1_000_000)) + " GNOT)" - } else if (req.State == "Proposed") { - action += md.Link("Dispute value", ufmt.Sprintf("%s", txlink.NewLink("DisputeData").AddArgs("id", req.Id).SetSend(strconv.Itoa(int(Bond))).URL())) + "\n\n(Note: Disputing need to bond " + strconv.Itoa(int(Bond / 1_000_000)) + " GNOT)" - } else if (req.State == "Disputed" && !Disputes[req.Id].IsResolved) { + if req.State == "Requested" { + action += md.Link("Propose a value", ufmt.Sprintf("%s", txlink.NewLink("ProposeData").AddArgs("id", req.Id, "proposedValue", "YOUR_VALUE_HERE").SetSend(strconv.Itoa(int(Bond))).URL())) + "\n\n(Note: Proposing need to bond " + strconv.Itoa(int(Bond/1_000_000)) + " GNOT)" + } else if req.State == "Proposed" { + action += md.Link("Dispute value", ufmt.Sprintf("%s", txlink.NewLink("DisputeData").AddArgs("id", req.Id).SetSend(strconv.Itoa(int(Bond))).URL())) + "\n\n(Note: Disputing need to bond " + strconv.Itoa(int(Bond/1_000_000)) + " GNOT)" + } else if req.State == "Disputed" && !Disputes[req.Id].IsResolved { if Disputes[req.Id].EndTime.Before(time.Now()) { action += md.Link("Reveal vote", ufmt.Sprintf("%s", txlink.NewLink("RevealVote").AddArgs("id", req.Id, "value", "YOUR_VALUE_HERE", "salt", "YOUR_SALT_HERE").URL())) + "\n\n(Note: You must reveal your vote before the reveal period ends at " + Disputes[req.Id].EndRevealTime.UTC().Format(time.UnixDate) + ")" } else { diff --git a/examples/gno.land/r/intermarch3/oo/resolver.gno b/examples/gno.land/r/intermarch3/oo/resolver.gno index 95f1e332c3e..89fc5554cc7 100644 --- a/examples/gno.land/r/intermarch3/oo/resolver.gno +++ b/examples/gno.land/r/intermarch3/oo/resolver.gno @@ -18,7 +18,7 @@ func resolve(id string) int64 { } } -func resolveYesNo(dispute Dispute) int64{ +func resolveYesNo(dispute Dispute) int64 { yesVotes := int64(0) noVotes := int64(0) for _, vote := range dispute.Votes { @@ -53,11 +53,11 @@ func resolveNumeric(dispute Dispute) int64 { valueWeights[vote.Value] += vote.TokenAmount } } - + // Find the value with the most token weight var winningValue int64 var maxWeight int64 - + for value, weight := range valueWeights { if weight > maxWeight { maxWeight = weight @@ -75,7 +75,7 @@ func rewardAndSlachVoters(dispute Dispute, winningValue, totalWeight int64) { // Reward winning voters VoteToken.mint(vote.Voter, 2) reward := Bond * (vote.TokenAmount / totalWeight) - if (reward != 0) { + if reward != 0 { Bank.SendCoins(std.CurrentRealm().Address(), vote.Voter, std.Coins{std.Coin{Denom: "ugnot", Amount: reward}}) } std.Emit("VoterRewarded", "voter", vote.Voter.String()) diff --git a/examples/gno.land/r/intermarch3/oo/token.gno b/examples/gno.land/r/intermarch3/oo/token.gno index 5188bf1ad88..3edd2b2107a 100644 --- a/examples/gno.land/r/intermarch3/oo/token.gno +++ b/examples/gno.land/r/intermarch3/oo/token.gno @@ -2,6 +2,7 @@ package oo import ( "std" + "gno.land/p/demo/grc/grc20" ) @@ -10,7 +11,6 @@ type OOT struct { *grc20.PrivateLedger } - func newOOToken(name, symbol string, decimals int) *OOT { token := &OOT{} t, adm := grc20.NewToken(name, symbol, decimals) From d3b9d2dfff3dacb09a5d7b9ec7d2b92e785de18b Mon Sep 17 00:00:00 2001 From: Lucas Leclerc Date: Tue, 26 Aug 2025 16:34:30 +0200 Subject: [PATCH 5/5] fix(examples): txLink in oracle render page --- examples/gno.land/r/intermarch3/oo/render.gno | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/gno.land/r/intermarch3/oo/render.gno b/examples/gno.land/r/intermarch3/oo/render.gno index 92eb820c665..58950e28b03 100644 --- a/examples/gno.land/r/intermarch3/oo/render.gno +++ b/examples/gno.land/r/intermarch3/oo/render.gno @@ -25,7 +25,7 @@ func renderHome() string { msg := md.H1("OO Home") msg += md.Paragraph("Welcome to the first Optimistic Oracle on GnoLand! This project is developed by " + md.Link("@intermarch3", "/r/intermarch3/home")) msg += md.Paragraph("For more information, visit the " + md.Link("Readme", std.CurrentRealm().PkgPath()[8:]+"$source") + ".") - msg += md.Link("Request Data", ufmt.Sprintf("%s", txlink.NewLink("RequestData").AddArgs("id", "YOUR_ID", "ancillaryData", "YOUR_QUESTION", "yesNoQuestion", "true or false").SetSend(strconv.Itoa(int(RequesterReward))).URL())) + "\n\n(Note: Requesting data need to pay a reward of " + strconv.Itoa(int(RequesterReward/1_000_000)) + " GNOT to the proposer).\n\n" + msg += md.Link("Request Data", ufmt.Sprintf("%s", txlink.NewLink("RequestData").AddArgs("ancillaryData", "YOUR_QUESTION", "yesNoQuestion", "true or false", "deadline", "DEADLINE_TIMESTAMP").SetSend(strconv.Itoa(int(RequesterReward))).URL())) + "\n\n(Note: Requesting data need to pay a reward of " + strconv.Itoa(int(RequesterReward/1_000_000)) + " GNOT to the proposer).\n\n" msg += md.H2("Current Requests :") // List current requests var table []string