diff --git a/.gitignore b/.gitignore index 723ef36..1f1025f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.idea \ No newline at end of file +.idea +.DS_Store \ No newline at end of file diff --git a/packages/r/intermarch3/goo/README.md b/packages/r/intermarch3/goo/README.md new file mode 100644 index 0000000..eef7a66 --- /dev/null +++ b/packages/r/intermarch3/goo/README.md @@ -0,0 +1,151 @@ +# Gno Optimistic Oracle (GOO) + +An Optimistic Oracle (GOO) 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) +- [Tokenomics](#tokenomics) +- [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, OOT (Optimistic 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. +See [Tokenomics](#tokenomics) to understand how to get an OOT. + +## 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 OOT 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. +- **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 (voters who vote incorrectly lose 25% of their Oracle Token balance). + +## Architecture + +The oracle contract is composed of three main files: + +- `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**: An Oracle Soulbound Token holder who participates in a dispute by voting on the correct outcome. + +## Tokenomics + +The 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). +OOT 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`. +**0. Buy OOT for Voter Role (one time action)** +```bash +# Buy Oracle Soulbound Token (replace with your key name) +gnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -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/goo" -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/goo" -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/goo" -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/goo" -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" + "test")` -> `a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21` +```bash +# Commit the vote (replace ID with the actual ID) +ggnokey maketx call -pkgpath "gno.land/r/intermarch3/goo" -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/goo" -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/goo" -func "ResolveDispute" -args "ID" -gas-fee 1000000ugnot -gas-wanted 10000000 -send "" -broadcast -chainid "dev" -remote "tcp://127.0.0.1:26657" +``` + +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 + +| [
Lucas Leclerc](https://github.com/intermarch3) | +| :---: | \ No newline at end of file diff --git a/packages/r/intermarch3/goo/court.gno b/packages/r/intermarch3/goo/court.gno new file mode 100644 index 0000000..b9a05db --- /dev/null +++ b/packages/r/intermarch3/goo/court.gno @@ -0,0 +1,249 @@ +package goo + +import ( + "chain" + "chain/banker" + "chain/runtime" + "crypto/sha256" + "encoding/hex" + "strconv" + "time" + + "gno.land/p/nt/avl" + "gno.land/p/nt/ufmt" +) + +var ( + disputes = avl.NewTree() + disputeDuration = 2 * int64(time.Minute.Seconds()) + revealDuration = 2 * int64(time.Minute.Seconds()) + voteTokenPrice = 1 * int64(1_000_000) // in GNOT + VoteToken = newOOToken("Gno Optimistic Oracle Token", "goot", 6) +) + +func initiateDispute(id string) { + if _, exists := disputes.Get(id); exists { + panic("error: Dispute for this request already exists.") + } + dispute := &Dispute{ + RequestId: id, + Votes: []Vote{}, + Voters: avl.NewTree(), + IsResolved: false, + EndTime: time.Now().Add(time.Duration(disputeDuration) * time.Second), + EndRevealTime: time.Now().Add(time.Duration(disputeDuration+revealDuration) * time.Second), + } + disputes.Set(id, dispute) + chain.Emit("DisputeInitiated", "id", id) +} + +// -- PUBLIC FUNCTIONS -- + +// BuyInitialVoteToken allows a user to buy their first vote token by sending voteTokenPrice amount of ugnot. +func BuyInitialVoteToken(_ realm) { + caller := runtime.OriginCaller() + coins := banker.OriginSend() + if len(coins) != 1 || coins.AmountOf("ugnot") != voteTokenPrice { + panic(ufmt.Sprintf("error: Must send exactly %d gnot to get a vote token.", voteTokenPrice/1_000_000)) + } + + balance := VoteToken.BalanceOf(caller) + if balance > 0 { + panic("error: You already have a vote token.") + } + + VoteToken.mint(caller, 1) + chain.Emit("VoteTokenPurchased", "voter", caller.String()) +} + +// VoteOnDispute allows a user to commit a vote during a dispute. +func VoteOnDispute(_ realm, id string, hash string) { + dispute := getDispute(id) + request := getRequest(id) + + if request.Proposer == runtime.PreviousRealm().Address() || request.Disputer == runtime.PreviousRealm().Address() { + 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.") + } + + amount := VoteToken.BalanceOf(runtime.PreviousRealm().Address()) + if amount < 1 { + panic("error: You need at least 1 vote token to vote.") + } + + vote := Vote{ + RequestId: id, + Voter: runtime.PreviousRealm().Address(), + TokenAmount: amount, + Hash: hash, + Revealed: false, + } + + voter, exist := dispute.Voters.Get(string(vote.Voter)) + if exist && voter.(Voter).HasVoted { + panic("error: Voter has already voted in this dispute.") + } + + dispute.Votes = append(dispute.Votes, vote) + dispute.Voters.Set(string(vote.Voter), Voter{HasVoted: true, VoteIndex: int64(len(dispute.Votes) - 1)}) + chain.Emit("VoteSubmitted", "id", id, "voter", vote.Voter.String()) +} + +// RevealVote allows a user to reveal their vote after the voting period has ended. +func RevealVote(_ realm, id string, value int64, salt string) { + dispute := getDispute(id) + + 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, exist := dispute.Voters.Get(string(runtime.PreviousRealm().Address())) + if !exist || !voter.(Voter).HasVoted { + panic("error: Voter did not participate in this dispute.") + } + + vote := dispute.Votes[voter.(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.(Voter).VoteIndex] = vote + chain.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. +func ResolveDispute(_ realm, id string) { + dispute := getDispute(id) + 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 + // Update the original request with the winning value + request := getRequest(id) + + request.ProposedValue = val + request.State = "Resolved" + chain.Emit("DisputeResolved", "id", id, "winningValue", strconv.Itoa(int(val))) + chain.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(val))) + + var winner 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(runtime.CurrentRealm().Address(), winner, chain.Coins{chain.Coin{Denom: "ugnot", Amount: bond + requesterReward}}) +} + +// -- admin functions -- + +// SetDisputeDuration sets the duration (in seconds) for the voting period. +func SetDisputeDuration(_ realm, duration int64) { + if runtime.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. +func SetRevealDuration(_ realm, duration int64) { + if runtime.OriginCaller() == admin { + revealDuration = duration * int64(time.Second) + } else { + panic("error: Only admin can set reveal duration.") + } +} + +// SetVoteTokenPrice sets the price (in ugnot) to cast a vote. +func SetVoteTokenPrice(_ realm, price int64) { + if runtime.OriginCaller() == admin { + voteTokenPrice = price + } else { + panic("error: Only admin can set vote price.") + } +} + +// -- view functions -- + +// GetDispute returns the details of a specific dispute. +func GetDispute(id string) *Dispute { + return getDispute(id) +} + +// GetDisputeDuration returns the current dispute duration. +func GetDisputeDuration() int64 { + return disputeDuration +} + +// GetVoteTokenPrice returns the current vote price. +func GetVoteTokenPrice() int64 { + return voteTokenPrice +} + +// GetDisputeEndTime returns the end time of the voting period for a specific dispute. +func GetDisputeEndTime(id string) time.Time { + dispute := getDispute(id) + return dispute.EndTime +} + +// GetDisputeVotesAmount returns the total number of votes cast in a dispute. +func GetDisputeVotesAmount(id string) int64 { + dispute := getDispute(id) + return int64(len(dispute.Votes)) +} + +// GetRevealEndTime returns the end time of the reveal period for a specific dispute. +func GetRevealEndTime(id string) time.Time { + dispute := getDispute(id) + return dispute.EndRevealTime +} + +// GetRevealDuration returns the current reveal duration. +func GetRevealDuration() int64 { + return revealDuration +} + +// Utils functions + +func getDispute(id string) *Dispute { + dispute, exists := disputes.Get(id) + if !exists { + panic("error: Dispute with this ID does not exist.") + } + return dispute.(*Dispute) +} diff --git a/packages/r/intermarch3/goo/court_test.gno b/packages/r/intermarch3/goo/court_test.gno new file mode 100644 index 0000000..a49f72b --- /dev/null +++ b/packages/r/intermarch3/goo/court_test.gno @@ -0,0 +1,148 @@ +package goo + +import ( + "chain" + "strconv" + "testing" + "time" + + "gno.land/p/nt/testutils" + "gno.land/p/nt/urequire" +) + +var user4 = testutils.TestAddress("user4") + +func TestBuyInitialVoteToken(t *testing.T) { + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Must send exactly "+strconv.Itoa(int(voteTokenPrice/1_000_000))+" gnot to get a vote token.", func() { + BuyInitialVoteToken(cross) + }, "user should not be able to buy a vote token without sending the correct amount") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: voteTokenPrice}}) + urequire.NotPanics(t, func() { + BuyInitialVoteToken(cross) + }, "user should be able to buy a vote token by sending the correct amount") + amount := VoteToken.BalanceOf(user1) + urequire.Equal(t, int64(1), amount, "user should have received a vote token") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: voteTokenPrice}}) + urequire.AbortsWithMessage(t, "error: You already have a vote token.", func() { + BuyInitialVoteToken(cross) + }, "user should not be able to buy a second vote token") +} + +func TestVoteOnDispute(t *testing.T) { + // setup: create request and dispute + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + DisputeData(cross, id) + + // buy vote token + testing.SetRealm(testing.NewUserRealm(user3)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: voteTokenPrice}}) + BuyInitialVoteToken(cross) + + // vote on dispute + testing.SetRealm(testing.NewUserRealm(user3)) + urequire.NotPanics(t, func() { + VoteOnDispute(cross, id, "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21") + }, "user should be able to vote on dispute") + + testing.SetRealm(testing.NewUserRealm(user4)) + urequire.AbortsWithMessage(t, "error: You need at least 1 vote token to vote.", func() { + VoteOnDispute(cross, id, "hash") + }, "user should not be able to vote on dispute without a vote token") + + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Proposer and Disputer cannot vote in this dispute.", func() { + VoteOnDispute(cross, id, "hash") + }, "user should not be able to vote on dispute if they are the proposer or disputer") + + testing.SetRealm(testing.NewUserRealm(user3)) + urequire.AbortsWithMessage(t, "error: Voter has already voted in this dispute.", func() { + VoteOnDispute(cross, id, "hash") + }, "user should not be able to vote on dispute if they have already voted") + + testing.SetRealm(testing.NewUserRealm(user4)) + setTime(time.Now().Add(time.Duration(disputeDuration)*time.Second + time.Second)) + urequire.AbortsWithMessage(t, "error: Vote period has ended.", func() { + VoteOnDispute(cross, id, "hash") + }, "user should not be able to vote on dispute after the voting period has ended") +} + +func TestRevealVote(t *testing.T) { + // setup: create request and dispute + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + DisputeData(cross, id) + + // vote on dispute + testing.SetRealm(testing.NewUserRealm(user3)) + VoteOnDispute(cross, id, "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21") + + testing.SetRealm(testing.NewUserRealm(user3)) + setTime(time.Now().Add(time.Duration(disputeDuration)*time.Second + time.Second)) + urequire.AbortsWithMessage(t, "error: Hash does not match the revealed value and salt.", func() { + RevealVote(cross, id, 1, "mysalt") + }, "vote reveal with incorrect value and salt should fail") + + // reveal vote + testing.SetRealm(testing.NewUserRealm(user3)) + urequire.NotPanics(t, func() { + RevealVote(cross, id, 0, "test") + }, "user should be able to reveal their vote") + + testing.SetRealm(testing.NewUserRealm(user3)) + urequire.AbortsWithMessage(t, "error: Vote already revealed.", func() { + RevealVote(cross, id, 1, "mysalt") + }, "user should not be able to reveal their vote again") +} + +func TestResolveDispute(t *testing.T) { + // setup: create request and dispute + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + DisputeData(cross, id) + + // vote on dispute + testing.SetRealm(testing.NewUserRealm(user3)) + VoteOnDispute(cross, id, "a96e0beb59a16b085a7d2b3b5ffd6e5971870aa2903c6df86f26fa908ded2e21") + setTime(time.Now().Add(time.Duration(disputeDuration)*time.Second + time.Second)) + RevealVote(cross, id, 0, "test") + + setTime(time.Now().Add(time.Duration(revealDuration)*time.Second + time.Second)) + CreateGnotCoins(cross, (bond*2)+requesterReward) + urequire.NotPanics(t, func() { + ResolveDispute(cross, id) + }, "user should be able to resolve dispute after the reveal period has ended") + + urequire.AbortsWithMessage(t, "error: Dispute is already resolved.", func() { + ResolveDispute(cross, id) + }, "user should not be able to resolve dispute before the reveal period has ended") +} diff --git a/packages/r/intermarch3/goo/gnomod.toml b/packages/r/intermarch3/goo/gnomod.toml new file mode 100644 index 0000000..8c11fe4 --- /dev/null +++ b/packages/r/intermarch3/goo/gnomod.toml @@ -0,0 +1,2 @@ +module = "gno.land/r/intermarch3/goo" +gno = "0.9" diff --git a/packages/r/intermarch3/goo/oracle.gno b/packages/r/intermarch3/goo/oracle.gno new file mode 100644 index 0000000..00dfdaa --- /dev/null +++ b/packages/r/intermarch3/goo/oracle.gno @@ -0,0 +1,270 @@ +package goo + +import ( + "chain" + "chain/banker" + "chain/runtime" + "strconv" + "time" + + "gno.land/p/nt/avl" + "gno.land/p/nt/seqid" + "gno.land/p/nt/ufmt" +) + +var ( + admin address + resolutionTime = 2 * int64(time.Minute.Seconds()) + requesterReward = 1 * int64(1_000_000) // in GNOT + bond = 2 * int64(1_000_000) // in GNOT + requests = avl.NewTree() + bank = banker.NewBanker(banker.BankerTypeRealmSend) + idGenerator seqid.ID +) + +func init() { + admin = runtime.CurrentRealm().Address() +} + +// -- PUBLIC FUNCTIONS -- + +// RequestData allows a user to request data from the oracle. +// `requesterReward` value needs to be sent to the contract as a reward +// `deadline`is the timestamp in unix format for the deadline of the request. +// 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). +func RequestData(_ realm, ancillaryData string, yesNoQuestion bool, deadline int64) string { + 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.") + } + + coins := banker.OriginSend() + if len(coins) != 1 || coins.AmountOf("ugnot") != requesterReward { + panic(ufmt.Sprintf("error: Incorrect reward amount sent. Required: %d ugnot.", requesterReward)) + } + + id := idGenerator.Next().String() + + request := &DataRequest{ + Id: id, + Timestamp: time.Now(), + AncillaryData: ancillaryData, + YesNoQuestion: yesNoQuestion, + State: "Requested", + Deadline: time.Unix(deadline, 0), + Creator: runtime.PreviousRealm().Address(), + } + requests.Set(id, request) + chain.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. +func ProposeValue(_ realm, id string, proposedValue int64) { + request := getRequest(id) + 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.") + } + + if time.Now().After(request.Deadline) { + panic("error: Deadline for proposal has passed.") + } + + coins := banker.OriginSend() + if len(coins) != 1 || coins.AmountOf("ugnot") != bond { + panic("error: Incorrect bond amount sent. Required: " + strconv.FormatInt(bond, 10) + " ugnot") + } + + request.ProposedValue = proposedValue + request.Proposer = runtime.PreviousRealm().Address() + request.ProposerBond = bond + request.ResolutionTime = time.Now().Add(time.Duration(resolutionTime) * time.Second) + request.State = "Proposed" + chain.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. +func DisputeData(_ realm, id string) string { + request := getRequest(id) + if request.Proposer == runtime.PreviousRealm().Address() { + 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.") + } + + coins := banker.OriginSend() + if len(coins) != 1 || coins.AmountOf("ugnot") != bond { + panic("error: Incorrect bond amount sent. Required: " + strconv.FormatInt(bond, 10) + " ugnot") + } + + request.Disputer = runtime.PreviousRealm().Address() + request.DisputerBond = bond + request.State = "Disputed" + initiateDispute(id) + chain.Emit("DataDisputed", "id", id, "disputer", request.Disputer.String()) + return "Time: " + time.Now().String() +} + +// ResolveRequest finalizes an undisputed request after the resolution period has passed. +func ResolveRequest(_ realm, id string) { + request := getRequest(id) + 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 + + from := runtime.CurrentRealm().Address() + to := request.Proposer + totalPayout := request.ProposerBond + requesterReward + payout := chain.Coins{chain.Coin{Denom: "ugnot", Amount: totalPayout}} + bank.SendCoins(from, to, payout) + + chain.Emit("RequestResolved", "id", id, "winningValue", strconv.Itoa(int(request.WinningValue))) +} + +// RequestResult returns the winning value of a resolved request. +func RequestResult(_ realm, id string) int64 { + request := getRequest(id) + if request.State != "Resolved" { + panic("error: Request is not resolved.") + } + return request.WinningValue +} + +// RequesterRetreiveFund allows the original requester to get their reward back if the deadline passed without a proposal. +func RequesterRetreiveFund(_ realm, id string) { + request := getRequest(id) + if request.State != "Requested" { + panic("error: cannot retreive fund as requests fulfilled.") + } + + if request.Creator != runtime.PreviousRealm().Address() { + panic("error: Only the creator of the request can retrieve the fund.") + } + + if request.Deadline.After(time.Now()) { + panic("error: Cannot retrieve fund before the deadline.") + } + + from := runtime.CurrentRealm().Address() + to := request.Creator + refund := chain.Coins{chain.Coin{Denom: "ugnot", Amount: requesterReward}} + bank.SendCoins(from, to, refund) + + request.State = "Expired" + chain.Emit("RequestExpired", "id", id) +} + +// -- ADMIN FUNCTIONS -- + +// SetResolutionDuration sets the duration (in seconds) for the resolution period. +func SetResolutionDuration(_ realm, duration int64) { + if runtime.OriginCaller() == admin { + resolutionTime = duration + chain.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. +func SetrequesterReward(_ realm, reward int64) { + if runtime.OriginCaller() == admin { + requesterReward = reward + chain.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. +func SetBond(_ realm, newBond int64) { + if runtime.OriginCaller() == admin { + bond = newBond + chain.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. +func ChangeAdmin(_ realm, newAdmin address) { + if runtime.OriginCaller() == admin { + admin = newAdmin + chain.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. +func GetRequest(id string) *DataRequest { + return getRequest(id) +} + +// GetBond returns the current bond amount. +func GetBond() int64 { + return bond +} + +// GetResolutionTime returns the current resolution time duration. +func GetResolutionTime() int64 { + return resolutionTime +} + +// GetRequesterReward returns the current requester reward amount. +func GetRequesterReward() int64 { + return requesterReward +} + +// GetRequeststate returns the current state of a specific data request. +func GetRequeststate(id string) string { + request := getRequest(id) + return request.State +} + +// GetAdmin returns the current admin address. +func GetAdmin() address { + return admin +} + +// Utils functions + +func getRequest(id string) *DataRequest { + request, exists := requests.Get(id) + if !exists { + panic("error: Request with this ID does not exist.") + } + return request.(*DataRequest) +} diff --git a/packages/r/intermarch3/goo/oracle_test.gno b/packages/r/intermarch3/goo/oracle_test.gno new file mode 100644 index 0000000..d79cf6b --- /dev/null +++ b/packages/r/intermarch3/goo/oracle_test.gno @@ -0,0 +1,203 @@ +package goo + +import ( + "chain" + "chain/runtime" + "strconv" + "testing" + "time" + + "gno.land/p/nt/testutils" + "gno.land/p/nt/urequire" +) + +var ( + user1 = testutils.TestAddress("user1") + user2 = testutils.TestAddress("user2") + user3 = testutils.TestAddress("user3") +) + +func TestRequestData(t *testing.T) { + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Incorrect reward amount sent. Required: "+strconv.FormatInt(requesterReward, 10)+" ugnot.", func() { + RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + }, "user should not be able to request data without sending the requester reward") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + urequire.AbortsWithMessage(t, "error: Ancillary data cannot be empty.", func() { + RequestData(cross, "", true, time.Now().Add(24*time.Hour).Unix()) + }, "user should not be able to request data with an empty ancillary data") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + urequire.AbortsWithMessage(t, "error: Deadline must be at least 24 hours in the future.", func() { + RequestData(cross, "test", true, time.Now().Unix()) + }, "user should not be able to request data with a deadline less than 24 hours in the future") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + urequire.NotPanics(t, func() { + RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + }, "user should be able to request data") +} + +func TestProposeValue(t *testing.T) { + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Request with this ID does not exist.", func() { + ProposeValue(cross, "test", 0) + }, "request id does not exist but didn't revert") + + // create a request + now := time.Now() + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Proposed value must be 0 or 1 for yes/no questions.", func() { + ProposeValue(cross, id, 9) + }, "user should not be able to propose a value other than 0 or 1 for a yes/no question") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: 0}}) + urequire.AbortsWithMessage(t, "error: Incorrect bond amount sent. Required: "+strconv.FormatInt(bond, 10)+" ugnot", func() { + ProposeValue(cross, id, 0) + }, "user should not be able to propose a value without sending the bond") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + setTime(time.Now().Add(25 * time.Hour)) + urequire.AbortsWithMessage(t, "error: Deadline for proposal has passed.", func() { + ProposeValue(cross, id, 0) + }, "user should not be able to propose a value after the deadline") + + testing.SetRealm(testing.NewUserRealm(user1)) + setTime(now.Add(time.Hour)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.NotPanics(t, func() { + ProposeValue(cross, id, 0) + }, "user should be able to propose a value") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Request is not in 'Requested' state.", func() { + ProposeValue(cross, id, 0) + }, "user should not be able to propose a value if the request is not in 'Requested' state") +} + +func TestDisputeData(t *testing.T) { + // setup: create request and propose a value + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + now := time.Now() + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Request with this ID does not exist.", func() { + DisputeData(cross, "test") + }, "request id does not exist but didn't revert") + + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Proposer cannot dispute their own proposal.", func() { + DisputeData(cross, id) + }, "proposer cannot dispute their own proposal") + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: 0}}) + urequire.AbortsWithMessage(t, "error: Incorrect bond amount sent. Required: "+strconv.FormatInt(bond, 10)+" ugnot", func() { + DisputeData(cross, id) + }, "user should not be able to dispute a value without sending the bond") + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + setTime(time.Now().Add(time.Duration(resolutionTime)*time.Second + time.Second)) + urequire.AbortsWithMessage(t, "error: Dispute period has ended.", func() { + DisputeData(cross, id) + }, "user should not be able to dispute a value after the dispute period has ended") + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + setTime(now.Add(time.Second)) + urequire.NotPanics(t, func() { + DisputeData(cross, id) + }, "user should be able to dispute a value") + + testing.SetRealm(testing.NewUserRealm(user2)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: bond}}) + urequire.AbortsWithMessage(t, "error: Request is not in 'Proposed' state.", func() { + DisputeData(cross, id) + }, "user should not be able to dispute a value if the request is not in 'Proposed' state") +} + +func TestResolveRequest(t *testing.T) { + // setup: create request + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + now := time.Now() + id := RequestData(cross, "test", true, time.Now().Add(24*time.Hour).Unix()) + + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Request has not been proposed yet.", func() { + ResolveRequest(cross, id) + }, "user should not be able to resolve a request if it has not been proposed yet") + + // propose a value + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend(chain.Coins{{Denom: "ugnot", Amount: bond}}) + ProposeValue(cross, id, 0) + + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Resolution period has not ended yet.", func() { + ResolveRequest(cross, id) + }, "user should not be able to resolve a request if the resolution period has not ended yet") + + setTime(now.Add(time.Duration(resolutionTime)*time.Second + time.Second)) + CreateGnotCoins(cross, bond+requesterReward) + urequire.NotPanics(t, func() { + ResolveRequest(cross, id) + }, "user should be able to resolve a request") + + testing.SetRealm(testing.NewUserRealm(user1)) + urequire.AbortsWithMessage(t, "error: Request is already resolved.", func() { + ResolveRequest(cross, id) + }, "user should not be able to resolve a request if it is already resolved") +} + +func TestRequesterRetreiveFund(t *testing.T) { + // setup: create request + testing.SetRealm(testing.NewUserRealm(user1)) + testing.SetOriginSend([]chain.Coin{{Denom: "ugnot", Amount: requesterReward}}) + duration := time.Now().Add(24 * time.Hour) + id := RequestData(cross, "test", true, duration.Unix()) + + testing.SetRealm(testing.NewUserRealm(user2)) + urequire.AbortsWithMessage(t, "error: Only the creator of the request can retrieve the fund.", func() { + RequesterRetreiveFund(cross, id) + }, "only requester should be able to retrieve funds") + + testing.SetRealm(testing.NewUserRealm(user1)) + setTime(duration.Add(time.Hour)) + CreateGnotCoins(cross, requesterReward) + urequire.NotPanics(t, func() { + RequesterRetreiveFund(cross, id) + }, "requester should be able to retrieve funds") +} + +// Helper functions + +func setTime(newTime time.Time) { + ctx := testing.GetContext() + ctx.Time = newTime + testing.SetContext(ctx) +} + +func CreateGnotCoins(_ realm, amount int64) { + testing.IssueCoins(runtime.CurrentRealm().Address(), chain.Coins{{Denom: "ugnot", Amount: amount}}) +} diff --git a/packages/r/intermarch3/goo/render.gno b/packages/r/intermarch3/goo/render.gno new file mode 100644 index 0000000..b04328c --- /dev/null +++ b/packages/r/intermarch3/goo/render.gno @@ -0,0 +1,221 @@ +package goo + +import ( + "chain/runtime" + "strconv" + "time" + + "gno.land/p/moul/md" + "gno.land/p/moul/realmpath" + "gno.land/p/moul/txlink" + "gno.land/p/nt/ufmt" +) + +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("GOO 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", runtime.CurrentRealm().PkgPath()[8:]+"$source") + ".") + 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))+"ugnot").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 and build table + var table []string + table = append(table, md.Bold("Question"), md.Bold("Proposed Value"), md.Bold("State"), md.Bold("See more")) + + requests.Iterate("", "", func(key string, value any) bool { + if value.(*DataRequest).State == "Resolved" { + return false + } + table = append(table, renderRequest(value.(*DataRequest))...) + return false + }) + msg += md.ColumnsN(table, 4, false) + + // If no current requests, show a message + if len(table) == 4 { + msg += md.Paragraph("No current requests.") + } + + msg += md.HorizontalRule() + + // List last 5 resolved requests and build table + 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")) + + requests.Iterate("", "", func(key string, value any) bool { + if value.(*DataRequest).State != "Resolved" { + return false + } + resolved = append(resolved, md.Paragraph(value.(*DataRequest).AncillaryData)) + resolved = append(resolved, md.Paragraph(rendervalue(value.(*DataRequest), value.(*DataRequest).WinningValue))) + resolved = append(resolved, md.Paragraph(value.(*DataRequest).Proposer.String())) + resolved = append(resolved, md.Link("Click here", runtime.CurrentRealm().PkgPath()[8:]+":"+value.(*DataRequest).Id)) + return false + }) + msg += md.ColumnsN(resolved, 4, false) + + // If no resolved requests, show a message + if len(resolved) == 4 { + msg += md.Paragraph("No resolved requests yet.") + } + return msg +} + +// renderRequestPage renders the page for a specific request +func renderRequestPage(id string) string { + req := getRequest(id) + + msg := md.H1("Question: " + req.AncillaryData) + msg += md.H2("Details:\n\n") + msg += md.H3("Request ID: "+req.Id) + "\n\n" + + // check if the request is past the deadline + 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 + } + + // show the state of the request + 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 the request is resolved, show the winning value and the number of votes + if req.State == "Resolved" { + msg += md.H3("Winning Value: "+rendervalue(req, req.WinningValue)) + "\n\n" + res, exists := disputes.Get(req.Id) + if !exists { + msg += md.Paragraph("Value Proposed by: "+req.Proposer.String()) + "\n\n" + return msg + } + + dispute := res.(*Dispute) + 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 the request is proposed, show the proposed value and the end resolution time + 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 the request is disputed, show the dispute details + if req.State == "Disputed" { + res, exist := disputes.Get(req.Id) + if exist { + dispute := res.(*Dispute) + 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("Revealed 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 +} + +// create table of the votes that have been revealed +func renderRevealedVotes(dispute Dispute) string { + var table []string + + table = append(table, md.Bold("Voter"), md.Bold("Vote"), md.Bold("Token Amount")) + request := getRequest(dispute.RequestId) + + for _, vote := range dispute.Votes { + if vote.Revealed { + table = append(table, md.Paragraph(vote.Voter.String()), md.Paragraph(rendervalue(request, 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", runtime.CurrentRealm().PkgPath()[8:]+":"+req.Id)) + return table +} + +// return appropriate action link based on the state of the request +func renderAction(req *DataRequest) string { + action := md.H2("Actions:\n\n") + dispute, _ := disputes.Get(req.Id) + + if req.State == "Requested" { + // tx to propose a value + action += md.Link("Propose a value", ufmt.Sprintf("%s", txlink.NewLink("ProposeValue").AddArgs("id", req.Id, "proposedValue", "YOUR_VALUE_HERE").SetSend(strconv.Itoa(int(bond))+"ugnot").URL())) + "\n\n(Note: Proposing need to bond " + strconv.Itoa(int(bond/1_000_000)) + " GNOT)" + } else if req.State == "Proposed" { + // tx to dispute a value + action += md.Link("Dispute value", ufmt.Sprintf("%s", txlink.NewLink("DisputeData").AddArgs("id", req.Id).SetSend(strconv.Itoa(int(bond))+"ugnot").URL())) + "\n\n(Note: Disputing need to bond " + strconv.Itoa(int(bond/1_000_000)) + " GNOT)" + } else if req.State == "Disputed" { + if req.ResolutionTime.Before(time.Now()) { + // tx to reveal a vote + 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 " + dispute.(*Dispute).EndRevealTime.UTC().Format(time.UnixDate) + ")" + } else { + // tx to vote on a dispute + action += md.Link("Vote", ufmt.Sprintf("%s", txlink.NewLink("VoteOnDispute").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" && dispute.(*Dispute).EndTime.Before(time.Now()) { + // tx to resolve a dispute + action += md.Link("Resolve dispute", ufmt.Sprintf("%s", txlink.NewLink("ResolveDispute").AddArgs("id", req.Id).URL())) + "\n\n" + } else if req.State == "Proposed" && req.ResolutionTime.Before(time.Now()) { + // tx to resolve a request + action += md.Link("Resolve request", ufmt.Sprintf("%s", txlink.NewLink("ResolveRequest").AddArgs("id", req.Id).URL())) + "\n\n" + } + return action +} diff --git a/packages/r/intermarch3/goo/resolver.gno b/packages/r/intermarch3/goo/resolver.gno new file mode 100644 index 0000000..0ed2b71 --- /dev/null +++ b/packages/r/intermarch3/goo/resolver.gno @@ -0,0 +1,108 @@ +// Package goo contains the resolver logic for the GOO. +// It supports both Yes/No and Numeric resolutions. +// It is used to determine the winning value of a dispute and reward the voters. +package goo + +import ( + "chain" + "chain/runtime" +) + +func resolve(id string) int64 { + dispute := getDispute(id) + request := getRequest(id) + + if dispute.NbResolvedVotes == 0 { + // If no one voted or reveal their vote, the proposed value wins by default + return request.ProposedValue + } + + if request.YesNoQuestion { + return resolveYesNo(dispute) + } + return resolveNumeric(dispute) +} + +// resolveYesNo determines the winning value for yes/no disputes based on the highest total token weight. +// If 0 votes or equal votes, the proposed value wins by default. +func resolveYesNo(dispute *Dispute) int64 { + var ( + winningValue int64 + weight int64 + yesVotes int64 + noVotes int64 + ) + + for _, vote := range dispute.Votes { + if vote.Revealed { + if vote.Value == 1 { + yesVotes += vote.TokenAmount + } else if vote.Value == 0 { + noVotes += vote.TokenAmount + } + } + } + + if yesVotes > noVotes { + winningValue = 1 + weight = yesVotes + } else { + winningValue = 0 + weight = noVotes + } + + rewardAndSlashVoters(dispute, winningValue, weight) + 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 { + // Find the value with the most token weight + var ( + winningValue int64 + maxWeight 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 + } + } + + for value, weight := range valueWeights { + if weight > maxWeight { + maxWeight = weight + winningValue = value + } + } + + rewardAndSlashVoters(dispute, winningValue, maxWeight) + return winningValue +} + +func rewardAndSlashVoters(dispute *Dispute, winningValue, totalWeight int64) { + for _, vote := range dispute.Votes { + if !vote.Revealed { + continue + } + + if vote.Value == winningValue { + // Reward winning voters + VoteToken.mint(vote.Voter, 2) + reward := bond * (vote.TokenAmount / totalWeight) + + if reward != 0 { + bank.SendCoins(runtime.CurrentRealm().Address(), vote.Voter, chain.Coins{chain.Coin{Denom: "ugnot", Amount: reward}}) + } + chain.Emit("VoterRewarded", "voter", vote.Voter.String()) + } else { + // Slash losing voters + slash := vote.TokenAmount / 4 // 25% slash + VoteToken.burn(vote.Voter, slash) + chain.Emit("VoterSlashed", "voter", vote.Voter.String()) + } + } +} diff --git a/packages/r/intermarch3/goo/token.gno b/packages/r/intermarch3/goo/token.gno new file mode 100644 index 0000000..51431fa --- /dev/null +++ b/packages/r/intermarch3/goo/token.gno @@ -0,0 +1,27 @@ +package goo + +import ( + "gno.land/p/demo/tokens/grc20" +) + +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 address, amount int64) { + t.PrivateLedger.Mint(to, amount) +} + +// Burn destroy tokens from an address. +func (t *OOT) burn(from address, amount int64) { + t.PrivateLedger.Burn(from, amount) +} + +func (t *OOT) BalanceOf(addr address) int64 { + return t.Token.BalanceOf(addr) +} diff --git a/packages/r/intermarch3/goo/types.gno b/packages/r/intermarch3/goo/types.gno new file mode 100644 index 0000000..7c34491 --- /dev/null +++ b/packages/r/intermarch3/goo/types.gno @@ -0,0 +1,58 @@ +package goo + +import ( + "time" + + "gno.land/p/demo/tokens/grc20" + "gno.land/p/nt/avl" +) + +// court.gno +type Vote struct { + RequestId string + Voter 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 *avl.Tree + IsResolved bool + WinningValue int64 + EndTime time.Time + EndRevealTime time.Time +} + +// oracle.gno +type DataRequest struct { + Id string + Creator address + Timestamp time.Time + AncillaryData string + YesNoQuestion bool + ProposedValue int64 + Proposer address + ProposerBond int64 + Disputer address + DisputerBond int64 + ResolutionTime time.Time + WinningValue int64 + State string // "Requested", "Proposed", "Disputed", "Resolved" + Deadline time.Time +} + +// token.gno +type OOT struct { + *grc20.Token + *grc20.PrivateLedger +}