diff --git a/backend/main/leaderboard_test.go b/backend/main/leaderboard_test.go index 310ff47f2..f66226c37 100644 --- a/backend/main/leaderboard_test.go +++ b/backend/main/leaderboard_test.go @@ -3,13 +3,15 @@ package main import ( "encoding/json" "net/http" + "sort" "testing" + "time" "github.com/DapperCollectives/CAST/backend/main/test_utils" "github.com/stretchr/testify/assert" ) -func TestGetCommunityLeaderboard(t *testing.T) { +func TestGetLeaderboard(t *testing.T) { clearTable("communities") clearTable("community_users") clearTable("user_achievements") @@ -43,7 +45,7 @@ func TestGetCommunityLeaderboard(t *testing.T) { assert.Equal(t, expectedScore, receivedUser2Score) } -func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { +func TestGetLeaderboardWithEarlyVotes(t *testing.T) { clearTable("communities") clearTable("community_users") clearTable("user_achievements") @@ -72,7 +74,7 @@ func TestGetCommunityLeaderboardWithEarlyVotes(t *testing.T) { assert.Equal(t, expectedScore, receivedScore) } -func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) { +func TestGetLeaderboardWithSingleStreak(t *testing.T) { clearTable("communities") clearTable("community_users") clearTable("user_achievements") @@ -83,8 +85,8 @@ func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) { streaks := []int{3, 4} streakBonus := 1 expectedUsers := 2 - expectedUser1Score := 3 + (1 * streakBonus) - expectedUser2Score := 4 + (1 * streakBonus) + expectedScoreA := streaks[0] + (1 * streakBonus) + expectedScoreB := streaks[1] + (1 * streakBonus) otu.GenerateSingleStreakAchievements(communityId, streaks) @@ -94,15 +96,20 @@ func TestGetCommunityLeaderboardWithSingleStreak(t *testing.T) { var p test_utils.PaginatedResponseWithLeaderboardUser json.Unmarshal(response.Body.Bytes(), &p) - receivedUser1Score := p.Data[0].Score - receivedUser2Score := p.Data[1].Score + // ensure scores ordered for assert + sort.Slice(p.Data, func(i, j int) bool { + return p.Data[i].Score < p.Data[j].Score + }) + + receivedScoreA := p.Data[0].Score + receivedScoreB := p.Data[1].Score assert.Equal(t, expectedUsers, len(p.Data)) - assert.Equal(t, expectedUser1Score, receivedUser1Score) - assert.Equal(t, expectedUser2Score, receivedUser2Score) + assert.Equal(t, expectedScoreA, receivedScoreA) + assert.Equal(t, expectedScoreB, receivedScoreB) } -func TestGetCommunityLeaderboardWithMultiStreaks(t *testing.T) { +func TestGetLeaderboardWithMultiStreaks(t *testing.T) { clearTable("communities") clearTable("community_users") clearTable("user_achievements") @@ -129,3 +136,44 @@ func TestGetCommunityLeaderboardWithMultiStreaks(t *testing.T) { assert.Equal(t, expectedUsers, len(p.Data)) assert.Equal(t, expectedUser1Score, receivedUser1Score) } + +func TestGetLeaderboardWithWinningVote(t *testing.T) { + clearTable("communities") + clearTable("community_users") + clearTable("user_achievements") + clearTable("proposals") + clearTable("proposal_results") + clearTable("votes") + communityId := otu.AddCommunities(1)[0] + winningVoteBonus := 1 + + proposalId := otu.GenerateWinningVoteAchievement(communityId, "one-address-one-vote") + otu.UpdateProposalEndTime(proposalId, time.Now().UTC()) + otu.GetProposalResultsAPI(proposalId) + + response := otu.GetCommunityLeaderboardAPI(communityId) + checkResponseCode(t, http.StatusOK, response.Code) + + var p test_utils.PaginatedResponseWithLeaderboardUser + json.Unmarshal(response.Body.Bytes(), &p) + + winningUserScore := 1 + 1*winningVoteBonus + losingUserScore := 1 + + receivedWinners := 0 + receivedLosers := 0 + + for _, user := range p.Data { + if user.Score == winningUserScore { + receivedWinners += 1 + } else if user.Score == losingUserScore { + receivedLosers += 1 + } + } + + expectedWinners := 3 + expectedLosers := 1 + + assert.Equal(t, expectedWinners, receivedWinners) + assert.Equal(t, expectedLosers, receivedLosers) +} diff --git a/backend/main/models/community_users.go b/backend/main/models/community_users.go index 2d493ded9..a713d8603 100644 --- a/backend/main/models/community_users.go +++ b/backend/main/models/community_users.go @@ -40,10 +40,11 @@ type CommunityUserPayload struct { } type UserAchievements = []struct { - Address string - NumVotes int - EarlyVote int - Streak int + Address string + NumVotes int + EarlyVote int + Streak int + WinningVote int } type LeaderboardUserPayload struct { @@ -111,6 +112,7 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L var leaderboardUsers = []LeaderboardUserPayload{} var defaultEarlyVoteWeight = 1 var defaultStreakWeight = 1 + var defaultWinningVoteWeight = 1 userAchievements, err := getUserAchievements(db, communityId, start, count) @@ -121,7 +123,7 @@ func GetCommunityLeaderboard(db *s.Database, communityId, start, count int) ([]L for _, user := range userAchievements { var leaderboardUser = LeaderboardUserPayload{} leaderboardUser.Addr = user.Address - leaderboardUser.Score = user.NumVotes + (user.EarlyVote * defaultEarlyVoteWeight) + (user.Streak * defaultStreakWeight) + leaderboardUser.Score = user.NumVotes + (user.EarlyVote * defaultEarlyVoteWeight) + (user.Streak * defaultStreakWeight) + (user.WinningVote * defaultWinningVoteWeight) leaderboardUsers = append(leaderboardUsers, leaderboardUser) } @@ -272,26 +274,44 @@ func getUserAchievements(db *s.Database, communityId int, start int, count int) // their votes and achievements (e.g. early votes, streaks and winning choices) // Note 1: crosstab is a postgres extension that creates a pivot table. // Achievements are joined as columns for each user. - // Note 2: Subselect community_id not replaced properly by $1, so has been + // Note 2: Subselects community_id not replaced properly by $1, so has been // substituted in string first. sql := fmt.Sprintf( ` - SELECT v.addr as address, count(*) as num_votes, - CASE WHEN a.early_vote is NULL THEN 0 ELSE a.early_vote END as early_vote - FROM votes v - LEFT OUTER JOIN proposals p ON p.id = v.proposal_id - LEFT OUTER JOIN ( - SELECT * FROM crosstab( - $$SELECT addr, achievement_type, count(*) FROM user_achievements - WHERE community_id = %d - GROUP BY addr, achievement_type - ORDER BY 1,2$$ - ) AS ct(address varchar(18), early_vote bigint) - ) a ON v.addr = a.address + SELECT v.addr as address, count(*) as num_votes, + COALESCE(a.early_vote, 0) as early_vote, + COALESCE(b.streak, 0) as streak, + COALESCE(c.winning_vote, 0) as winning_vote + FROM votes v + LEFT OUTER JOIN proposals p ON p.id = v.proposal_id + LEFT OUTER JOIN ( + SELECT * FROM crosstab( + $$SELECT addr, achievement_type, count(*) FROM user_achievements + WHERE community_id = %d and achievement_type = 'earlyVote' + GROUP BY addr, achievement_type + ORDER BY 1,2$$ + ) AS ct(address varchar(18), early_vote bigint) + ) a ON v.addr = a.address + LEFT OUTER JOIN ( + SELECT * FROM crosstab( + $$SELECT addr, achievement_type, count(*) FROM user_achievements + WHERE community_id = %d and achievement_type = 'streak' + GROUP BY addr, achievement_type + ORDER BY 1,2$$ + ) AS ct(address varchar(18), streak bigint) + ) b ON v.addr = b.address + LEFT OUTER JOIN ( + SELECT * FROM crosstab( + $$SELECT addr, achievement_type, count(*) FROM user_achievements + WHERE community_id = %d and achievement_type = 'winningVote' + GROUP BY addr, achievement_type + ORDER BY 1,2$$ + ) AS ct(address varchar(18), winning_vote bigint) + ) c ON v.addr = c.address WHERE p.community_id = $1 - GROUP BY v.addr, a.early_vote + GROUP BY v.addr, a.early_vote, b.streak, c.winning_vote LIMIT $2 OFFSET $3 - `, communityId) + `, communityId, communityId, communityId) err := pgxscan.Select(db.Context, db.Conn, &userAchievements, sql, communityId, count, start) diff --git a/backend/main/models/vote.go b/backend/main/models/vote.go index 78d9d269d..4f6bc5d01 100644 --- a/backend/main/models/vote.go +++ b/backend/main/models/vote.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "errors" "fmt" + "sort" "strconv" "strings" "time" @@ -206,7 +207,7 @@ func (v *Vote) GetVoteById(db *s.Database) error { } func (v *Vote) CreateVote(db *s.Database) error { - var defaultEarlyVoteLength = 1 + var defaultEarlyVoteLength = 2 // in hours err := createVote(db, v) if err != nil { @@ -221,13 +222,13 @@ func (v *Vote) CreateVote(db *s.Database) error { isEarlyVote := v.Created_at.Before(proposal.Start_time.Add(time.Hour * time.Duration(defaultEarlyVoteLength))) if isEarlyVote { - err = addEarlyVoteAchievement(db, v, proposal) + err = AddEarlyVoteAchievement(db, v, proposal) if err != nil { return err } } - err = addStreakAchievement(db, v, proposal) + err = AddStreakAchievement(db, v, proposal) return err } @@ -358,7 +359,7 @@ func getProposal(db *s.Database, proposalId int) (Proposal, error) { return proposal, err } -func addEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error { +func AddEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error { //Unique identifier ensuring there are no duplicate early vote achievements earlyVoteDetails := fmt.Sprintf("%s:%s:%d:%d", EarlyVote, v.Addr, p.Community_id, v.Proposal_id) @@ -378,7 +379,7 @@ func addEarlyVoteAchievement(db *s.Database, v *Vote, p Proposal) error { return nil } -func addStreakAchievement(db *s.Database, v *Vote, p Proposal) error { +func AddStreakAchievement(db *s.Database, v *Vote, p Proposal) error { var defaultStreakLength = 3 votingStreak, err := getUserVotingStreak(db, v.Addr, p.Community_id) @@ -392,7 +393,14 @@ func addStreakAchievement(db *s.Database, v *Vote, p Proposal) error { if vote.Addr != "" { proposals = append(proposals, vote.Proposal_id) } + + if len(proposals) >= defaultStreakLength && (i == len(votingStreak)-1 || (i < len(votingStreak)-1 && votingStreak[i+1].Addr == "")) { + // ensure proposals always ordered to guarantee no duplicates + sort.Slice(proposals, func(i, j int) bool { + return proposals[i] < proposals[j] + }) + //Unique identifier for current streak currentStreakDetails := fmt.Sprintf("%s:%s:%d:%s", Streak, v.Addr, p.Community_id, strings.Trim(strings.Join(strings.Fields(fmt.Sprint(proposals)), ","), "[]")) @@ -420,6 +428,39 @@ func addStreakAchievement(db *s.Database, v *Vote, p Proposal) error { return nil } +func AddWinningVoteAchievement(db *s.Database, votes []*VoteWithBalance, p ProposalResults, communityId int) error { + maxVotes := 0 + winningChoice := "" + for k, v := range p.Results { + if v > maxVotes { + maxVotes = v + winningChoice = k + } + } + for _, v := range votes { + if v.Choice == winningChoice { + details := fmt.Sprintf("%s:%s:%d:%d", WinningVote, v.Addr, communityId, v.Proposal_id) + err := db.Conn.QueryRow(db.Context, + ` + INSERT INTO user_achievements(addr, achievement_type, community_id, proposals, details) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (details) + DO NOTHING + RETURNING id + `, v.Addr, WinningVote, communityId, []int{v.Proposal_id}, details).Scan(&v.ID) + + if checkErrorIgnoreNoRows(err) { + // Row already exists, so we have previously calculated + // winning achievements for this proposal + return nil + } else if err != nil { + return err + } + } + } + return nil +} + func getUserVotingStreak(db *s.Database, addr string, communityId int) ([]VotingStreak, error) { // Determine if this vote is part of a streak // Proposals with the user address count as a vote for that proposal diff --git a/backend/main/server/app.go b/backend/main/server/app.go index 9f3aaf44e..856d1fce5 100644 --- a/backend/main/server/app.go +++ b/backend/main/server/app.go @@ -325,6 +325,10 @@ func (a *App) getResultsForProposal(w http.ResponseWriter, r *http.Request) { return } + if *p.Computed_status == "closed" { + models.AddWinningVoteAchievement(a.DB, votes, proposalResults, p.Community_id) + } + // Send Proposal Results respondWithJSON(w, http.StatusOK, proposalResults) } diff --git a/backend/main/strategies/token_weighted_default.go b/backend/main/strategies/token_weighted_default.go index 67671057a..46bcc6579 100644 --- a/backend/main/strategies/token_weighted_default.go +++ b/backend/main/strategies/token_weighted_default.go @@ -46,7 +46,6 @@ func (s *TokenWeightedDefault) TallyVotes( votes []*models.VoteWithBalance, p *models.ProposalResults, ) (models.ProposalResults, error) { - //tally votes for _, vote := range votes { if vote.PrimaryAccountBalance != nil { diff --git a/backend/main/test_utils/factory.go b/backend/main/test_utils/factory.go index 1ca69e4df..9b3bfc494 100644 --- a/backend/main/test_utils/factory.go +++ b/backend/main/test_utils/factory.go @@ -259,6 +259,16 @@ func (otu *OverflowTestUtils) AddActiveProposalsWithStartTimeNow(cId int, count return retIds } +func (otu *OverflowTestUtils) UpdateProposalEndTime(pId int, endTime time.Time) { + _, err := otu.A.DB.Conn.Exec(otu.A.DB.Context, + ` + UPDATE proposals SET end_time = $2 WHERE id = $1 + `, pId, endTime) + if err != nil { + log.Error().Err(err).Msg("update proposal end_time DB err") + } +} + func (otu *OverflowTestUtils) AddLists(cId int, count int) []int { if count < 1 { count = 1 diff --git a/backend/main/test_utils/leaderboard_utils.go b/backend/main/test_utils/leaderboard_utils.go index 2a031c77f..a87b76856 100644 --- a/backend/main/test_utils/leaderboard_utils.go +++ b/backend/main/test_utils/leaderboard_utils.go @@ -73,6 +73,20 @@ func (otu *OverflowTestUtils) GenerateMultiStreakAchievements(communityId int, s } } +func (otu *OverflowTestUtils) GenerateWinningVoteAchievement(communityId int, strategy string) int { + proposalIds, _ := otu.AddProposalsForStrategy(communityId, strategy, 1) + proposalId := proposalIds[0] + winningChoice := "a" + losingChoice := "b" + + otu.CreateVoteAPI(proposalId, otu.GenerateValidVotePayload("user1", proposalId, losingChoice)) + otu.CreateVoteAPI(proposalId, otu.GenerateValidVotePayload("user2", proposalId, winningChoice)) + otu.CreateVoteAPI(proposalId, otu.GenerateValidVotePayload("user3", proposalId, winningChoice)) + otu.CreateVoteAPI(proposalId, otu.GenerateValidVotePayload("user4", proposalId, winningChoice)) + + return proposalId +} + func max(s []int) int { var m int for i, v := range s { diff --git a/backend/main/test_utils/proposal_utils.go b/backend/main/test_utils/proposal_utils.go index dd5c84e3c..3ab6a9d97 100644 --- a/backend/main/test_utils/proposal_utils.go +++ b/backend/main/test_utils/proposal_utils.go @@ -98,6 +98,21 @@ func (otu *OverflowTestUtils) GenerateCancelProposalStruct( return &payload } +func (otu *OverflowTestUtils) GenerateClosedProposalStruct( + signer string, + proposalId int, +) *models.UpdateProposalRequestPayload { + payload := models.UpdateProposalRequestPayload{Status: "closed"} + timestamp := fmt.Sprint(time.Now().UnixNano() / int64(time.Millisecond)) + compositeSignatures := otu.GenerateCompositeSignatures(signer, timestamp) + account, _ := otu.O.State.Accounts().ByName(fmt.Sprintf("emulator-%s", signer)) + payload.Signing_addr = fmt.Sprintf("0x%s", account.Address().String()) + payload.Timestamp = timestamp + payload.Composite_signatures = compositeSignatures + + return &payload +} + func (otu *OverflowTestUtils) GenerateProposalPayload(signer string, proposal *models.Proposal) *models.Proposal { timestamp := fmt.Sprint(time.Now().UnixNano() / int64(time.Millisecond)) compositeSignatures := otu.GenerateCompositeSignatures(signer, timestamp) diff --git a/backend/migrations/000021_add_achievements_details_column.down.sql b/backend/migrations/000021_add_achievements_details_column.down.sql new file mode 100644 index 000000000..eee75d540 --- /dev/null +++ b/backend/migrations/000021_add_achievements_details_column.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE user_achievements RENAME TO community_users_achievements; +ALTER TABLE community_users_achievements DROP COLUMN details; + diff --git a/backend/migrations/000021_add_achievements_details_column.up.sql b/backend/migrations/000021_add_achievements_details_column.up.sql new file mode 100644 index 000000000..800ce37e4 --- /dev/null +++ b/backend/migrations/000021_add_achievements_details_column.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE community_users_achievements ADD details VARCHAR NOT NULL; +ALTER TABLE community_users_achievements ADD UNIQUE (details); +ALTER TABLE community_users_achievements RENAME TO user_achievements; \ No newline at end of file