github.com/DapperCollectives/CAST/backend@v0.0.0-20230921221157-1350c8be7c96/main/models/vote.go (about) 1 package models 2 3 import ( 4 "encoding/hex" 5 "errors" 6 "fmt" 7 "strconv" 8 "strings" 9 "time" 10 11 "github.com/DapperCollectives/CAST/backend/main/shared" 12 s "github.com/DapperCollectives/CAST/backend/main/shared" 13 "github.com/georgysavva/scany/pgxscan" 14 "github.com/google/uuid" 15 "github.com/jackc/pgx/v4" 16 "github.com/rs/zerolog/log" 17 ) 18 19 type Vote struct { 20 ID int `json:"id,omitempty"` 21 Proposal_id int `json:"proposalId"` 22 Addr string `json:"addr" validate:"required"` 23 Choice string `json:"choice" validate:"required"` 24 Composite_signatures *[]s.CompositeSignature `json:"compositeSignatures" validate:"required"` 25 Created_at time.Time `json:"createdAt,omitempty"` 26 Cid *string `json:"cid"` 27 Message string `json:"message"` 28 Voucher *shared.Voucher `json:"voucher,omitempty"` 29 IsCancelled bool `json:"isCancelled"` 30 IsEarly bool `json:"isEarly"` 31 IsWinning bool `json:"isWinning"` 32 } 33 34 type VoteWithBalance struct { 35 // Extend Vote 36 Vote 37 // Balance 38 BlockHeight *uint64 `json:"blockHeight" pg:"block_height"` 39 Balance *uint64 `json:"balance"` 40 PrimaryAccountBalance *uint64 `json:"primaryAccountBalance"` 41 SecondaryAccountBalance *uint64 `json:"secondaryAccountBalance"` 42 StakingBalance *uint64 `json:"stakingBalance"` 43 Weight *float64 `json:"weight"` 44 45 NFTs []*NFT 46 } 47 48 type NFT struct { 49 ID interface{} `json:"id"` 50 Contract_addr string `json:"contract_addr"` 51 Created_at time.Time `json:"created_at"` 52 Float_event_id uint64 `json:"event_id,omitempty"` 53 } 54 55 type VotingStreak struct { 56 Proposal_id uint64 57 Addr string 58 Is_cancelled bool 59 } 60 61 const ( 62 timestampExpiry = 60 63 defaultStreakLength = 3 64 ) 65 66 const ( 67 EarlyVote string = "earlyVote" 68 Streak = "streak" 69 WinningVote = "winningVote" 70 ) 71 72 /////////// 73 // Votes // 74 /////////// 75 76 func GetVotesForAddress( 77 db *s.Database, 78 address string, 79 proposalIds *[]int, 80 pageParams shared.PageParams, 81 ) ([]*VoteWithBalance, int, error) { 82 var votes []*VoteWithBalance 83 var err error 84 85 sql := `select v.*, 86 b.primary_account_balance, 87 b.secondary_account_balance, 88 b.staking_balance 89 from votes v 90 left join balances b on b.addr = v.addr 91 WHERE v.addr = $3` 92 93 // Conditionally add proposal_id condition 94 if len(*proposalIds) > 0 { 95 sql = sql + " AND proposal_id = ANY($4)" 96 sql = sql + "LIMIT $1 OFFSET $2 " 97 err = pgxscan.Select(db.Context, db.Conn, &votes, 98 sql, pageParams.Count, pageParams.Start, address, *proposalIds) 99 } else { 100 sql = sql + "LIMIT $1 OFFSET $2 " 101 err = pgxscan.Select(db.Context, db.Conn, &votes, 102 sql, pageParams.Count, pageParams.Start, address) 103 } 104 105 if err != nil && err.Error() != pgx.ErrNoRows.Error() { 106 return nil, 0, err 107 } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { 108 return []*VoteWithBalance{}, 0, nil 109 } 110 111 // Get total number of votes on proposal 112 var totalRecords int 113 countSql := ` 114 SELECT COUNT(*) FROM votes WHERE addr = $1 and proposal_id = ANY($2) 115 ` 116 _ = db.Conn.QueryRow(db.Context, countSql, address, *proposalIds).Scan(&totalRecords) 117 return votes, totalRecords, nil 118 } 119 120 func GetAllVotesForProposal(db *s.Database, proposalId int, strategy string) ([]*VoteWithBalance, error) { 121 var votes []*VoteWithBalance 122 123 //return all balances, strategy will do rest of the work 124 sql := `select v.*, 125 b.primary_account_balance, 126 b.secondary_account_balance, 127 b.staking_balance, 128 COALESCE(p.block_height, 0) as block_height 129 from votes v 130 join proposals p on p.id = $1 131 left join balances b on b.addr = v.addr 132 and p.block_height = b.block_height 133 where proposal_id = $1 134 ` 135 err := pgxscan.Select(db.Context, db.Conn, &votes, sql, proposalId) 136 if err != nil && err.Error() != pgx.ErrNoRows.Error() { 137 return nil, err 138 } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { 139 return []*VoteWithBalance{}, nil 140 } 141 142 if IsNFTStrategy(strategy) { 143 votesWithNFTs, err := getUsersNFTs(db, votes) 144 if err != nil { 145 return nil, err 146 } 147 return votesWithNFTs, nil 148 } 149 150 return votes, nil 151 } 152 153 func GetVotesForProposal( 154 db *s.Database, 155 proposalId int, 156 strategy string, 157 pageParams shared.PageParams, 158 ) ([]*VoteWithBalance, int, error) { 159 var votes []*VoteWithBalance 160 var orderBySql string 161 162 if pageParams.Order == "desc" { 163 orderBySql = "ORDER BY b.created_at DESC" 164 } else { 165 orderBySql = "ORDER BY b.created_at ASC" 166 } 167 168 //return all balances, strategy will do rest of the work 169 sql := `select v.*, p.block_height, 170 b.primary_account_balance, 171 b.secondary_account_balance, 172 b.staking_balance 173 from votes v 174 join proposals p on p.id = v.proposal_id 175 left join balances b on b.addr = v.addr 176 and p.block_height = b.block_height 177 where v.proposal_id = $3` 178 179 sql = sql + " " + orderBySql 180 sql = sql + " LIMIT $1 OFFSET $2" 181 182 err := pgxscan.Select( 183 db.Context, 184 db.Conn, 185 &votes, 186 sql, 187 pageParams.Count, 188 pageParams.Start, 189 proposalId, 190 ) 191 192 if err != nil && err.Error() != pgx.ErrNoRows.Error() { 193 log.Error().Err(err).Msg("Error querying votes for proposal") 194 return nil, 0, err 195 } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { 196 return []*VoteWithBalance{}, 0, nil 197 } 198 199 if IsNFTStrategy(strategy) { 200 votes, err = getUsersNFTs(db, votes) 201 if err != nil { 202 log.Error().Err(err).Msg("Error getting user NFTs") 203 return nil, 0, err 204 } 205 } 206 207 // Get total number of votes on proposal 208 var totalRecords int 209 countSql := `SELECT COUNT(*) FROM votes WHERE proposal_id = $1` 210 _ = db.Conn.QueryRow(db.Context, countSql, proposalId).Scan(&totalRecords) 211 return votes, totalRecords, nil 212 } 213 214 func (v *Vote) GetVote(db *s.Database) error { 215 return pgxscan.Get(db.Context, db.Conn, v, 216 `SELECT * from votes 217 WHERE proposal_id = $1 AND addr = $2`, 218 v.Proposal_id, v.Addr) 219 } 220 221 func (vb *VoteWithBalance) GetVote(db *s.Database) error { 222 err := pgxscan.Get(db.Context, db.Conn, vb, 223 `select v.*, 224 b.primary_account_balance, 225 b.secondary_account_balance, 226 b.staking_balance 227 from votes v 228 left join balances b on b.addr = v.addr 229 WHERE proposal_id = $1 AND v.addr = $2`, 230 vb.Proposal_id, vb.Addr) 231 232 if err != nil { 233 return err 234 } 235 236 nftIds, err := GetUserNFTs(db, vb) 237 vb.NFTs = nftIds 238 return err 239 } 240 241 func (v *Vote) GetVoteById(db *s.Database) error { 242 return pgxscan.Get(db.Context, db.Conn, v, 243 `SELECT * from votes 244 WHERE id = $1`, 245 v.ID) 246 } 247 248 func (v *Vote) CreateVote(db *s.Database) error { 249 var defaultEarlyVoteLength = 2 // in hours 250 251 err := createVote(db, v) 252 if err != nil { 253 return err 254 } 255 256 proposal, err := getProposal(db, v.Proposal_id) 257 if err != nil { 258 return err 259 } 260 261 isEarlyVote := v.Created_at.Before(proposal.Start_time.Add(time.Hour * time.Duration(defaultEarlyVoteLength))) 262 263 if isEarlyVote { 264 err = AddEarlyVoteAchievement(db, v) 265 if err != nil { 266 return err 267 } 268 } 269 270 return nil 271 } 272 273 func ValidateVoteMessage(message string, proposal Proposal) error { 274 log.Info().Msgf("validating message: %s", message) 275 vars := strings.Split(message, ":") 276 277 // check proposal choices to see if choice is valid 278 encodedChoice := vars[1] 279 choiceBytes, err := hex.DecodeString(encodedChoice) 280 281 if err != nil { 282 return errors.New("couldnt decode choice in message from hex string") 283 } 284 285 validChoice := false 286 for _, choice := range proposal.Choices { 287 if choice.Choice_text == string(choiceBytes) { 288 validChoice = true 289 break 290 } 291 } 292 if !validChoice { 293 return errors.New("invalid choice for proposal") 294 } 295 296 // check timestamp and ensure no longer than 60 seconds has passed 297 timestamp, _ := strconv.ParseInt(vars[2], 10, 64) 298 uxTime := time.Unix(timestamp/1000, (timestamp%1000)*1000*1000) 299 diff := time.Now().UTC().Sub(uxTime).Seconds() 300 if diff > timestampExpiry { 301 return errors.New("timestamp on request has expired") 302 } 303 304 return nil 305 } 306 307 func (v *Vote) ValidateChoice(proposal Proposal) error { 308 validChoice := false 309 for _, choice := range proposal.Choices { 310 if choice.Choice_text == v.Choice { 311 validChoice = true 312 break 313 } 314 } 315 if !validChoice { 316 return errors.New("invalid choice for proposal") 317 } 318 return nil 319 } 320 321 func getUsersNFTs(db *s.Database, votes []*VoteWithBalance) ([]*VoteWithBalance, error) { 322 for _, vote := range votes { 323 nftIds, err := GetUserNFTs(db, vote) 324 if err != nil { 325 return nil, err 326 } 327 vote.NFTs = nftIds 328 } 329 330 return votes, nil 331 } 332 333 func GetUserNFTs(db *s.Database, vote *VoteWithBalance) ([]*NFT, error) { 334 var ids []*NFT 335 sql := `select id from nfts 336 where proposal_id = $1 and owner_addr = $2 337 ` 338 339 err := pgxscan.Select(db.Context, db.Conn, &ids, sql, vote.Proposal_id, vote.Addr) 340 if err != nil && err.Error() != pgx.ErrNoRows.Error() { 341 return nil, err 342 } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { 343 return []*NFT{}, nil 344 } 345 346 return ids, nil 347 } 348 349 func CreateUserNFTRecord(db *s.Database, v *VoteWithBalance) error { 350 for _, nft := range v.NFTs { 351 _, err := db.Conn.Exec(db.Context, 352 ` 353 INSERT INTO nfts(uuid, proposal_id, owner_addr, id) 354 VALUES($1, $2, $3, $4) 355 `, uuid.New(), v.Proposal_id, v.Addr, nft.ID) 356 if err != nil { 357 return err 358 } 359 } 360 361 return nil 362 } 363 364 func DoesNFTExist(db *s.Database, v *VoteWithBalance) (bool, error) { 365 for _, nft := range v.NFTs { 366 var nftId int 367 sql := `select id from nfts 368 where proposal_id = $1 and id = $2 369 ` 370 err := pgxscan.Get(db.Context, db.Conn, &nftId, sql, v.Proposal_id, nft.ID) 371 if err != nil && err.Error() != pgx.ErrNoRows.Error() { 372 return false, err 373 } else if err != nil && err.Error() == pgx.ErrNoRows.Error() { 374 return false, nil 375 } 376 } 377 378 return true, nil 379 } 380 381 func createVote(db *s.Database, v *Vote) error { 382 // Create Vote 383 err := db.Conn.QueryRow(db.Context, 384 ` 385 INSERT INTO votes(proposal_id, addr, choice, composite_signatures, cid, message) 386 VALUES($1, $2, $3, $4, $5, $6) 387 RETURNING id, created_at 388 `, v.Proposal_id, v.Addr, v.Choice, v.Composite_signatures, v.Cid, v.Message).Scan(&v.ID, &v.Created_at) 389 390 return err 391 } 392 393 func getProposal(db *s.Database, proposalId int) (Proposal, error) { 394 var proposal Proposal 395 err := pgxscan.Get(db.Context, db.Conn, &proposal, 396 `SELECT start_time, community_id from proposals 397 WHERE id = $1`, 398 proposalId) 399 400 return proposal, err 401 } 402 403 func AddEarlyVoteAchievement(db *s.Database, v *Vote) error { 404 _, err := db.Conn.Exec(db.Context, `UPDATE votes SET is_early = 'true' WHERE id = $1`, v.ID) 405 406 if err != nil { 407 return err 408 } 409 410 return nil 411 } 412 413 func AddWinningVoteAchievement(db *s.Database, votes []*VoteWithBalance, p ProposalResults) error { 414 maxVotes := 0 415 winningChoice := "" 416 for k, v := range p.Results { 417 if v > maxVotes { 418 maxVotes = v 419 winningChoice = k 420 } 421 } 422 for _, v := range votes { 423 if v.Choice == winningChoice { 424 _, err := db.Conn.Exec(db.Context, `UPDATE votes SET is_winning = 'true' WHERE id = $1`, v.ID) 425 if err != nil { 426 return err 427 } 428 } 429 } 430 431 _, err := db.Conn.Exec(db.Context, `UPDATE proposals SET achievements_done = 'true' WHERE id = $1`, p.Proposal_id) 432 if err != nil { 433 return err 434 } 435 436 return nil 437 } 438 439 func getStreakAchievement(db *s.Database, addr string, communityId int) (int, error) { 440 streaks := 0 441 votes, err := getUserVotes(db, addr, communityId) 442 if err != nil { 443 return 0, err 444 } 445 446 if len(votes) >= defaultStreakLength { 447 var proposals []uint64 448 for i, vote := range votes { 449 // check if user voted on non-cancelled proposal 450 if vote.Addr != "" && !vote.Is_cancelled { 451 proposals = append(proposals, vote.Proposal_id) 452 // check if vote is last in a streak 453 if len(proposals) >= defaultStreakLength && (i == len(votes)-1 || (i < len(votes)-1 && votes[i+1].Addr == "")) { 454 streaks = streaks + 1 455 456 // reset proposals to check for other streaks 457 proposals = nil 458 } 459 } else { 460 // user did not vote on proposal, reset to look for new streak 461 proposals = nil 462 } 463 } 464 } 465 466 return streaks, nil 467 } 468 469 func getUserVotes(db *s.Database, addr string, communityId int) ([]VotingStreak, error) { 470 // Determine if this vote is part of a streak 471 // Proposals with the user address count as a vote for that proposal 472 // NULL means the user did not vote 473 sql := fmt.Sprintf(` 474 SELECT 475 p.id as proposal_id, 476 COALESCE(v.is_cancelled, 'false') as is_cancelled, 477 COALESCE(v.addr, '') as addr 478 FROM proposals p 479 LEFT OUTER JOIN ( 480 SELECT * FROM votes where addr = '%s' 481 ) v ON v.proposal_id = p.id 482 where p.community_id = $1 483 ORDER BY start_time ASC 484 `, addr) 485 var votingStreak []VotingStreak 486 err := pgxscan.Select(db.Context, db.Conn, &votingStreak, sql, communityId) 487 return votingStreak, err 488 }