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  }