diff --git a/packages/r/greg007/gnobounty/README.md b/packages/r/greg007/gnobounty/README.md new file mode 100644 index 0000000..83fb6ee --- /dev/null +++ b/packages/r/greg007/gnobounty/README.md @@ -0,0 +1,284 @@ +# GnoBounty + +A decentralized bounty system for managing rewards on GitHub issues (or any issue tracker) using the Gno blockchain, with DAO-based validation through validator voting. + +## Features + +- **Create Bounties**: Post a bounty on any issue by providing the issue URL and sending GNOT tokens +- **Apply for Bounties**: Submit your merged PR link to apply for a bounty +- **DAO Validation**: 3 validators are randomly selected to vote on each application +- **Automatic Claims**: Bounties are automatically claimed when 2/3 validators approve +- **Validator System**: Decentralized validation through registered validators +- **Cancel Bounties**: Creators can cancel unclaimed bounties and receive a refund +- **List Bounties**: Browse all active bounties with reward amounts +- **Transparent**: All bounties and applications are stored on-chain and publicly viewable + +## How to Use + +### Creating a Bounty + +To create a bounty, call the `CreateBounty` function with the issue URL and description, and send GNOT tokens: + +```bash +gnokey maketx call \ + -pkgpath "gno.land/r/greg007/gnobounty" \ + -func "CreateBounty" \ + -args "https://github.com/gnolang/gno/issues/1234" \ + -args "Fix the rendering bug in GRC20 token display" \ + -send "5000000ugnot" \ + -gas-fee "1000000ugnot" \ + -gas-wanted "2000000" \ + -broadcast \ + -chainid "dev" \ + -remote "localhost:26657" \ + mykey +``` + +**Parameters:** +- `issueURL`: The URL of the issue (GitHub, GitLab, etc.) +- `description`: A brief description of what needs to be done +- `send`: The bounty amount in ugnot (1 GNOT = 1,000,000 ugnot) + +**Minimum bounty**: 1 GNOT (1,000,000 ugnot) + +### Viewing Bounties + +Visit the realm in your browser to see all active bounties: + +``` +https://gno.land/r/greg007/gnobounty +``` + +Or view a specific bounty by ID: + +``` +https://gno.land/r/greg007/gnobounty/1 +``` + +### Applying for a Bounty + +When you complete work on a bounty, submit your merged PR link to apply: + +```bash +gnokey maketx call \ + -pkgpath "gno.land/r/greg007/gnobounty" \ + -func "ApplyForBounty" \ + -args "1" \ + -args "https://github.com/gnolang/gno/pull/5678" \ + -gas-fee "1000000ugnot" \ + -gas-wanted "2000000" \ + -broadcast \ + -chainid "dev" \ + -remote "localhost:26657" \ + mykey +``` + +**Parameters:** +- First arg: Bounty ID +- Second arg: URL of your merged pull request + +**What happens next:** +1. Your application is created +2. 3 random validators are selected from the validator pool +3. Validators review your PR and vote to approve or reject +4. If 2 out of 3 validators approve, the bounty is automatically transferred to you! + +### Validator Voting + +If you're a registered validator, you can vote on applications assigned to you: + +```bash +# Approve an application +gnokey maketx call \ + -pkgpath "gno.land/r/greg007/gnobounty" \ + -func "Vote" \ + -args "1" \ + -args "true" \ + -gas-fee "1000000ugnot" \ + -gas-wanted "2000000" \ + -broadcast \ + -chainid "dev" \ + -remote "localhost:26657" \ + mykey + +# Reject an application +gnokey maketx call \ + -pkgpath "gno.land/r/greg007/gnobounty" \ + -func "Vote" \ + -args "1" \ + -args "false" \ + -gas-fee "1000000ugnot" \ + -gas-wanted "2000000" \ + -broadcast \ + -chainid "dev" \ + -remote "localhost:26657" \ + mykey +``` + +**Parameters:** +- First arg: Application ID +- Second arg: "true" to approve, "false" to reject + +### Legacy: Manual Claiming (deprecated) + +The old manual claim system is still available but deprecated in favor of DAO validation: + +```bash +gnokey maketx call \ + -pkgpath "gno.land/r/greg007/gnobounty" \ + -func "ClaimBounty" \ + -args "1" \ + -args "g1abc123...xyz" \ + -gas-fee "1000000ugnot" \ + -gas-wanted "2000000" \ + -broadcast \ + -chainid "dev" \ + -remote "localhost:26657" \ + mykey +``` + +### Cancelling a Bounty + +If you need to cancel a bounty and get your funds back: + +```bash +gnokey maketx call \ + -pkgpath "gno.land/r/greg007/gnobounty" \ + -func "CancelBounty" \ + -args "1" \ + -gas-fee "1000000ugnot" \ + -gas-wanted "2000000" \ + -broadcast \ + -chainid "dev" \ + -remote "localhost:26657" \ + mykey +``` + +**Note:** You can only cancel bounties that haven't been claimed yet. + +## API Reference + +### Bounty Functions + +#### `CreateBounty(cur realm, issueURL, description string) uint64` +Creates a new bounty and returns the bounty ID. Must send GNOT with the transaction. + +#### `GetBounty(id uint64) *Bounty` +Returns bounty details by ID. + +#### `CancelBounty(cur realm, id uint64)` +Cancels a bounty and refunds the creator. Only callable by the bounty creator on unclaimed bounties. + +#### `ListBounties() string` +Returns a formatted list of all active bounties. + +#### `GetBountyDetails(id uint64) string` +Returns detailed information about a specific bounty with apply button. + +#### `GetBountyCount() uint64` +Returns the total number of bounties created. + +### Application & Voting Functions + +#### `ApplyForBounty(cur realm, bountyID uint64, prLink string) uint64` +Submit an application for a bounty with your merged PR link. Returns the application ID. +- Automatically selects 3 random validators +- Creates a pending application +- Returns application ID + +#### `Vote(cur realm, applicationID uint64, approve bool)` +Vote on an application (validator only). Must be one of the selected validators. +- `approve`: true to approve, false to reject +- Automatically claims bounty if 2/3 validators approve + +#### `GetApplication(id uint64) *Application` +Returns application details by ID. + +#### `GetApplicationsForBounty(bountyID uint64) []*Application` +Returns all applications for a specific bounty. + +#### `GetApplicationsForValidator(validatorAddr address) []*Application` +Returns all applications assigned to a validator for voting. + +#### `GetApplicationCount() uint64` +Returns the total number of applications created. + +### Validator Management Functions + +#### `AddValidator(cur realm, validatorAddr address)` +Adds a new validator to the system (admin function). + +#### `RemoveValidator(cur realm, validatorAddr address)` +Deactivates a validator (admin function). + +#### `IsValidator(addr address) bool` +Checks if an address is an active validator. + +#### `GetActiveValidatorCount() int` +Returns the number of active validators. + +#### `ListValidators() string` +Returns a formatted list of all validators and their status. + +### Rendering Functions + +#### `RenderMyVotes(validatorAddr address) string` +Shows pending applications that need votes from the specified validator, with approve/reject buttons. + +#### `Render(path string) string` +Main render function for the realm. + +### Bounty Structure + +```go +type Bounty struct { + ID uint64 // Unique bounty identifier + IssueURL string // Link to the issue + Description string // Description of the work + Amount int64 // Reward amount in ugnot + Creator address // Address of the bounty creator + CreatedAt time.Time // When the bounty was created + IsClaimed bool // Whether the bounty has been claimed + Claimer address // Address of the claimer (if claimed) + ClaimedAt time.Time // When the bounty was claimed +} +``` + +## Example Workflow + +1. **Alice creates a bounty** for fixing a bug, sending 5 GNOT: + ``` + CreateBounty("https://github.com/project/repo/issues/42", "Fix memory leak in parser") + ``` + +2. **Bob sees the bounty** and decides to work on it. He visits the issue URL and starts coding. + +3. **Bob completes the work** and submits a pull request fixing the issue. + +4. **Alice verifies the fix** and approves Bob's claim: + ``` + ClaimBounty(cross, 1, "g1bob123...xyz") + ``` + +5. **Bob receives 5 GNOT** automatically to his address! + +## Security Considerations + +- Only the bounty creator can approve claims +- Bounties are locked in the realm contract until claimed or cancelled +- Once claimed, bounties cannot be cancelled +- The realm must have sufficient funds to pay out bounties + +## Notes + +- This implementation uses the new Gno standard library split (post-#3874) +- Amounts are stored in ugnot (micro-GNOT): 1 GNOT = 1,000,000 ugnot +- The `address` type is used as a builtin instead of `std.Address` +- Uses new imports: + - `gno.land/r/sys/chain` for `Coin` and `Coins` types + - `gno.land/r/sys/chain/banker` for banker operations (`NewBanker`, `BankerType*`, `OriginSend`) + - `gno.land/r/sys/chain/runtime` for realm functions (`PreviousRealm`, `CurrentRealm`) + +## License + +MIT diff --git a/packages/r/greg007/gnobounty/application.gno b/packages/r/greg007/gnobounty/application.gno new file mode 100644 index 0000000..269862a --- /dev/null +++ b/packages/r/greg007/gnobounty/application.gno @@ -0,0 +1,192 @@ +package gnobounty + +import ( + "chain/runtime" + "time" + + "gno.land/p/nt/commondao" + "gno.land/p/nt/ufmt" +) + +// ApplyForBounty allows a user to apply for a bounty with their merged PR link +// This creates an application with a private DAO for 3 random validators to vote +func ApplyForBounty(_ realm, bountyID uint64, prLink string) uint64 { + caller := runtime.OriginCaller() + + // Validate inputs + if prLink == "" { + panic("PR link cannot be empty") + } + + // Check bounty exists and is not claimed + bounty := GetBounty(bountyID) + if bounty == nil { + panic("bounty not found") + } + + if bounty.IsClaimed { + panic("bounty already claimed") + } + + // Check if there are enough validators + activeValidatorCount := GetActiveValidatorCount() + if activeValidatorCount < validatorsPerDAO { + panic(ufmt.Sprintf("not enough validators: need %d, have %d", validatorsPerDAO, activeValidatorCount)) + } + + // Check if user already has a pending application for this bounty + applications.Iterate("", "", func(key string, value interface{}) bool { + app := value.(*Application) + if app.BountyID == bountyID && app.Applicant == caller && app.Status == StatusPending { + panic("you already have a pending application for this bounty") + } + return false + }) + + // Select 3 random validators for this application + selectedValidators := selectRandomValidators(applicationCount+1, validatorsPerDAO) + + // Create the DAO for this application + applicationCount++ + daoName := ufmt.Sprintf("Bounty #%d Application #%d Review DAO", bountyID, applicationCount) + daoDesc := ufmt.Sprintf("Private DAO to review application for bounty #%d", bountyID) + + // Create DAO with selected validators as members + // DisableVotingDeadlineCheck allows immediate execution after votes + dao := commondao.New( + commondao.WithID(applicationCount), + commondao.WithName(daoName), + commondao.WithDescription(daoDesc), + commondao.DisableVotingDeadlineCheck(), + ) + + // Add selected validators as members + for _, validatorAddr := range selectedValidators { + dao.Members().Add(validatorAddr) + } + + // Create a proposal in the DAO to approve this application BEFORE creating the Application struct + proposalDef := NewClaimBountyProposal(bountyID, applicationCount, caller, prLink) + proposal := dao.MustPropose(caller, proposalDef) + + // Create the application with the correct proposalID from the start + application := &Application{ + ID: applicationCount, + BountyID: bountyID, + Applicant: caller, + PRLink: prLink, + AppliedAt: time.Now(), + Status: StatusPending, + DAO: dao, + ProposalID: proposal.ID(), + } + + // Store application + applications.Set(ufmt.Sprintf("%d", applicationCount), application) + + return applicationCount +} + +// Vote allows a selected validator to vote on an application via the DAO +func Vote(_ realm, applicationID uint64, vote string) { + caller := runtime.OriginCaller() + + // Check if caller is a validator + if !IsValidator(caller) { + panic("only validators can vote") + } + + // Get application + app := GetApplication(applicationID) + if app == nil { + panic("application not found") + } + + if app.Status != StatusPending { + panic("application is no longer pending") + } + + // Check if caller is a member of this application's DAO + if !app.DAO.Members().Has(caller) { + panic("you are not a validator for this application") + } + + // Get the proposal + proposal := app.DAO.ActiveProposals().Get(app.ProposalID) + if proposal == nil { + panic("proposal not found") + } + + // Convert string vote to VoteChoice + var voteChoice commondao.VoteChoice + switch vote { + case "yes", "true", "approve": + voteChoice = commondao.ChoiceYes + case "no", "false", "reject": + voteChoice = commondao.ChoiceNo + case "abstain": + voteChoice = commondao.ChoiceAbstain + default: + panic("invalid vote: must be 'yes', 'no', or 'abstain'") + } + + // Vote on the proposal using CommonDAO's Vote method + // This method validates membership, deadline, and vote choice + err := app.DAO.Vote(caller, app.ProposalID, voteChoice, "") + if err != nil { + panic(err) + } + + // Record vote locally + app.Votes = append(app.Votes, VoteRecord{ + Voter: caller, + Choice: voteChoice, + }) + + // Try to execute the proposal + // Tally() will return true when all validators have voted + // Execute() will handle both approval and rejection + proposal = app.DAO.ActiveProposals().Get(app.ProposalID) + if proposal != nil { + totalMembers := app.DAO.Members().Size() + totalVotes := proposal.VotingRecord().Size() + + // If all validators have voted, execute to finalize + if totalVotes == totalMembers { + err = app.DAO.Execute(app.ProposalID) + // Ignore errors - proposal might already be executed or failed + // The status will be updated in Execute() regardless + } + } +} + +// GetApplicationsForBounty returns all applications for a specific bounty +func GetApplicationsForBounty(bountyID uint64) []*Application { + apps := make([]*Application, 0) + + applications.Iterate("", "", func(key string, value interface{}) bool { + app := value.(*Application) + if app.BountyID == bountyID { + apps = append(apps, app) + } + return false + }) + + return apps +} + +// GetApplicationsForValidator returns all applications assigned to a validator +func GetApplicationsForValidator(validatorAddr address) []*Application { + apps := make([]*Application, 0) + + applications.Iterate("", "", func(key string, value interface{}) bool { + app := value.(*Application) + // Check if validator is a member of the application's DAO + if app.DAO != nil && app.Status == StatusPending && app.DAO.Members().Has(validatorAddr) { + apps = append(apps, app) + } + return false + }) + + return apps +} diff --git a/packages/r/greg007/gnobounty/application_test.gno b/packages/r/greg007/gnobounty/application_test.gno new file mode 100644 index 0000000..6254820 --- /dev/null +++ b/packages/r/greg007/gnobounty/application_test.gno @@ -0,0 +1,91 @@ +package gnobounty + +import ( + "testing" + + "gno.land/p/nt/ownable" + "gno.land/p/nt/testutils" +) + +func TestAddValidator(t *testing.T) { + // Setup owner - use testing.SetOriginCaller to set who is calling + adminAddr := testutils.TestAddress("admin") + testing.SetOriginCaller(adminAddr) + + // Initialize ownership with current caller as owner + ownership = ownable.New() + + // Add a validator + validator1 := testutils.TestAddress("validator1") + AddValidator(cross, validator1) + + if !IsValidator(validator1) { + t.Error("Validator should be active") + } + + count := GetActiveValidatorCount() + if count < 1 { + t.Error("Expected at least 1 active validator") + } +} + +func TestValidatorList(t *testing.T) { + // Setup owner - reuse same admin from previous test + adminAddr := testutils.TestAddress("admin") + testing.SetOriginCaller(adminAddr) + + // Add more validators + validator2 := testutils.TestAddress("validator2") + + // Check if already exists before adding + if !IsValidator(validator2) { + AddValidator(cross, validator2) + } + + if !IsValidator(validator2) { + t.Error("Validator2 should be active") + } + + count := GetActiveValidatorCount() + if count < 2 { + t.Errorf("Expected at least 2 active validators, got %d", count) + } +} + +func TestListValidators(t *testing.T) { + list := ListValidators() + if list == "" { + t.Error("Expected non-empty validators list") + } +} + +func TestIsValidatorCheck(t *testing.T) { + // Test a non-validator address + nonValidator := testutils.TestAddress("nonvalidator") + if IsValidator(nonValidator) { + t.Error("Non-validator should not be recognized as validator") + } + + // Test existing validator + validator1 := testutils.TestAddress("validator1") + if !IsValidator(validator1) { + t.Error("validator1 should be recognized as validator") + } +} + +func TestGetValidator(t *testing.T) { + validator1 := testutils.TestAddress("validator1") + v := GetValidator(validator1) + + if v == nil { + t.Fatal("Expected to find validator1") + } + + if v.Address != validator1 { + t.Error("Validator address mismatch") + } + + if !v.Active { + t.Error("Validator should be active") + } +} diff --git a/packages/r/greg007/gnobounty/gnobounty_test.gno b/packages/r/greg007/gnobounty/gnobounty_test.gno new file mode 100644 index 0000000..195f9ec --- /dev/null +++ b/packages/r/greg007/gnobounty/gnobounty_test.gno @@ -0,0 +1,80 @@ +package gnobounty + +import ( + "chain" + "testing" + + "gno.land/p/nt/testutils" +) + +func TestCreateBounty(t *testing.T) { + // Setup test environment + testing.SetOriginCaller(testutils.TestAddress("alice")) + testing.SetOriginSend(chain.Coins{chain.Coin{Denom: "ugnot", Amount: 5000000}}) + + // Create a bounty + bountyID := CreateBounty( + cross, + "Fix Rendering Bug", + "https://github.com/gnolang/gno/issues/1234", + "Fix the rendering issue in the GRC20 token display", + ) + + if bountyID != 1 { + t.Errorf("Expected bounty ID 1, got %d", bountyID) + } + + // Check bounty was created + bounty := GetBounty(bountyID) + if bounty == nil { + t.Fatal("Bounty not found") + } + + if bounty.Amount != 5000000 { + t.Errorf("Expected amount 5000000, got %d", bounty.Amount) + } + + if bounty.IsClaimed { + t.Error("Bounty should not be claimed yet") + } + + if bounty.Title != "Fix Rendering Bug" { + t.Errorf("Expected title 'Fix Rendering Bug', got %s", bounty.Title) + } +} + +func TestGetBountyCount(t *testing.T) { + count := GetBountyCount() + if count < 1 { + t.Error("Expected at least 1 bounty from previous test") + } +} + +func TestListBounties(t *testing.T) { + list := ListBounties() + if list == "" { + t.Error("Expected non-empty bounties list") + } + if list == "No bounties available" { + t.Error("Expected bounties to be listed") + } +} + +func TestGetBountyDetails(t *testing.T) { + // Test getting details for existing bounty + details := GetBountyDetails(1) + if details == "" { + t.Error("Expected non-empty bounty details") + } + if details == "Bounty not found" { + t.Error("Bounty #1 should exist") + } +} + +func TestGetBountyDetailsNotFound(t *testing.T) { + // Test getting details for non-existent bounty + details := GetBountyDetails(99999) + if details != "Bounty not found" { + t.Error("Expected 'Bounty not found' for non-existent bounty") + } +} diff --git a/packages/r/greg007/gnobounty/gnomod.toml b/packages/r/greg007/gnobounty/gnomod.toml new file mode 100644 index 0000000..c385ec5 --- /dev/null +++ b/packages/r/greg007/gnobounty/gnomod.toml @@ -0,0 +1,2 @@ +module = "gno.land/r/greg007/gnobounty" +gno = "0.9" diff --git a/packages/r/greg007/gnobounty/leaderboard.gno b/packages/r/greg007/gnobounty/leaderboard.gno new file mode 100644 index 0000000..0aa4f01 --- /dev/null +++ b/packages/r/greg007/gnobounty/leaderboard.gno @@ -0,0 +1,151 @@ +package gnobounty + +import ( + "gno.land/p/moul/md" + "gno.land/p/nt/avl" + "gno.land/p/nt/ufmt" +) + +// LeaderboardEntry holds the stats for a user +type LeaderboardEntry struct { + Address address + BountiesCreated int + BountiesApplied int + ValidationsPerformed int + Score int +} + +// GetLeaderboard aggregates stats for all users +func GetLeaderboard() []LeaderboardEntry { + var stats avl.Tree // address -> *LeaderboardEntry + + // Count bounties created (10 points) + bounties.Iterate("", "", func(key string, value interface{}) bool { + if value == nil { + return false + } + bounty, ok := value.(*Bounty) + if !ok || bounty == nil { + return false + } + entry := getEntry(&stats, bounty.Creator) + entry.BountiesCreated++ + entry.Score += 10 + return false + }) + + // Count applications and validations + applications.Iterate("", "", func(key string, value interface{}) bool { + if value == nil { + return false + } + app, ok := value.(*Application) + if !ok || app == nil { + return false + } + + // Only count approved applications (20 points) + if app.Status == StatusApproved { + entry := getEntry(&stats, app.Applicant) + entry.BountiesApplied++ + entry.Score += 20 + } + + // Count validations from local record (5 points) + for _, vote := range app.Votes { + entry := getEntry(&stats, vote.Voter) + entry.ValidationsPerformed++ + entry.Score += 5 + } + + return false + }) + + // Convert tree to slice + var result []LeaderboardEntry + stats.Iterate("", "", func(key string, value interface{}) bool { + entry := value.(*LeaderboardEntry) + result = append(result, *entry) + return false + }) + + // Sort by Score descending + // Manual bubble sort since sort.Slice is not available + for i := 0; i < len(result)-1; i++ { + for j := 0; j < len(result)-i-1; j++ { + if result[j].Score < result[j+1].Score { + // Swap + result[j], result[j+1] = result[j+1], result[j] + } + } + } + + return result +} + +// RenderLeaderboard renders the leaderboard page +func RenderLeaderboard() string { + entries := GetLeaderboard() + + output := md.H1("🏆 Community Leaderboard") + output += "Top contributors to the GnoBounty ecosystem.\n\n" + output += md.Bold("Scoring:") + " Bounty Creation (10pts) | Approved Application (20pts) | Validation (5pts)\n\n" + + if len(entries) == 0 { + output += "No activity recorded yet." + return output + } + + // Table header + output += "| Rank | User | Score | 💰 Bounties | 📝 Applications | ⚖️ Validations |\n" + output += "|---|---|---|---|---|---|\n" + + // Table rows + for i, entry := range entries { + rank := getRankDisplay(i) + output += tableRow( + rank, + string(entry.Address), + md.Bold(ufmt.Sprintf("%d", entry.Score)), + ufmt.Sprintf("%d", entry.BountiesCreated), + ufmt.Sprintf("%d", entry.BountiesApplied), + ufmt.Sprintf("%d", entry.ValidationsPerformed), + ) + } + + return output +} + +// getRankDisplay returns the rank display (medal for top 3, number otherwise) +func getRankDisplay(position int) string { + switch position { + case 0: + return "🥇" + case 1: + return "🥈" + case 2: + return "🥉" + default: + return ufmt.Sprintf("%d", position+1) + } +} + +// tableRow creates a markdown table row +func tableRow(cells ...string) string { + output := "|" + for _, cell := range cells { + output += " " + cell + " |" + } + return output + "\n" +} + +func getEntry(stats *avl.Tree, addr address) *LeaderboardEntry { + key := string(addr) + val, ok := stats.Get(key) + if !ok { + entry := &LeaderboardEntry{Address: addr} + stats.Set(key, entry) + return entry + } + return val.(*LeaderboardEntry) +} diff --git a/packages/r/greg007/gnobounty/logic.gno b/packages/r/greg007/gnobounty/logic.gno new file mode 100644 index 0000000..97c56fa --- /dev/null +++ b/packages/r/greg007/gnobounty/logic.gno @@ -0,0 +1,124 @@ +package gnobounty + +import ( + "chain" + "chain/banker" + "chain/runtime" + "time" + + "gno.land/p/nt/ufmt" +) + +// CreateBounty creates a new bounty post +// title: short title for the bounty +// issueURL: link to the GitHub issue or other issue tracker +// description: description of what needs to be done +// Caller must send GNOT tokens with this transaction +func CreateBounty(_ realm, title, issueURL, description string) uint64 { + // Validate inputs + if title == "" { + panic("title is required") + } + if issueURL == "" { + panic("issue URL is required") + } + if description == "" { + panic("description is required") + } + + // Get the sent coins + sent := banker.OriginSend() + if len(sent) == 0 { + panic("no coins sent with bounty") + } + + // Find GNOT amount + var gnotAmount int64 + for _, coin := range sent { + if coin.Denom == "ugnot" { + gnotAmount = coin.Amount + break + } + } + + if gnotAmount < minimumBounty { + panic(ufmt.Sprintf("bounty amount must be at least %d ugnot", minimumBounty)) + } + + // Create bounty + bountyCount++ + bounty := &Bounty{ + ID: bountyCount, + Title: title, + IssueURL: issueURL, + Description: description, + Amount: gnotAmount, + Creator: runtime.OriginCaller(), + CreatedAt: time.Now(), + IsClaimed: false, + } + + // Store bounty + bounties.Set(ufmt.Sprintf("%d", bountyCount), bounty) + + return bountyCount +} + +// ClaimBounty allows someone to claim a bounty +// Only the creator can approve and transfer funds +func ClaimBounty(_ realm, id uint64, claimer address) { + caller := runtime.OriginCaller() + + bounty := GetBounty(id) + if bounty == nil { + panic("bounty not found") + } + + if bounty.IsClaimed { + panic("bounty already claimed") + } + + if caller != bounty.Creator { + panic("only bounty creator can approve claims") + } + + if claimer == "" { + panic("claimer address cannot be empty") + } + + // Mark as claimed + bounty.IsClaimed = true + bounty.Claimer = claimer + bounty.ClaimedAt = time.Now() + + // Transfer bounty amount to claimer + bnk := banker.NewBanker(banker.BankerTypeRealmSend) + send := chain.Coins{chain.Coin{Denom: "ugnot", Amount: bounty.Amount}} + bnk.SendCoins(pkgAddr, claimer, send) +} + +// CancelBounty allows creator to cancel and get refund +func CancelBounty(_ realm, id uint64) { + caller := runtime.OriginCaller() + + bounty := GetBounty(id) + if bounty == nil { + panic("bounty not found") + } + + if bounty.IsClaimed { + panic("cannot cancel claimed bounty") + } + + if caller != bounty.Creator { + panic("only bounty creator can cancel") + } + + // Remove bounty first (Checks-Effects-Interactions) + bounties.Remove(ufmt.Sprintf("%d", id)) + + // Refund the creator + bnk := banker.NewBanker(banker.BankerTypeRealmSend) + send := chain.Coins{chain.Coin{Denom: "ugnot", Amount: bounty.Amount}} + bnk.SendCoins(pkgAddr, bounty.Creator, send) +} diff --git a/packages/r/greg007/gnobounty/proposal.gno b/packages/r/greg007/gnobounty/proposal.gno new file mode 100644 index 0000000..d2ddc37 --- /dev/null +++ b/packages/r/greg007/gnobounty/proposal.gno @@ -0,0 +1,132 @@ +package gnobounty + +import ( + "chain" + "chain/banker" + "time" + + "gno.land/p/nt/commondao" + "gno.land/p/nt/ufmt" +) + +// ClaimBountyProposal implements commondao.ProposalDefinition for bounty claim proposals +type ClaimBountyProposal struct { + BountyID uint64 + ApplicationID uint64 + Applicant address + PRLink string + title string + description string +} + +// NewClaimBountyProposal creates a new bounty claim proposal definition +func NewClaimBountyProposal(bountyID, applicationID uint64, applicant address, prLink string) *ClaimBountyProposal { + return &ClaimBountyProposal{ + BountyID: bountyID, + ApplicationID: applicationID, + Applicant: applicant, + PRLink: prLink, + title: ufmt.Sprintf("Approve Application #%d for Bounty #%d", applicationID, bountyID), + description: ufmt.Sprintf("Review PR: %s\nApplicant: %s", prLink, applicant), + } +} + +// Title returns proposal title +func (p *ClaimBountyProposal) Title() string { + return p.title +} + +// Body returns proposal's body +func (p *ClaimBountyProposal) Body() string { + return p.description +} + +// VotingPeriod returns the period where votes are allowed after proposal creation +func (p *ClaimBountyProposal) VotingPeriod() time.Duration { + // 7 days voting period + return 7 * 24 * time.Hour +} + +// Tally counts votes and determines if proposal is ready to execute +// Returns true when all validators have voted (regardless of outcome) +func (p *ClaimBountyProposal) Tally(ctx commondao.VotingContext) (bool, error) { + totalMembers := ctx.Members.Size() + if totalMembers == 0 { + return false, ufmt.Errorf("no members in DAO") + } + + totalVotes := ctx.VotingRecord.Size() + + // Return true when all validators have voted + // This allows Execute to be called and handle approval/rejection + if totalVotes == totalMembers { + return true, nil + } + + // Not ready yet - waiting for more votes + return false, nil +} + +// Execute executes the proposal after all votes are in +// Determines approval or rejection based on vote counts +func (p *ClaimBountyProposal) Execute(_ realm) error { + app := GetApplication(p.ApplicationID) + if app == nil { + return ufmt.Errorf("application not found") + } + + bounty := GetBounty(p.BountyID) + if bounty == nil { + return ufmt.Errorf("bounty not found") + } + + if bounty.IsClaimed { + return ufmt.Errorf("bounty already claimed") + } + + // Get vote counts from the DAO proposal + proposal := app.DAO.ActiveProposals().Get(app.ProposalID) + if proposal == nil { + return ufmt.Errorf("proposal not found") + } + + yesCount := proposal.VotingRecord().VoteCount(commondao.ChoiceYes) + noCount := proposal.VotingRecord().VoteCount(commondao.ChoiceNo) + totalMembers := app.DAO.Members().Size() + + // Check if unanimous approval (all YES, no NO) + if yesCount == totalMembers && noCount == 0 { + // Approve and transfer funds + app.Status = StatusApproved + + bounty.IsClaimed = true + bounty.Claimer = p.Applicant + bounty.ClaimedAt = time.Now() + + // Calculate reward split: 5% to validators, 95% to applicant + validatorReward := (bounty.Amount * validatorRewardPercent) / 100 + applicantReward := bounty.Amount - validatorReward + + // Split validator reward equally among all validators who voted + rewardPerValidator := validatorReward / int64(totalMembers) + + // Transfer rewards + bnk := banker.NewBanker(banker.BankerTypeRealmSend) + + // Pay each validator their share + app.DAO.Members().IterateByOffset(0, totalMembers, func(member address) bool { + validatorPayout := chain.Coins{chain.Coin{Denom: "ugnot", Amount: rewardPerValidator}} + bnk.SendCoins(pkgAddr, member, validatorPayout) + return false + }) + + // Pay the applicant the remaining 95% + applicantPayout := chain.Coins{chain.Coin{Denom: "ugnot", Amount: applicantReward}} + bnk.SendCoins(pkgAddr, p.Applicant, applicantPayout) + } else { + // Reject - not unanimous + app.Status = StatusRejected + } + + return nil +} diff --git a/packages/r/greg007/gnobounty/render.gno b/packages/r/greg007/gnobounty/render.gno new file mode 100644 index 0000000..34666d2 --- /dev/null +++ b/packages/r/greg007/gnobounty/render.gno @@ -0,0 +1,293 @@ +package gnobounty + +import ( + "strconv" + + "gno.land/p/moul/md" + "gno.land/p/moul/txlink" + "gno.land/p/nt/commondao" + "gno.land/p/nt/ufmt" +) + +// ListBounties returns all active bounties +func ListBounties() string { + minSend := ufmt.Sprintf("%dugnot", minimumBounty) + createBountyLink := txlink.NewLink("CreateBounty"). + AddArgs( + "title", "", + "issueURL", "", + "description", "", + ). + SetSend(minSend). + URL() + + output := "# GnoBounty - Decentralized Bounty System\n\n" + + output += "## 🎯 Quick Actions\n\n" + output += md.Link("💰 Create a new bounty", createBountyLink) + output += ufmt.Sprintf(" _(minimum reward: %s)_", minSend) + output += " | " + output += md.Link("📋 View All Validators", "/"+pkgPath+":validators") + output += " | " + output += md.Link("🏆 Leaderboard", "/"+pkgPath+":leaderboard") + output += "\n\n" + + // Add validator stats + activeValidatorCount := GetActiveValidatorCount() + output += ufmt.Sprintf("**Active Validators:** %d | **Total Bounties:** %d\n\n", activeValidatorCount, bountyCount) + + output += "---\n\n" + output += "## 💎 Active Bounties\n\n" + output += "**How to apply:** To apply to a bounty, you need to give your **MERGED PR** (important) URL in the transaction. You also need to write your Gno address in the description of your PR to verify your identity. If no address is written, the bounty can be refused.\n\n" + + if bountyCount == 0 { + output += "No bounties available" + return output + } + + bounties.Iterate("", "", func(key string, value interface{}) bool { + bounty := value.(*Bounty) + if !bounty.IsClaimed { + output += ufmt.Sprintf("## Bounty #%d: %s\n", bounty.ID, bounty.Title) + output += ufmt.Sprintf("**Issue:** %s\n", bounty.IssueURL) + output += ufmt.Sprintf("**Description:** %s\n", bounty.Description) + output += ufmt.Sprintf("**Reward:** %d ugnot (%.2f GNOT)\n", bounty.Amount, float64(bounty.Amount)/1000000.0) + output += ufmt.Sprintf("**Creator:** %s\n", bounty.Creator) + output += ufmt.Sprintf("**Created:** %s\n\n", bounty.CreatedAt.Format("2006-01-02 15:04:05")) + + // Add apply button for this bounty + applyLink := txlink.NewLink("ApplyForBounty"). + AddArgs( + "bountyID", ufmt.Sprintf("%d", bounty.ID), + "prLink", "", + ). + URL() + output += md.Link("📝 Apply for this bounty", applyLink) + output += " | " + + // Add view details link + detailsPath := ufmt.Sprintf("/%s:%d", pkgPath, bounty.ID) + output += md.Link("🔍 View details", detailsPath) + output += "\n\n" + } + return false + }) + + return output +} + +// GetBountyDetails returns detailed information about a specific bounty +func GetBountyDetails(id uint64) string { + bounty := GetBounty(id) + if bounty == nil { + return "Bounty not found" + } + + output := ufmt.Sprintf("# Bounty #%d: %s\n\n", bounty.ID, bounty.Title) + output += ufmt.Sprintf("**Issue URL:** %s\n", bounty.IssueURL) + output += ufmt.Sprintf("**Description:** %s\n", bounty.Description) + output += ufmt.Sprintf("**Reward:** %d ugnot (%.2f GNOT)\n", bounty.Amount, float64(bounty.Amount)/1000000.0) + output += ufmt.Sprintf("**Creator:** %s\n", bounty.Creator) + output += ufmt.Sprintf("**Created:** %s\n", bounty.CreatedAt.Format("2006-01-02 15:04:05")) + + if bounty.IsClaimed { + output += ufmt.Sprintf("**Status:** CLAIMED\n") + output += ufmt.Sprintf("**Claimed by:** %s\n", bounty.Claimer) + output += ufmt.Sprintf("**Claimed at:** %s\n", bounty.ClaimedAt.Format("2006-01-02 15:04:05")) + } else { + output += ufmt.Sprintf("**Status:** OPEN\n\n") + output += "**How to apply:** To apply to a bounty, you need to give your **MERGED PR** (important) URL in the transaction. You also need to write your Gno address in the description of your PR to verify your identity. If no address is written, the bounty can be refused.\n\n" + + // Add apply button + applyLink := txlink.NewLink("ApplyForBounty"). + AddArgs( + "bountyID", ufmt.Sprintf("%d", id), + "prLink", "", + ). + URL() + output += md.Link("Apply for this bounty", applyLink) + "\n\n" + + // Show existing applications + apps := GetApplicationsForBounty(id) + if len(apps) > 0 { + output += "### Applications\n\n" + for _, app := range apps { + output += renderApplication(app) + } + } + } + + return output +} + +// renderApplication renders a single application +func renderApplication(app *Application) string { + output := ufmt.Sprintf("**Application #%d**\n", app.ID) + output += ufmt.Sprintf("- Applicant: %s\n", app.Applicant) + output += ufmt.Sprintf("- PR Link: %s\n", app.PRLink) + output += ufmt.Sprintf("- Applied: %s\n", app.AppliedAt.Format("2006-01-02 15:04:05")) + + statusStr := "PENDING" + if app.Status == StatusApproved { + statusStr = "APPROVED" + } else if app.Status == StatusRejected { + statusStr = "REJECTED" + } + output += ufmt.Sprintf("- Status: %s\n", statusStr) + + // Show DAO voting information if DAO exists + if app.DAO != nil { + output += ufmt.Sprintf("- Proposal ID: %d\n", app.ProposalID) + + // Get proposal and show detailed validator voting status + proposal := app.DAO.ActiveProposals().Get(app.ProposalID) + if proposal != nil { + // Get vote counts + yesCount := proposal.VotingRecord().VoteCount(commondao.ChoiceYes) + noCount := proposal.VotingRecord().VoteCount(commondao.ChoiceNo) + abstainCount := proposal.VotingRecord().VoteCount(commondao.ChoiceAbstain) + totalVotes := proposal.VotingRecord().Size() + totalMembers := app.DAO.Members().Size() + + output += ufmt.Sprintf("\n**Voting Progress:** %d/%d validators have voted\n", totalVotes, totalMembers) + output += ufmt.Sprintf("- ✅ YES: %d\n", yesCount) + output += ufmt.Sprintf("- ❌ NO: %d\n", noCount) + output += ufmt.Sprintf("- 🤷 ABSTAIN: %d\n", abstainCount) + output += ufmt.Sprintf("- ⏳ PENDING: %d\n", totalMembers-totalVotes) + } + } + + // Add vote buttons for validators if application is still pending + if app.Status == StatusPending && app.DAO != nil { + output += "\n**Vote on this application:**\n" + + // Add vote buttons (yes/no/abstain) + yesLink := txlink.NewLink("Vote"). + AddArgs( + "applicationID", ufmt.Sprintf("%d", app.ID), + "vote", "yes", + ). + URL() + noLink := txlink.NewLink("Vote"). + AddArgs( + "applicationID", ufmt.Sprintf("%d", app.ID), + "vote", "no", + ). + URL() + abstainLink := txlink.NewLink("Vote"). + AddArgs( + "applicationID", ufmt.Sprintf("%d", app.ID), + "vote", "abstain", + ). + URL() + + output += md.Link("✅ Approve", yesLink) + " | " + md.Link("❌ Reject", noLink) + " | " + md.Link("🤷 Abstain", abstainLink) + "\n" + output += "\n_Note: Only assigned validators can vote on this application._\n" + } + output += "\n" + + return output +} + +// RenderMyVotes shows pending votes for a validator +func RenderMyVotes(validatorAddr address) string { + if !IsValidator(validatorAddr) { + return "You are not a registered validator" + } + + output := "# My Pending Votes\n\n" + + apps := GetApplicationsForValidator(validatorAddr) + pendingCount := 0 + + for _, app := range apps { + if app.Status == StatusPending { + bounty := GetBounty(app.BountyID) + if bounty == nil { + continue + } + + pendingCount++ + output += ufmt.Sprintf("## Application #%d for Bounty #%d\n", app.ID, app.BountyID) + output += ufmt.Sprintf("**Bounty:** %s\n", bounty.Description) + output += ufmt.Sprintf("**Bounty Reward:** %d ugnot (%.2f GNOT)\n", bounty.Amount, float64(bounty.Amount)/1000000.0) + output += ufmt.Sprintf("**Applicant:** %s\n", app.Applicant) + output += ufmt.Sprintf("**PR Link:** %s\n", app.PRLink) + + // Show DAO proposal details + if app.DAO != nil { + output += ufmt.Sprintf("**Proposal ID:** %d\n", app.ProposalID) + + // Get proposal and show vote count + proposal := app.DAO.ActiveProposals().Get(app.ProposalID) + if proposal != nil { + voteCount := 0 + proposal.VotingRecord().Iterate(0, 0, false, func(v commondao.Vote) bool { + voteCount++ + return false + }) + output += ufmt.Sprintf("**Votes Submitted:** %d/%d\n", voteCount, app.DAO.Members().Size()) + } + } + output += "\n" + + // Add vote buttons (yes/no/abstain) + yesLink := txlink.NewLink("Vote"). + AddArgs( + "applicationID", ufmt.Sprintf("%d", app.ID), + "vote", "yes", + ). + URL() + noLink := txlink.NewLink("Vote"). + AddArgs( + "applicationID", ufmt.Sprintf("%d", app.ID), + "vote", "no", + ). + URL() + abstainLink := txlink.NewLink("Vote"). + AddArgs( + "applicationID", ufmt.Sprintf("%d", app.ID), + "vote", "abstain", + ). + URL() + + output += md.Link("Approve (Yes)", yesLink) + " | " + md.Link("Reject (No)", noLink) + " | " + md.Link("Abstain", abstainLink) + "\n\n" + output += "---\n\n" + } + } + + if pendingCount == 0 { + output += "No pending votes" + } + + return output +} + +// RenderValidators shows all validators +func RenderValidators() string { + return ListValidators() +} + +// Render implements the Render() method for the realm +func Render(path string) string { + if path == "" { + return ListBounties() + } + + // Check for special paths + if path == "validators" { + return RenderValidators() + } + + if path == "leaderboard" { + return RenderLeaderboard() + } + + // Parse bounty ID from path + id, err := strconv.Atoi(path) + if err != nil { + return ufmt.Sprintf("Invalid path. Use /%s for bounties or /%s:validators for validators list", pkgPath, pkgPath) + } + + return GetBountyDetails(uint64(id)) +} diff --git a/packages/r/greg007/gnobounty/storage.gno b/packages/r/greg007/gnobounty/storage.gno new file mode 100644 index 0000000..3e794ad --- /dev/null +++ b/packages/r/greg007/gnobounty/storage.gno @@ -0,0 +1,125 @@ +package gnobounty + +import ( + "chain" + + "gno.land/p/nt/avl" + "gno.land/p/nt/ownable" + "gno.land/p/nt/ufmt" +) + +var ( + bounties avl.Tree // uint64 -> *Bounty + bountyCount uint64 + minimumBounty int64 = 1000000 // 1 GNOT minimum + + applications avl.Tree // uint64 -> *Application + applicationCount uint64 + + validators avl.Tree // address -> *Validator + validatorList []address // List of validator addresses for random selection + validatorsPerDAO int = 3 // Number of validators per DAO vote + requiredVotes int = 3 // Number of approvals needed (3/3) + + // Validator reward percentage (5% of bounty split among validators) + validatorRewardPercent int64 = 5 // 5% of bounty goes to validators + + ownership *ownable.Ownable + + // Package address - stored for easy redeployment + pkgAddr address + + // Package path - used in URLs for rendering + pkgPath string = "gno.land/r/greg007/gnobounty" +) + +func init() { + ownership = ownable.NewWithOrigin() + pkgAddr = chain.PackageAddress(pkgPath) +} + +// GetBounty returns a bounty by ID +func GetBounty(id uint64) *Bounty { + bountyInterface, exists := bounties.Get(ufmt.Sprintf("%d", id)) + if !exists { + return nil + } + return bountyInterface.(*Bounty) +} + +// GetBountyCount returns the total number of bounties created +func GetBountyCount() uint64 { + return bountyCount +} + +// SetMinimumBounty allows setting minimum bounty amount (admin function) +func SetMinimumBounty(amount int64) { + ownership.AssertOwnedByPrevious() + minimumBounty = amount +} + +// SetPackagePath allows setting the package path for URLs (admin function) +func SetPackagePath(path string) { + ownership.AssertOwnedByPrevious() + if path == "" { + panic("package path cannot be empty") + } + pkgPath = path + pkgAddr = chain.PackageAddress(path) +} + +// GetApplication returns an application by ID +func GetApplication(id uint64) *Application { + appInterface, exists := applications.Get(ufmt.Sprintf("%d", id)) + if !exists { + return nil + } + return appInterface.(*Application) +} + +// GetApplicationCount returns the total number of applications +func GetApplicationCount() uint64 { + return applicationCount +} + +// IsValidator checks if an address is a validator +func IsValidator(addr address) bool { + validatorInterface, exists := validators.Get(string(addr)) + if !exists { + return false + } + validator := validatorInterface.(*Validator) + return validator.Active +} + +// GetValidator returns a validator by address +func GetValidator(addr address) *Validator { + validatorInterface, exists := validators.Get(string(addr)) + if !exists { + return nil + } + return validatorInterface.(*Validator) +} + +// GetActiveValidatorCount returns the number of active validators +func GetActiveValidatorCount() int { + count := 0 + validators.Iterate("", "", func(key string, value interface{}) bool { + validator := value.(*Validator) + if validator.Active { + count++ + } + return false + }) + return count +} + +// GetOwner returns the owner address (helper function for debugging) +func GetOwner() address { + return ownership.Owner() +} + +// GetPackagePath returns the current package path +func GetPackagePath() string { + return pkgPath +} diff --git a/packages/r/greg007/gnobounty/types.gno b/packages/r/greg007/gnobounty/types.gno new file mode 100644 index 0000000..6d5a8fe --- /dev/null +++ b/packages/r/greg007/gnobounty/types.gno @@ -0,0 +1,56 @@ +package gnobounty + +import ( + "time" + + "gno.land/p/nt/commondao" +) + +// Bounty represents a bounty post for an issue +type Bounty struct { + ID uint64 + Title string // Short title for the bounty + IssueURL string + Description string + Amount int64 // Amount in ugnot + Creator address + CreatedAt time.Time + IsClaimed bool + Claimer address + ClaimedAt time.Time +} + +// Application represents a claim application for a bounty with a private DAO +type Application struct { + ID uint64 + BountyID uint64 + Applicant address + PRLink string + AppliedAt time.Time + Status ApplicationStatus + DAO *commondao.CommonDAO // Private DAO for this application + ProposalID uint64 // Proposal ID in the DAO + Votes []VoteRecord // Local record of votes +} + +// VoteRecord represents a vote cast by a validator +type VoteRecord struct { + Voter address + Choice commondao.VoteChoice +} + +// ApplicationStatus represents the status of an application +type ApplicationStatus int + +const ( + StatusPending ApplicationStatus = iota + StatusApproved + StatusRejected +) + +// Validator represents a validator who can vote on applications +type Validator struct { + Address address + AddedAt time.Time + Active bool +} diff --git a/packages/r/greg007/gnobounty/validators.gno b/packages/r/greg007/gnobounty/validators.gno new file mode 100644 index 0000000..4c11dd0 --- /dev/null +++ b/packages/r/greg007/gnobounty/validators.gno @@ -0,0 +1,112 @@ +package gnobounty + +import ( + "time" + + "gno.land/p/nt/ufmt" +) + +// AddValidator adds a new validator to the system (Admin only) +func AddValidator(_ realm, validatorAddr address) { + // Check if caller is admin + ownership.AssertOwnedByPrevious() + + // Check if validator already exists + if _, exists := validators.Get(string(validatorAddr)); exists { + panic("validator already exists") + } + + validator := &Validator{ + Address: validatorAddr, + AddedAt: time.Now(), + Active: true, + } + + validators.Set(string(validatorAddr), validator) + validatorList = append(validatorList, validatorAddr) +} + +// RemoveValidator removes a validator from the system (Admin only) +func RemoveValidator(_ realm, validatorAddr address) { + // Check if caller is admin + ownership.AssertOwnedByPrevious() + + validatorInterface, exists := validators.Get(string(validatorAddr)) + if !exists { + panic("validator not found") + } + + validator := validatorInterface.(*Validator) + validator.Active = false + + // Remove from validator list + newList := make([]address, 0) + for _, addr := range validatorList { + if addr != validatorAddr { + newList = append(newList, addr) + } + } + validatorList = newList +} + +// selectRandomValidators selects N random validators for voting +// Uses a simple pseudo-random selection based on application ID +func selectRandomValidators(appID uint64, count int) []address { + activeValidators := getActiveValidators() + + if len(activeValidators) < count { + panic(ufmt.Sprintf("not enough active validators: need %d, have %d", count, len(activeValidators))) + } + + // Simple pseudo-random selection using appID as seed + selected := make([]address, 0, count) + seed := appID + + for i := 0; i < count; i++ { + index := int((seed + uint64(i)*7) % uint64(len(activeValidators))) + selected = append(selected, activeValidators[index]) + + // Remove selected validator to avoid duplicates + activeValidators = append(activeValidators[:index], activeValidators[index+1:]...) + } + + return selected +} + +// getActiveValidators returns all active validator addresses +func getActiveValidators() []address { + active := make([]address, 0) + for _, addr := range validatorList { + if IsValidator(addr) { + active = append(active, addr) + } + } + return active +} + +// ListValidators returns information about all validators +func ListValidators() string { + output := "# Validators\n\n" + + output += ufmt.Sprintf("**Total Active Validators:** %d\n\n", GetActiveValidatorCount()) + + if len(validatorList) == 0 { + output += "No validators registered yet.\n\n" + } else { + validators.Iterate("", "", func(key string, value interface{}) bool { + validator := value.(*Validator) + status := "✅ ACTIVE" + if !validator.Active { + status = "❌ INACTIVE" + } + output += ufmt.Sprintf("- %s %s - Added: %s\n", + status, + validator.Address, + validator.AddedAt.Format("2006-01-02")) + return false + }) + output += "\n" + } + + return output +}