diff --git a/api/v2.go b/api/v2.go index 727b0f76..7df4e1a6 100644 --- a/api/v2.go +++ b/api/v2.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022, Chain4Travel AG. All rights reserved. +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. // // This file is a derived work, based on ava-labs code whose // original notices appear below. @@ -153,7 +153,9 @@ func AddV2Routes(ctx *Context, router *web.Router, path string, indexBytes []byt Get("/cacheassetaggregates", (*V2Context).CacheAssetAggregates). Get("/cacheaggregates/:id", (*V2Context).CacheAggregates). Get("/multisigalias/:owners", (*V2Context).GetMultisigAlias). - Post("/rewards", (*V2Context).GetRewardPost) + Post("/rewards", (*V2Context).GetRewardPost). + Get("/proposals", (*V2Context).ListDACProposals). + Get("/proposals/:id", (*V2Context).GetDACProposalWithVotes) } // AVAX @@ -1145,3 +1147,46 @@ func (c *V2Context) CacheAggregates(w web.ResponseWriter, r *web.Request) { WriteJSON(w, b) } + +func (c *V2Context) ListDACProposals(w web.ResponseWriter, r *web.Request) { + collectors := utils.NewCollectors( + utils.NewCounterObserveMillisCollect(MetricMillis), + utils.NewCounterIncCollect(MetricCount), + ) + defer func() { + _ = collectors.Collect() + }() + + params := ¶ms.ListDACProposalsParams{} + if err := params.ForValues(c.version, r.URL.Query()); err != nil { + c.WriteErr(w, 400, err) + return + } + + c.WriteCacheable(w, caching.Cacheable{ + TTL: 5 * time.Second, + Key: c.cacheKeyForParams("list_dac_proposals", params), + CacheableFn: func(ctx context.Context) (interface{}, error) { + return c.avaxReader.ListDACProposals(ctx, params) + }, + }) +} + +func (c *V2Context) GetDACProposalWithVotes(w web.ResponseWriter, r *web.Request) { + collectors := utils.NewCollectors( + utils.NewCounterObserveMillisCollect(MetricMillis), + utils.NewCounterIncCollect(MetricCount), + ) + defer func() { + _ = collectors.Collect() + }() + + proposalID := r.PathParams["id"] + c.WriteCacheable(w, caching.Cacheable{ + TTL: 5 * time.Second, + Key: c.cacheKeyForID("get_dac_proposal", proposalID), + CacheableFn: func(ctx context.Context) (interface{}, error) { + return c.avaxReader.GetDACProposalWithVotes(ctx, proposalID) + }, + }) +} diff --git a/db/dbmodel.go b/db/dbmodel.go index 68170c95..6ea88fee 100644 --- a/db/dbmodel.go +++ b/db/dbmodel.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022, Chain4Travel AG. All rights reserved. +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. // // This file is a derived work, based on ava-labs code whose // original notices appear below. @@ -21,6 +21,7 @@ import ( "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/hashing" "github.com/chain4travel/magellan/models" + "github.com/chain4travel/magellan/services/indexes/params" "github.com/chain4travel/magellan/utils" "github.com/gocraft/dbr/v2" ) @@ -56,6 +57,8 @@ const ( TableMultisigAliases = "multisig_aliases" TableReward = "reward" TableRewardOwner = "reward_owner" + TableDACProposals = "dac_proposals" + TableDACVotes = "dac_votes" ) type Persist interface { @@ -64,6 +67,11 @@ type Persist interface { dbr.SessionRunner, *Transactions, ) (*Transactions, error) + QueryMultipleTransactions( + context.Context, + dbr.SessionRunner, + []string, + ) (*[]Transactions, error) InsertTransactions( context.Context, dbr.SessionRunner, @@ -440,6 +448,62 @@ type Persist interface { dbr.SessionRunner, *Reward, ) error + + // DAC Proposals + InsertDACProposal( + ctx context.Context, + session dbr.SessionRunner, + proposal *DACProposal, + ) error + UpdateDACProposal( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, + updatedProposal []byte, + ) error + FinishDACProposals( + ctx context.Context, + session dbr.SessionRunner, + proposalIDs []string, + finishedAt time.Time, + proposalStatus models.ProposalStatus, + ) error + FinishDACProposalWithOutcome( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, + finishedAt time.Time, + proposalStatus models.ProposalStatus, + outcome []byte, + ) error + GetDACProposals( + ctx context.Context, + session dbr.SessionRunner, + proposalIDs []string, + ) ([]DACProposal, error) + QueryDACProposals( + ctx context.Context, + session dbr.SessionRunner, + params *params.ListDACProposalsParams, + ) ([]DACProposal, error) + + // DAC Votes + InsertDACVote( + ctx context.Context, + session dbr.SessionRunner, + vote *DACVote, + ) error + QueryDACProposalVotes( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, + ) ([]DACVote, error) + + GetTxHeight( + ctx context.Context, + session dbr.SessionRunner, + txID string, + ) (uint64, error) } type persist struct{} @@ -500,6 +564,29 @@ func (p *persist) QueryTransactions( return v, err } +func (p *persist) QueryMultipleTransactions( + ctx context.Context, + sess dbr.SessionRunner, + txIDs []string, +) (*[]Transactions, error) { + v := &[]Transactions{} + err := sess.Select( + "id", + "chain_id", + "type", + "memo", + "created_at", + "canonical_serialization", + "txfee", + "genesis", + "network_id", + "status", + ).From(TableTransactions). + Where("id IN ?", txIDs). + LoadOneContext(ctx, v) + return v, err +} + func (p *persist) InsertTransactions( ctx context.Context, sess dbr.SessionRunner, @@ -2390,3 +2477,246 @@ func (p *persist) DeactivateReward(ctx context.Context, session dbr.SessionRunne return p.InsertReward(ctx, session, v) } + +type DACProposal struct { + ID string `db:"id"` // proposal id, also addProposalTx id + ProposerAddr string `db:"proposer_addr"` // address which authorized proposal + StartTime time.Time `db:"start_time"` // time when proposal will become votable + EndTime time.Time `db:"end_time"` // time when proposal will become non-votable and will be executed if its successful + Type models.ProposalType `db:"type"` // proposal type + IsAdminProposal bool `db:"admin_proposal"` // true if it is admin proposal + Options []byte `db:"options"` // proposal votable options + Data []byte `db:"data"` // arbitrary proposal data + SerializedBytes []byte `db:"serialized_bytes"` // serialized dac.ProposalState + Memo []byte `db:"memo"` // addProposalTx memo + FinishedAt *time.Time `db:"finished_at"` // time when proposal was finished + Outcome []byte `db:"outcome"` // outcome of successful proposal, usually is one or multiple options indexes + Status models.ProposalStatus `db:"status"` // current status of proposal +} + +func (p *persist) InsertDACProposal(ctx context.Context, session dbr.SessionRunner, proposal *DACProposal) error { + _, err := session. + InsertInto(TableDACProposals). + Pair("id", proposal.ID). + Pair("proposer_addr", proposal.ProposerAddr). + Pair("start_time", proposal.StartTime). + Pair("end_time", proposal.EndTime). + Pair("type", proposal.Type). + Pair("admin_proposal", proposal.IsAdminProposal). + Pair("serialized_bytes", proposal.SerializedBytes). + Pair("options", proposal.Options). + Pair("data", proposal.Data). + Pair("status", proposal.Status). + ExecContext(ctx) + if err != nil { + return EventErr(TableDACProposals, false, err) + } + return nil +} + +func (p *persist) UpdateDACProposal( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, + updatedProposal []byte, +) error { + _, err := session. + Update(TableDACProposals). + Set("serialized_bytes", updatedProposal). + Where("id=?", proposalID). + ExecContext(ctx) + if err != nil { + return EventErr(TableDACProposals, true, err) + } + return nil +} + +func (p *persist) FinishDACProposals( + ctx context.Context, + session dbr.SessionRunner, + proposalIDs []string, + finishedAt time.Time, + proposalStatus models.ProposalStatus, +) error { + result, err := session. + Update(TableDACProposals). + Set("status", proposalStatus). + Set("finished_at", finishedAt). + Where("id IN ?", proposalIDs). + ExecContext(ctx) + if err != nil { + return EventErr(TableDACProposals, true, err) + } + + if rowsAffected, err := result.RowsAffected(); err != nil { + return EventErr(TableDACProposals, true, err) + } else if rowsAffected == 0 { + return EventErr(TableDACProposals, true, dbr.ErrNotFound) + } + + return nil +} + +func (p *persist) FinishDACProposalWithOutcome( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, + finishedAt time.Time, + proposalStatus models.ProposalStatus, + outcome []byte, +) error { + result, err := session. + Update(TableDACProposals). + Set("status", proposalStatus). + Set("finished_at", finishedAt). + Set("outcome", outcome). + Where("id = ?", proposalID). + ExecContext(ctx) + if err != nil { + return EventErr(TableDACProposals, true, err) + } + + if rowsAffected, err := result.RowsAffected(); err != nil { + return EventErr(TableDACProposals, true, err) + } else if rowsAffected == 0 { + return EventErr(TableDACProposals, true, dbr.ErrNotFound) + } + + return nil +} + +func (p *persist) GetDACProposals( + ctx context.Context, + session dbr.SessionRunner, + proposalIDs []string, +) ([]DACProposal, error) { + v := &[]DACProposal{} + query := session.Select( + "P.id", + "P.proposer_addr", + "P.start_time", + "P.end_time", + "P.type", + "P.admin_proposal", + "P.serialized_bytes", + "P.options", + "P.data", + "T.memo", + "P.outcome", + "P.status", + ).From(dbr.I(TableDACProposals).As("P")). + Join(dbr.I(TableTransactions).As("T"), "T.id=P.id"). + Where("P.id in ?", proposalIDs) + + _, err := query.LoadContext(ctx, v) + return *v, err +} + +func (p *persist) QueryDACProposals( + ctx context.Context, + session dbr.SessionRunner, + params *params.ListDACProposalsParams, +) ([]DACProposal, error) { + v := &[]DACProposal{} + query := session.Select( + "P.id", + "P.proposer_addr", + "P.start_time", + "P.end_time", + "P.type", + "P.admin_proposal", + "P.serialized_bytes", + "P.options", + "P.data", + "T.memo", + "P.finished_at", + "P.outcome", + "P.status", + ).From(dbr.I(TableDACProposals).As("P")). + Join(dbr.I(TableTransactions).As("T"), "T.id=P.id") + + if params.Offset > 0 { + query.Offset(uint64(params.Offset)) + } + if params.Limit > 0 { + query.Limit(uint64(params.Limit)) + } + if params.MinStartTimeProvided { + query.Where("P.start_time >= ?", params.MinStartTime) + } + if params.MaxStartTimeProvided { + query.Where("P.start_time <= ?", params.MaxStartTime) + } + if params.EndTimeProvided { + query.Where("P.end_time >= ?", params.EndTime) + } + if params.ProposalType != nil { + query.Where("P.type = ?", params.ProposalType) + } + if params.ProposalStatus != nil { + if *params.ProposalStatus == models.ProposalStatusCompleted { + query.Where("P.status IN ?", []models.ProposalStatus{models.ProposalStatusSuccess, models.ProposalStatusFailed}) + } else { + query.Where("P.status = ?", params.ProposalStatus) + } + } + + _, err := query.LoadContext(ctx, v) + return *v, err +} + +type DACVote struct { + VoteTxID string `db:"id"` // addVoteTx id + VoterAddr string `db:"voter_addr"` // address which authorized this vote + VotedAt time.Time `db:"voted_at"` // timestamp when this vote happened + ProposalID string `db:"proposal_id"` // id of proposal that was voted on + VotedOptions []byte `db:"voted_options"` // proposal options that was voted by this vote, usually one or multiple option indexes +} + +func (p *persist) InsertDACVote(ctx context.Context, session dbr.SessionRunner, vote *DACVote) error { + _, err := session. + InsertInto(TableDACVotes). + Pair("id", vote.VoteTxID). + Pair("voter_addr", vote.VoterAddr). + Pair("voted_at", vote.VotedAt). + Pair("proposal_id", vote.ProposalID). + Pair("voted_options", vote.VotedOptions). + ExecContext(ctx) + if err != nil { + return EventErr(TableDACVotes, false, err) + } + return nil +} + +func (p *persist) QueryDACProposalVotes( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, +) ([]DACVote, error) { + v := &[]DACVote{} + _, err := session.Select( + "id", + "voter_addr", + "voted_at", + "voted_options", + ).From(TableDACVotes). + Where("proposal_id = ?", proposalID). + LoadContext(ctx, v) + return *v, err +} + +func (p *persist) GetTxHeight( + ctx context.Context, + session dbr.SessionRunner, + txID string, +) (uint64, error) { + v := uint64(0) + err := session.SelectBySql(` + SELECT PB.height + FROM magellan.pvm_blocks AS PB + WHERE PB.id IN ( + SELECT TB.tx_block_id + FROM magellan.transactions_block AS TB + WHERE TB.id = ?);`, txID).LoadOneContext(ctx, &v) + return v, err +} diff --git a/db/dbmodel_mock.go b/db/dbmodel_mock.go index e70dcd8e..814bd559 100644 --- a/db/dbmodel_mock.go +++ b/db/dbmodel_mock.go @@ -1,3 +1,13 @@ +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. +// +// This file is a derived work, based on ava-labs code whose +// original notices appear below. +// +// It is distributed under the same license conditions as the +// original code from which it is derived. +// +// Much love to the original authors for their work. +// ********************************************************** // (c) 2021, Ava Labs, Inc. All rights reserved. // See the file LICENSE for licensing terms. @@ -6,7 +16,10 @@ package db import ( "context" "sync" + "time" + "github.com/chain4travel/magellan/models" + "github.com/chain4travel/magellan/services/indexes/params" "github.com/gocraft/dbr/v2" ) @@ -43,6 +56,8 @@ type MockPersist struct { MultisigAlias map[string]*MultisigAlias RewardOwner map[string]*RewardOwner Reward map[string]*Reward + DACProposals map[string]*DACProposal + DACVotes map[string]*DACVote } func NewPersistMock() *MockPersist { @@ -75,6 +90,8 @@ func NewPersistMock() *MockPersist { KeyValueStore: make(map[string]*KeyValueStore), NodeIndex: make(map[string]*NodeIndex), MultisigAlias: make(map[string]*MultisigAlias), + DACProposals: make(map[string]*DACProposal), + DACVotes: make(map[string]*DACVote), } } @@ -87,6 +104,18 @@ func (m *MockPersist) QueryTransactions(ctx context.Context, runner dbr.SessionR return nil, nil } +func (m *MockPersist) QueryMultipleTransactions(ctx context.Context, runner dbr.SessionRunner, txIDs []string) (*[]Transactions, error) { + m.lock.RLock() + defer m.lock.RUnlock() + var txs []Transactions + for _, txID := range txIDs { + if tx, ok := m.Transactions[txID]; ok { + txs = append(txs, *tx) + } + } + return &txs, nil +} + func (m *MockPersist) InsertTransactions(ctx context.Context, runner dbr.SessionRunner, v *Transactions, b bool) error { m.lock.Lock() defer m.lock.Unlock() @@ -665,3 +694,123 @@ func (m *MockPersist) InsertReward(ctx context.Context, session dbr.SessionRunne m.Reward[nv.RewardOwnerHash] = nv return nil } + +func (m *MockPersist) InsertDACProposal(ctx context.Context, session dbr.SessionRunner, proposal *DACProposal) error { + m.lock.Lock() + defer m.lock.Unlock() + nv := &DACProposal{} + *nv = *proposal + m.DACProposals[proposal.ID] = nv + return nil +} + +func (m *MockPersist) UpdateDACProposal(ctx context.Context, session dbr.SessionRunner, proposalID string, updatedProposal []byte) error { + m.lock.Lock() + defer m.lock.Unlock() + proposal, ok := m.DACProposals[proposalID] + if !ok { + return nil + } + proposal.SerializedBytes = updatedProposal + return nil +} + +// QueryDACProposals is not deterministic about return result, cause of random mapping order +func (m *MockPersist) QueryDACProposals(ctx context.Context, session dbr.SessionRunner, params *params.ListDACProposalsParams) ([]DACProposal, error) { + m.lock.Lock() + defer m.lock.Unlock() + proposals := make([]DACProposal, 0, len(m.DACProposals)) + offset := 0 + if params.Offset != 0 { + offset = params.Offset + } + + for _, v := range m.DACProposals { + if len(proposals) == params.Limit { + break + } + + if offset > 0 { + offset-- + continue + } + + if (!params.StartTimeProvided || !params.StartTime.Before(v.StartTime)) && + (!params.EndTimeProvided || !params.EndTime.After(v.EndTime)) && + (params.ProposalType == nil || *params.ProposalType == v.Type) && + (params.ProposalStatus == nil || *params.ProposalStatus == v.Status) { + proposals = append(proposals, *v) + } + } + return proposals, nil +} + +func (m *MockPersist) FinishDACProposals(ctx context.Context, session dbr.SessionRunner, proposalIDs []string, finishedAt time.Time, proposalStatus models.ProposalStatus) error { + m.lock.Lock() + defer m.lock.Unlock() + for _, proposalID := range proposalIDs { + if proposal, ok := m.DACProposals[proposalID]; ok { + proposal.FinishedAt = &finishedAt + proposal.Status = proposalStatus + } + } + return nil +} + +func (m *MockPersist) FinishDACProposalWithOutcome(ctx context.Context, session dbr.SessionRunner, proposalID string, finishedAt time.Time, proposalStatus models.ProposalStatus, outcome []byte) error { + m.lock.Lock() + defer m.lock.Unlock() + proposal, ok := m.DACProposals[proposalID] + if !ok { + return nil + } + proposal.FinishedAt = &finishedAt + proposal.Status = proposalStatus + proposal.Outcome = outcome + return nil +} + +func (m *MockPersist) GetDACProposals(ctx context.Context, session dbr.SessionRunner, proposalIDs []string) ([]DACProposal, error) { + proposals := make([]DACProposal, 0, len(proposalIDs)) + for i := range proposalIDs { + if proposal, ok := m.DACProposals[proposalIDs[i]]; ok { + proposals = append(proposals, *proposal) + } + } + return proposals, nil +} + +func (m *MockPersist) InsertDACVote(ctx context.Context, session dbr.SessionRunner, vote *DACVote) error { + m.lock.Lock() + defer m.lock.Unlock() + nv := &DACVote{} + *nv = *vote + m.DACVotes[vote.VoterAddr] = nv + return nil +} + +func (m *MockPersist) QueryDACProposalVotes(ctx context.Context, session dbr.SessionRunner, voterAddr string) ([]DACVote, error) { + m.lock.Lock() + defer m.lock.Unlock() + votes := make([]DACVote, 0, len(m.DACVotes)) + for _, v := range m.DACVotes { + if v.VoterAddr == voterAddr { + votes = append(votes, *v) + } + } + return votes, nil +} + +func (m *MockPersist) GetTxHeight(ctx context.Context, session dbr.SessionRunner, txID string) (uint64, error) { + m.lock.Lock() + defer m.lock.Unlock() + txBlock, ok := m.TransactionsBlock[txID] + if !ok { + return 0, dbr.ErrNotFound + } + pvmBlock, ok := m.PvmBlocks[txBlock.TxBlockID] + if !ok { + return 0, dbr.ErrNotFound + } + return pvmBlock.Height, nil +} diff --git a/db/dbmodel_test.go b/db/dbmodel_test.go index 51f898d1..2a3d1b3c 100644 --- a/db/dbmodel_test.go +++ b/db/dbmodel_test.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022, Chain4Travel AG. All rights reserved. +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. // // This file is a derived work, based on ava-labs code whose // original notices appear below. @@ -20,7 +20,9 @@ import ( "time" "github.com/chain4travel/magellan/models" + "github.com/chain4travel/magellan/services/indexes/params" "github.com/gocraft/dbr/v2" + "github.com/stretchr/testify/require" ) const ( @@ -1384,3 +1386,540 @@ func TestInsertMultisigAlias(t *testing.T) { t.Fatal("delete fail", err) } } + +func TestInsertDACProposal(t *testing.T) { + p := NewPersist() + ctx := context.Background() + stream := &dbr.NullEventReceiver{} + rawDBConn, err := dbr.Open(TestDB, TestDSN, stream) + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableTransactions).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACVotes).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACProposals).Exec() + require.NoError(t, err) + now := time.Now().UTC().Truncate(time.Second) + + baseTxMemo := []byte("serialized base tx memo") + proposal := DACProposal{ + ID: "1111111111111111111111111111111111111111111111111", + ProposerAddr: "222222222222222222222222222222222", + StartTime: now.Add(1 * time.Second), + EndTime: now.Add(10 * time.Second), + Type: models.ProposalTypeBaseFee, + IsAdminProposal: false, + SerializedBytes: []byte("serialized proposal bytes"), + Options: []byte("serialized proposal options"), + Data: []byte("serialized proposal data"), + Memo: []byte("should be ignored"), // should be ingored and not inserted + Outcome: []byte("serialized proposal outcome"), // should be ingored and not inserted + Status: models.ProposalStatusInProgress, + } + + require.NoError(t, p.InsertTransactions(ctx, rawDBConn.NewSession(stream), &Transactions{ + ID: proposal.ID, + Memo: baseTxMemo, + CreatedAt: now, + }, false)) + require.NoError(t, p.InsertDACProposal(ctx, rawDBConn.NewSession(stream), &proposal)) + + expectedProposal := proposal + expectedProposal.Memo = baseTxMemo + expectedProposal.Outcome = nil + resultProposals, err := p.QueryDACProposals(ctx, rawDBConn.NewSession(stream), ¶ms.ListDACProposalsParams{}) + require.NoError(t, err) + require.Equal(t, []DACProposal{expectedProposal}, resultProposals) +} + +func TestUpdateDACProposal(t *testing.T) { + p := NewPersist() + ctx := context.Background() + stream := &dbr.NullEventReceiver{} + rawDBConn, err := dbr.Open(TestDB, TestDSN, stream) + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableTransactions).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACVotes).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACProposals).Exec() + require.NoError(t, err) + now := time.Now().UTC().Truncate(time.Second) + + proposal := DACProposal{ + ID: "1111111111111111111111111111111111111111111111111", + ProposerAddr: "222222222222222222222222222222222", + StartTime: now.Add(1 * time.Second), + EndTime: now.Add(10 * time.Second), + Type: models.ProposalTypeBaseFee, + IsAdminProposal: false, + SerializedBytes: []byte("serialized proposal bytes"), + Options: []byte("serialized proposal options"), + Data: []byte("serialized proposal data"), + Memo: []byte("serialized base tx memo"), + Status: models.ProposalStatusInProgress, + } + require.NoError(t, p.InsertTransactions(ctx, rawDBConn.NewSession(stream), &Transactions{ + ID: proposal.ID, + Memo: proposal.Memo, + CreatedAt: now, + }, false)) + require.NoError(t, p.InsertDACProposal(ctx, rawDBConn.NewSession(stream), &proposal)) + + updatedProposalBytes := []byte("updated serialized proposal bytes") + require.NoError(t, p.UpdateDACProposal( + ctx, + rawDBConn.NewSession(stream), + proposal.ID, + updatedProposalBytes, + )) + + expectedProposal := proposal + expectedProposal.SerializedBytes = updatedProposalBytes + resultProposals, err := p.QueryDACProposals(ctx, rawDBConn.NewSession(stream), ¶ms.ListDACProposalsParams{}) + require.NoError(t, err) + require.Equal(t, []DACProposal{expectedProposal}, resultProposals) +} + +func TestFinishDACProposals(t *testing.T) { + p := NewPersist() + ctx := context.Background() + stream := &dbr.NullEventReceiver{} + rawDBConn, err := dbr.Open(TestDB, TestDSN, stream) + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableTransactions).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACVotes).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACProposals).Exec() + require.NoError(t, err) + now := time.Now().UTC().Truncate(time.Second) + + proposals := []*DACProposal{ + { // 0 + ID: "1111111111111111111111111111111111111111111111111", + ProposerAddr: "111111111111111111111111111111111", + StartTime: now.Add(1 * time.Second), + EndTime: now.Add(10 * time.Second), + Type: models.ProposalTypeBaseFee, + IsAdminProposal: false, + SerializedBytes: []byte("1 serialized proposal bytes 1"), + Options: []byte("1 serialized proposal options 1"), + Data: []byte("1 serialized proposal data 1"), + Memo: []byte("1 serialized proposal memo 1"), + Status: models.ProposalStatusInProgress, + }, + { // 1 + ID: "2222222222222222222222222222222222222222222222222", + ProposerAddr: "222222222222222222222222222222222", + StartTime: now.Add(1 * time.Second), + EndTime: now.Add(10 * time.Second), + Type: models.ProposalTypeBaseFee, + IsAdminProposal: false, + SerializedBytes: []byte("2 serialized proposal bytes 2"), + Options: []byte("2 serialized proposal options 2"), + Data: []byte("2 serialized proposal data 2"), + Memo: []byte("2 serialized proposal memo 2"), + Status: models.ProposalStatusInProgress, + }, + } + + for _, proposal := range proposals { + require.NoError(t, p.InsertTransactions(ctx, rawDBConn.NewSession(stream), &Transactions{ + ID: proposal.ID, + Memo: proposal.Memo, + CreatedAt: now, + }, false)) + require.NoError(t, p.InsertDACProposal(ctx, rawDBConn.NewSession(stream), proposal)) + } + + finishTime := now.Add(5 * time.Second) + + require.Error(t, p.FinishDACProposals( + ctx, + rawDBConn.NewSession(stream), + []string{"3333333333333333333333333333333333333333333333333"}, // non-existing + finishTime, + models.ProposalStatusFailed, + )) + + require.NoError(t, p.FinishDACProposals( + ctx, + rawDBConn.NewSession(stream), + []string{proposals[0].ID, proposals[1].ID}, + finishTime, + models.ProposalStatusFailed, + )) + + expectedProposal0 := *proposals[0] + expectedProposal0.Status = models.ProposalStatusFailed + expectedProposal0.FinishedAt = &finishTime + expectedProposal1 := *proposals[1] + expectedProposal1.Status = models.ProposalStatusFailed + expectedProposal1.FinishedAt = &finishTime + resultProposals, err := p.QueryDACProposals(ctx, rawDBConn.NewSession(stream), ¶ms.ListDACProposalsParams{}) + require.NoError(t, err) + require.Equal(t, []DACProposal{expectedProposal0, expectedProposal1}, resultProposals) +} + +func TestFinishDACProposalWithOutcome(t *testing.T) { + p := NewPersist() + ctx := context.Background() + stream := &dbr.NullEventReceiver{} + rawDBConn, err := dbr.Open(TestDB, TestDSN, stream) + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableTransactions).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACVotes).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACProposals).Exec() + require.NoError(t, err) + now := time.Now().UTC().Truncate(time.Second) + + proposal := DACProposal{ + ID: "1111111111111111111111111111111111111111111111111", + ProposerAddr: "222222222222222222222222222222222", + StartTime: now.Add(1 * time.Second), + EndTime: now.Add(10 * time.Second), + Type: models.ProposalTypeBaseFee, + IsAdminProposal: false, + SerializedBytes: []byte("serialized proposal bytes"), + Options: []byte("serialized proposal options"), + Data: []byte("serialized proposal data"), + Memo: []byte("serialized base tx memo"), + Status: models.ProposalStatusInProgress, + } + require.NoError(t, p.InsertTransactions(ctx, rawDBConn.NewSession(stream), &Transactions{ + ID: proposal.ID, + Memo: proposal.Memo, + CreatedAt: now, + }, false)) + require.NoError(t, p.InsertDACProposal(ctx, rawDBConn.NewSession(stream), &proposal)) + + finishTime := now.Add(5 * time.Second) + outcome := []byte{} + + require.Error(t, p.FinishDACProposalWithOutcome( + ctx, + rawDBConn.NewSession(stream), + "3333333333333333333333333333333333333333333333333", + finishTime, + models.ProposalStatusSuccess, + outcome, + )) + + require.NoError(t, p.FinishDACProposalWithOutcome( + ctx, + rawDBConn.NewSession(stream), + proposal.ID, + finishTime, + models.ProposalStatusSuccess, + outcome, + )) + + expectedProposal := proposal + expectedProposal.Status = models.ProposalStatusSuccess + expectedProposal.Outcome = outcome + expectedProposal.FinishedAt = &finishTime + resultProposals, err := p.QueryDACProposals(ctx, rawDBConn.NewSession(stream), ¶ms.ListDACProposalsParams{}) + require.NoError(t, err) + require.Equal(t, []DACProposal{expectedProposal}, resultProposals) +} + +func TestGetDACProposals(t *testing.T) { + p := NewPersist() + ctx := context.Background() + stream := &dbr.NullEventReceiver{} + rawDBConn, err := dbr.Open(TestDB, TestDSN, stream) + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableTransactions).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACVotes).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACProposals).Exec() + require.NoError(t, err) + now := time.Now().UTC().Truncate(time.Second) + + proposals := []*DACProposal{ + { // 0 + ID: "1111111111111111111111111111111111111111111111111", + ProposerAddr: "111111111111111111111111111111111", + StartTime: now.Add(100 * time.Second), + EndTime: now.Add(100 * time.Second).Add(time.Hour), + Type: models.ProposalTypeBaseFee, + IsAdminProposal: false, + SerializedBytes: []byte("1 serialized proposal bytes 1"), + Options: []byte("1 serialized proposal options 1"), + Data: []byte("1 serialized proposal data 1"), + Memo: []byte("1 serialized proposal memo 1"), + Status: models.ProposalStatusInProgress, + }, + { // 1 + ID: "2222222222222222222222222222222222222222222222222", + ProposerAddr: "222222222222222222222222222222222", + StartTime: now.Add(100 * time.Second), + EndTime: now.Add(100 * time.Second).Add(time.Hour), + Type: models.ProposalType(100), // different proposal type + IsAdminProposal: false, + SerializedBytes: []byte("2 serialized proposal bytes 2"), + Options: []byte("2 serialized proposal options 2"), + Data: []byte("2 serialized proposal data 2"), + Memo: []byte("2 serialized proposal memo 2"), + Status: models.ProposalStatusInProgress, + }, + { // 2 + ID: "3333333333333333333333333333333333333333333333333", + ProposerAddr: "333333333333333333333333333333333", + StartTime: now.Add(100 * time.Second), + EndTime: now.Add(100 * time.Second).Add(time.Hour), + Type: models.ProposalTypeBaseFee, + IsAdminProposal: false, + SerializedBytes: []byte("3 serialized proposal bytes 3"), + Options: []byte("3 serialized proposal options 3"), + Data: []byte("3 serialized proposal data 3"), + Memo: []byte("3 serialized proposal memo 3"), + Status: models.ProposalStatusInProgress, + }, + { // 3 + ID: "4444444444444444444444444444444444444444444444444", + ProposerAddr: "444444444444444444444444444444444", + StartTime: now.Add(100 * time.Second), + EndTime: now.Add(100 * time.Second).Add(time.Hour), + Type: models.ProposalTypeBaseFee, + IsAdminProposal: false, + SerializedBytes: []byte("4 serialized proposal bytes 4"), + Options: []byte("4 serialized proposal options 4"), + Data: []byte("4 serialized proposal data 4"), + Memo: []byte("4 serialized proposal memo 4"), + Status: models.ProposalStatusInProgress, + }, + } + + for _, proposal := range proposals { + require.NoError(t, p.InsertTransactions(ctx, rawDBConn.NewSession(stream), &Transactions{ + ID: proposal.ID, + Memo: proposal.Memo, + CreatedAt: now, + }, false)) + require.NoError(t, p.InsertDACProposal(ctx, rawDBConn.NewSession(stream), proposal)) + } + + resultProposals, err := p.GetDACProposals(ctx, rawDBConn.NewSession(stream), []string{proposals[1].ID, proposals[2].ID}) + require.NoError(t, err) + require.Equal(t, []DACProposal{*proposals[1], *proposals[2]}, resultProposals) +} + +func TestQueryDACProposals(t *testing.T) { + p := NewPersist() + ctx := context.Background() + stream := &dbr.NullEventReceiver{} + rawDBConn, err := dbr.Open(TestDB, TestDSN, stream) + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableTransactions).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACVotes).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACProposals).Exec() + require.NoError(t, err) + now := time.Now().UTC().Truncate(time.Second) + + baseFeeProposalType := models.ProposalTypeBaseFee + proposalStatusSuccess := models.ProposalStatusSuccess + queryParams := ¶ms.ListDACProposalsParams{ + ListParams: params.ListParams{ + Limit: 3, + Offset: 1, + }, + MinStartTime: now.Add(100 * time.Second), + MaxStartTime: now.Add(105 * time.Second), + MinStartTimeProvided: true, + MaxStartTimeProvided: true, + ProposalType: &baseFeeProposalType, + ProposalStatus: &proposalStatusSuccess, + } + + proposals := []*DACProposal{ + { // 0 + ID: "1111111111111111111111111111111111111111111111111", + ProposerAddr: "111111111111111111111111111111111", + StartTime: queryParams.MinStartTime, + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + IsAdminProposal: false, + SerializedBytes: []byte("1 serialized proposal bytes 1"), + Options: []byte("1 serialized proposal options 1"), + Data: []byte("1 serialized proposal data 1"), + Memo: []byte("1 serialized proposal memo 1"), + Status: models.ProposalStatusInProgress, // different proposal status + }, + { // 1 + ID: "2222222222222222222222222222222222222222222222222", + ProposerAddr: "222222222222222222222222222222222", + StartTime: queryParams.MinStartTime, + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: models.ProposalType(100), // different proposal type + IsAdminProposal: false, + SerializedBytes: []byte("2 serialized proposal bytes 2"), + Options: []byte("2 serialized proposal options 2"), + Data: []byte("2 serialized proposal data 2"), + Memo: []byte("2 serialized proposal memo 2"), + Status: *queryParams.ProposalStatus, + }, + { // 2 + ID: "3333333333333333333333333333333333333333333333333", + ProposerAddr: "333333333333333333333333333333333", + StartTime: queryParams.MinStartTime.Add(-time.Second), // starttime is before + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + IsAdminProposal: false, + SerializedBytes: []byte("3 serialized proposal bytes 3"), + Options: []byte("3 serialized proposal options 3"), + Data: []byte("3 serialized proposal data 3"), + Memo: []byte("3 serialized proposal memo 3"), + Status: *queryParams.ProposalStatus, + }, + { // 3 + ID: "4444444444444444444444444444444444444444444444444", + ProposerAddr: "444444444444444444444444444444444", + StartTime: queryParams.MaxStartTime.Add(time.Second), // starttime is after + EndTime: queryParams.MaxStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + IsAdminProposal: false, + SerializedBytes: []byte("4 serialized proposal bytes 4"), + Options: []byte("4 serialized proposal options 4"), + Data: []byte("4 serialized proposal data 4"), + Memo: []byte("4 serialized proposal memo 4"), + Status: *queryParams.ProposalStatus, + }, + { // 4 // cut by offset + ID: "5555555555555555555555555555555555555555555555555", + ProposerAddr: "555555555555555555555555555555555", + StartTime: queryParams.MinStartTime, + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + IsAdminProposal: false, + SerializedBytes: []byte("5 serialized proposal bytes 5"), + Options: []byte("5 serialized proposal options 5"), + Data: []byte("5 serialized proposal data 5"), + Memo: []byte("5 serialized proposal memo 5"), + Status: *queryParams.ProposalStatus, + }, + { // 5 + ID: "6666666666666666666666666666666666666666666666666", + ProposerAddr: "666666666666666666666666666666666", + StartTime: queryParams.MinStartTime, + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + SerializedBytes: []byte("6 serialized proposal bytes 6"), + Options: []byte("6 serialized proposal options 6"), + Data: []byte("6 serialized proposal data 6"), + Memo: []byte("6 serialized proposal memo 6"), + Status: *queryParams.ProposalStatus, + }, + { // 6 + ID: "7777777777777777777777777777777777777777777777777", + ProposerAddr: "777777777777777777777777777777777", + StartTime: queryParams.MinStartTime, + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + IsAdminProposal: false, + SerializedBytes: []byte("7 serialized proposal bytes 7"), + Options: []byte("7 serialized proposal options 7"), + Data: []byte("7 serialized proposal data 7"), + Memo: []byte("7 serialized proposal memo 7"), + Status: *queryParams.ProposalStatus, + }, + { // 7 + ID: "8888888888888888888888888888888888888888888888888", + ProposerAddr: "888888888888888888888888888888888", + StartTime: queryParams.MinStartTime, + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + IsAdminProposal: true, + SerializedBytes: []byte("8 serialized proposal bytes 8"), + Options: []byte("8 serialized proposal options 8"), + Data: []byte("8 serialized proposal data 8"), + Memo: []byte("8 serialized proposal memo 8"), + Status: *queryParams.ProposalStatus, + }, + { // 8 // cut by limit + ID: "9999999999999999999999999999999999999999999999999", + ProposerAddr: "999999999999999999999999999999999", + StartTime: queryParams.MinStartTime, + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + IsAdminProposal: false, + SerializedBytes: []byte("9 serialized proposal bytes 9"), + Options: []byte("9 serialized proposal options 9"), + Data: []byte("9 serialized proposal data 9"), + Memo: []byte("9 serialized proposal memo 9"), + Status: *queryParams.ProposalStatus, + }, + { // 9 + ID: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ProposerAddr: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + StartTime: queryParams.MinStartTime, + EndTime: queryParams.MinStartTime.Add(time.Hour), + Type: *queryParams.ProposalType, + IsAdminProposal: true, + SerializedBytes: []byte("A serialized proposal bytes A"), + Options: []byte("A serialized proposal options A"), + Data: []byte("A serialized proposal data A"), + Memo: []byte("A serialized proposal memo A"), + Status: models.ProposalStatusFailed, // different proposal status, but will be included in "completed" status query + }, + } + + for _, proposal := range proposals { + require.NoError(t, p.InsertTransactions(ctx, rawDBConn.NewSession(stream), &Transactions{ + ID: proposal.ID, + Memo: proposal.Memo, + CreatedAt: now, + }, false)) + require.NoError(t, p.InsertDACProposal(ctx, rawDBConn.NewSession(stream), proposal)) + } + + resultProposals, err := p.QueryDACProposals(ctx, rawDBConn.NewSession(stream), queryParams) + require.NoError(t, err) + require.Equal(t, []DACProposal{*proposals[5], *proposals[6], *proposals[7]}, resultProposals) + + proposalStatusCompleted := models.ProposalStatusCompleted + queryParams = ¶ms.ListDACProposalsParams{ + ProposalStatus: &proposalStatusCompleted, + } + resultProposals, err = p.QueryDACProposals(ctx, rawDBConn.NewSession(stream), queryParams) + require.NoError(t, err) + require.Equal(t, + []DACProposal{*proposals[1], *proposals[2], *proposals[3], *proposals[4], *proposals[5], *proposals[6], *proposals[7], *proposals[8], *proposals[9]}, + resultProposals) +} + +// TestInsertDACVote also tests QueryDACProposalVotes +func TestInsertDACVote(t *testing.T) { + p := NewPersist() + ctx := context.Background() + stream := &dbr.NullEventReceiver{} + rawDBConn, err := dbr.Open(TestDB, TestDSN, stream) + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableTransactions).Exec() + require.NoError(t, err) + _, err = rawDBConn.NewSession(stream).DeleteFrom(TableDACVotes).Exec() + require.NoError(t, err) + now := time.Now().UTC().Truncate(time.Second) + + vote := DACVote{ + VoteTxID: "1111111111111111111111111111111111111111111111111", + VoterAddr: "222222222222222222222222222222222", + VotedAt: now, + ProposalID: "3333333333333333333333333333333333333333333333333", + VotedOptions: []byte("serialized voted options"), + } + + require.NoError(t, p.InsertDACVote(ctx, rawDBConn.NewSession(stream), &vote)) + + expectedVote := vote + expectedVote.ProposalID = "" + resultVotes, err := p.QueryDACProposalVotes(ctx, rawDBConn.NewSession(stream), vote.ProposalID) + require.NoError(t, err) + require.Equal(t, []DACVote{expectedVote}, resultVotes) +} diff --git a/dependencies/caminoethvm b/dependencies/caminoethvm index c70cf2d6..f525ec79 160000 --- a/dependencies/caminoethvm +++ b/dependencies/caminoethvm @@ -1 +1 @@ -Subproject commit c70cf2d6a4363a0cb6eadf85720c918c3ee708cb +Subproject commit f525ec79dd22f2eaafce8e81ae9186eac06d3ef6 diff --git a/go.mod b/go.mod index 12784e84..1f015b73 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect github.com/btcsuite/btcd/btcutil v1.1.3 // indirect github.com/cenkalti/backoff/v4 v4.1.3 // indirect + github.com/cespare/cp v1.0.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 // indirect github.com/dop251/goja v0.0.0-20220405120441-9037c2b61cbf // indirect diff --git a/go.sum b/go.sum index 2db6d24f..0af6c57b 100644 --- a/go.sum +++ b/go.sum @@ -187,6 +187,7 @@ github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.0 h1:V2/ZgjfDFIygAX3ZapeigkVBoVUtOJKSwrhZdlpSvaA= github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= @@ -221,7 +222,8 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= +github.com/cespare/cp v1.0.0 h1:47QuPGrUwHTJLdv2MeejqLT29EfhvKzfH+OMBvayz80= +github.com/cespare/cp v1.0.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= diff --git a/models/collections.go b/models/collections.go index 2e5eceb4..9f7e1905 100644 --- a/models/collections.go +++ b/models/collections.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022, Chain4Travel AG. All rights reserved. +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. // // This file is a derived work, based on ava-labs code whose // original notices appear below. @@ -21,6 +21,57 @@ import ( "github.com/chain4travel/magellan/modelsc" ) +type DACProposalWithVotes struct { + DACProposal DACProposal `json:"dacProposal"` + DACVotes []DACVote `json:"dacVotes"` +} + +type DACProposalsList struct { + DACProposals []DACProposalWithVotes `json:"dacProposals"` +} + +type ProposalStatus int + +const ( + ProposalStatusInProgress ProposalStatus = iota + ProposalStatusSuccess + ProposalStatusFailed + ProposalStatusCompleted // both success and failed +) + +type ProposalType int + +const ( + ProposalTypeBaseFee ProposalType = iota + ProposalTypeAddMember + ProposalTypeExcludeMember + ProposalTypeGeneral + ProposalTypeFeeDistribution +) + +type DACProposal struct { + ID string `json:"id"` // proposal id, also addProposalTx id + ProposerAddr string `json:"proposerAddr"` // address which authorized proposal + StartTime time.Time `json:"startTime"` // time when proposal will become votable + EndTime time.Time `json:"endTime"` // time when proposal will become non-votable and will be executed if its successful + FinishedAt *time.Time `json:"finishedAt,omitempty"` // time when proposal was finished + Type ProposalType `json:"type"` // proposal type + IsAdminProposal bool `json:"admin_proposal"` // true if it is admin proposal + Options []byte `json:"options"` // proposal votable options + Data []byte `json:"data,omitempty"` // arbitrary proposal data + Memo []byte `json:"memo"` // addProposalTx memo + Outcome []byte `json:"outcome,omitempty"` // outcome of successful proposal, usually is one or multiple options indexes + Status ProposalStatus `json:"status"` // current status of proposal + BlockHeight uint64 `json:"blockHeight"` // height of proposal block +} + +type DACVote struct { + VoteTxID string `json:"voteTxID"` // addVoteTx id + VoterAddr string `json:"voterAddr"` // address which authorized this vote + VotedAt time.Time `json:"votedAt"` // timestamp when this vote happened + VotedOptions []byte `json:"votedOptions"` // proposal options that was voted by this vote, usually one or multiple option indexes +} + type MultisigAliasList struct { Alias []string `json:"alias"` } diff --git a/models/types.go b/models/types.go index c1b9b1f7..5f287b7e 100644 --- a/models/types.go +++ b/models/types.go @@ -1,3 +1,13 @@ +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. +// +// This file is a derived work, based on ava-labs code whose +// original notices appear below. +// +// It is distributed under the same license conditions as the +// original code from which it is derived. +// +// Much love to the original authors for their work. +// ********************************************************** // (c) 2021, Ava Labs, Inc. All rights reserved. // See the file LICENSE for licensing terms. @@ -46,8 +56,6 @@ var ( TransactionTypeTransformSubnet TransactionType = 0x16 TransactionTypeAddPermissionlessValidator TransactionType = 0x17 TransactionTypeAddPermissionlessDelegator TransactionType = 0x18 - TransactionTypeAddDaoProposal TransactionType = 0x19 - TransactionTypeAddDaoVote TransactionType = 0x20 // Camino Custom Datatypes @@ -73,6 +81,9 @@ var ( TransactionTypeClaimReward TransactionType = RegisterTransactionTypeCustom + 10 TransactionTypeRewardsImport TransactionType = RegisterTransactionTypeCustom + 11 TransactionTypeAddDepositOffer TransactionType = RegisterTransactionTypeCustom + 15 + TransactionTypeAddDACProposal TransactionType = RegisterTransactionTypeCustom + 16 + TransactionTypeAddDACVote TransactionType = RegisterTransactionTypeCustom + 17 + TransactionTypeFinishDACProposals TransactionType = RegisterTransactionTypeCustom + 18 ResultTypeTransaction SearchResultType = "transaction" ResultTypeAsset SearchResultType = "asset" @@ -136,10 +147,12 @@ func (t TransactionType) String() string { return "add_permissionless_validator" case TransactionTypeAddPermissionlessDelegator: return "add_permissionless_delegator" - case TransactionTypeAddDaoProposal: - return "add_dao_proposal" - case TransactionTypeAddDaoVote: - return "add_dao_vote" + case TransactionTypeAddDACProposal: + return "add_dac_proposal" + case TransactionTypeAddDACVote: + return "add_dac_vote" + case TransactionTypeFinishDACProposals: + return "finish_dac_proposals" case TransactionTypeAddAddressState: return "address_state" case TransactionTypeDeposit: diff --git a/services/db/migrations/054_dac_proposals_and_votes.down.sql b/services/db/migrations/054_dac_proposals_and_votes.down.sql new file mode 100644 index 00000000..abfb95b1 --- /dev/null +++ b/services/db/migrations/054_dac_proposals_and_votes.down.sql @@ -0,0 +1,5 @@ +DROP INDEX dac_votes_by_proposal_id; +DROP TABLE dac_votes; + +DROP INDEX dac_proposals_by_id; +DROP TABLE dac_proposals; diff --git a/services/db/migrations/054_dac_proposals_and_votes.up.sql b/services/db/migrations/054_dac_proposals_and_votes.up.sql new file mode 100644 index 00000000..1c154e54 --- /dev/null +++ b/services/db/migrations/054_dac_proposals_and_votes.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE `dac_proposals` ( + id VARCHAR(50) NOT NULL PRIMARY KEY, + proposer_addr VARCHAR(50) NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + type TINYINT NOT NULL, + admin_proposal BOOLEAN NOT NULL, + serialized_bytes VARBINARY(1024) NOT NULL, + finished_at TIMESTAMP, + options VARBINARY(1024) NOT NULL, + data VARBINARY(1024), + outcome VARBINARY(1024), + status TINYINT NOT NULL +); + +CREATE UNIQUE INDEX dac_proposals_by_id ON dac_proposals (id); + +CREATE TABLE `dac_votes` ( + id VARCHAR(50) NOT NULL PRIMARY KEY, + voter_addr VARCHAR(50) NOT NULL, + voted_at TIMESTAMP NOT NULL, + proposal_id VARCHAR(50) NOT NULL, + voted_options VARBINARY(1024) NOT NULL, + + FOREIGN KEY (proposal_id) REFERENCES dac_proposals(id) +); + + +CREATE INDEX dac_votes_by_proposal_id ON dac_votes (proposal_id); \ No newline at end of file diff --git a/services/indexes/avax/reader.go b/services/indexes/avax/reader.go index 0c01839b..d1db6529 100644 --- a/services/indexes/avax/reader.go +++ b/services/indexes/avax/reader.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022, Chain4Travel AG. All rights reserved. +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. // // This file is a derived work, based on ava-labs code whose // original notices appear below. @@ -25,6 +25,8 @@ import ( "time" "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/formatting/address" "github.com/chain4travel/magellan/caching" "github.com/chain4travel/magellan/cfg" "github.com/chain4travel/magellan/db" @@ -1040,3 +1042,150 @@ func (r *Reader) ActiveAddresses(ctx context.Context, p *params.ListParams) (*mo addressStatistics.AddressInfo = ActiveAddresses return addressStatistics, err } + +func (r *Reader) ListDACProposals(ctx context.Context, p *params.ListDACProposalsParams) (*models.DACProposalsList, error) { + dbRunner, err := r.conns.DB().NewSession("list_dac_proposals", cfg.RequestTimeout) + if err != nil { + return nil, err + } + + proposals, err := r.sc.Persist.QueryDACProposals(ctx, dbRunner, p) + if err != nil { + return nil, err + } + + proposalsList := make([]models.DACProposalWithVotes, len(proposals)) + for i := range proposals { + height, err := r.sc.Persist.GetTxHeight(ctx, dbRunner, proposals[i].ID) + if err != nil { + return nil, err + } + + votes, err := r.sc.Persist.QueryDACProposalVotes(ctx, dbRunner, proposals[i].ID) + if err != nil { + return nil, err + } + + proposalModel, err := r.dacProposalFromDB(&proposals[i]) + if err != nil { + return nil, err + } + proposalModel.BlockHeight = height + + votesModel, err := r.dacVotesFromDB(votes) + if err != nil { + return nil, err + } + + proposalsList[i] = models.DACProposalWithVotes{ + DACProposal: proposalModel, + DACVotes: votesModel, + } + } + + return &models.DACProposalsList{DACProposals: proposalsList}, nil +} + +func (r *Reader) GetDACProposalWithVotes(ctx context.Context, proposalID string) (*models.DACProposalWithVotes, error) { + dbRunner, err := r.conns.DB().NewSession("get_dac_proposal", cfg.RequestTimeout) + if err != nil { + return nil, err + } + + proposals, err := r.sc.Persist.GetDACProposals(ctx, dbRunner, []string{proposalID}) + if err != nil { + return nil, err + } + switch { + case err != nil: + return nil, err + case len(proposals) == 0: + return nil, dbr.ErrNotFound + case len(proposals) != 1: + return nil, errors.New("db returned multiple proposals for one proposalID") // should never happen + } + + height, err := r.sc.Persist.GetTxHeight(ctx, dbRunner, proposalID) + if err != nil { + return nil, err + } + + votes, err := r.sc.Persist.QueryDACProposalVotes(ctx, dbRunner, proposalID) + if err != nil { + return nil, err + } + + proposalModel, err := r.dacProposalFromDB(&proposals[0]) + if err != nil { + return nil, err + } + proposalModel.BlockHeight = height + + votesModel, err := r.dacVotesFromDB(votes) + if err != nil { + return nil, err + } + + return &models.DACProposalWithVotes{ + DACVotes: votesModel, + DACProposal: proposalModel, + }, nil +} + +func (r *Reader) dacProposalFromDB(proposal *db.DACProposal) (models.DACProposal, error) { + id, err := ids.ShortFromString(proposal.ProposerAddr) + if err != nil { + return models.DACProposal{}, err + } + + proposerAddr, err := address.Format("P", constants.GetHRP(r.networkID), id[:]) + if err != nil { + return models.DACProposal{}, err + } + + return models.DACProposal{ + ID: proposal.ID, + ProposerAddr: proposerAddr, + StartTime: proposal.StartTime, + EndTime: proposal.EndTime, + FinishedAt: proposal.FinishedAt, + Type: proposal.Type, + IsAdminProposal: proposal.IsAdminProposal, + Options: proposal.Options, + Data: proposal.Data, + Memo: proposal.Memo, + Outcome: proposal.Outcome, + Status: proposal.Status, + }, nil +} + +func (r *Reader) dacVotesFromDB(votes []db.DACVote) ([]models.DACVote, error) { + dacVotes := make([]models.DACVote, len(votes)) + for i := range votes { + dacVote, err := r.dacVoteFromDB(&votes[i]) + if err != nil { + return nil, err + } + dacVotes[i] = dacVote + } + return dacVotes, nil +} + +func (r *Reader) dacVoteFromDB(vote *db.DACVote) (models.DACVote, error) { + id, err := ids.ShortFromString(vote.VoterAddr) + if err != nil { + return models.DACVote{}, err + } + + voterAddr, err := address.Format("P", constants.GetHRP(r.networkID), id[:]) + if err != nil { + return models.DACVote{}, err + } + + return models.DACVote{ + VoteTxID: vote.VoteTxID, + VoterAddr: voterAddr, + VotedAt: vote.VotedAt, + VotedOptions: vote.VotedOptions, + }, nil +} diff --git a/services/indexes/params/collections.go b/services/indexes/params/collections.go index 2816a0d3..01c92554 100644 --- a/services/indexes/params/collections.go +++ b/services/indexes/params/collections.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022, Chain4Travel AG. All rights reserved. +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. // // This file is a derived work, based on ava-labs code whose // original notices appear below. @@ -21,7 +21,6 @@ import ( "time" "github.com/ava-labs/avalanchego/ids" - "github.com/chain4travel/magellan/db" "github.com/chain4travel/magellan/models" "github.com/gocraft/dbr/v2" ) @@ -45,6 +44,7 @@ var ( _ Param = &ListOutputsParams{} _ Param = &ListCTransactionsParams{} _ Param = &ListBlocksParams{} + _ Param = &ListDACProposalsParams{} ) type SearchParams struct { @@ -384,7 +384,7 @@ func (p *ListCTransactionsParams) CacheKey() []string { } func (p *ListCTransactionsParams) Apply(b *dbr.SelectBuilder) *dbr.SelectBuilder { - p.ListParams.ApplyPk(db.TableCvmTransactionsTxdata, b, "hash", false) + p.ListParams.ApplyPk("cvm_transactions_txdata", b, "hash", false) return b } @@ -801,3 +801,51 @@ func (p *ValidatorParams) SetParamInfo(v uint8, rpc string) error { func (p *ValidatorParams) CacheKey() []string { return p.ListParams.CacheKey() } + +type ListDACProposalsParams struct { + ListParams + + MinStartTime time.Time + MaxStartTime time.Time + MinStartTimeProvided bool + MaxStartTimeProvided bool + + ProposalType *models.ProposalType + ProposalStatus *models.ProposalStatus +} + +func (p *ListDACProposalsParams) ForValues(v uint8, q url.Values) error { + val, err := GetQueryInt(q, KeyProposalType, -1) + if err != nil { + return err + } + if val != -1 { + p.ProposalType = new(models.ProposalType) + *p.ProposalType = models.ProposalType(val) + } + + val, err = GetQueryInt(q, KeyProposalStatus, -1) + if err != nil { + return err + } + if val != -1 { + p.ProposalStatus = new(models.ProposalStatus) + *p.ProposalStatus = models.ProposalStatus(val) + } + + p.MinStartTimeProvided, p.MinStartTime, err = GetQueryTime(q, KeyProposalMinStartTime) + if err != nil { + return err + } + + p.MaxStartTimeProvided, p.MaxStartTime, err = GetQueryTime(q, KeyProposalMaxStartTime) + if err != nil { + return err + } + + return p.ListParams.ForValuesAllowOffset(v, q) +} + +func (p *ListDACProposalsParams) CacheKey() []string { + return p.ListParams.CacheKey() +} diff --git a/services/indexes/params/params.go b/services/indexes/params/params.go index afd8b1bb..73ea2103 100644 --- a/services/indexes/params/params.go +++ b/services/indexes/params/params.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022, Chain4Travel AG. All rights reserved. +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. // // This file is a derived work, based on ava-labs code whose // original notices appear below. @@ -24,31 +24,35 @@ import ( ) const ( - KeyID = "id" - KeyChainID = "chainID" - KeyAddress = "address" - KeyToAddress = "toAddress" - KeyFromAddress = "fromAddress" - KeyBlockStart = "blockStart" - KeyBlockEnd = "blockEnd" - KeyHash = "hash" - KeyAlias = "alias" - KeyAssetID = "assetID" - KeySearchQuery = "query" - KeySortBy = "sort" - KeyLimit = "limit" - KeyOffset = "offset" - KeySpent = "spent" - KeyStartTime = "startTime" - KeyEndTime = "endTime" - KeyIntervalSize = "intervalSize" - KeyDisableCount = "disableCount" - KeyDisableGenesis = "disableGenesis" - KeyOutputOutputType = "outputOutputType" - KeyOutputGroupID = "outputGroupId" - KeyTransactionID = "transactionId" - KeyRPC = "rpc" - KeyRaw = "raw" + KeyID = "id" + KeyChainID = "chainID" + KeyAddress = "address" + KeyToAddress = "toAddress" + KeyFromAddress = "fromAddress" + KeyBlockStart = "blockStart" + KeyBlockEnd = "blockEnd" + KeyHash = "hash" + KeyAlias = "alias" + KeyAssetID = "assetID" + KeySearchQuery = "query" + KeySortBy = "sort" + KeyLimit = "limit" + KeyOffset = "offset" + KeySpent = "spent" + KeyStartTime = "startTime" + KeyEndTime = "endTime" + KeyIntervalSize = "intervalSize" + KeyDisableCount = "disableCount" + KeyDisableGenesis = "disableGenesis" + KeyOutputOutputType = "outputOutputType" + KeyOutputGroupID = "outputGroupId" + KeyTransactionID = "transactionId" + KeyProposalType = "proposalType" + KeyProposalStatus = "proposalStatus" + KeyProposalMinStartTime = "minStartTime" + KeyProposalMaxStartTime = "maxStartTime" + KeyRPC = "rpc" + KeyRaw = "raw" PaginationMaxLimit = 5000 PaginationDefaultOffset = 0 diff --git a/services/indexes/pvm/writer.go b/services/indexes/pvm/writer.go index d8211ef2..028861ef 100644 --- a/services/indexes/pvm/writer.go +++ b/services/indexes/pvm/writer.go @@ -1,3 +1,13 @@ +// Copyright (C) 2022-2023, Chain4Travel AG. All rights reserved. +// +// This file is a derived work, based on ava-labs code whose +// original notices appear below. +// +// It is distributed under the same license conditions as the +// original code from which it is derived. +// +// Much love to the original authors for their work. +// ********************************************************** // (c) 2021, Ava Labs, Inc. All rights reserved. // See the file LICENSE for licensing terms. @@ -26,7 +36,9 @@ import ( "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/components/multisig" "github.com/ava-labs/avalanchego/vms/components/verify" + as "github.com/ava-labs/avalanchego/vms/platformvm/addrstate" "github.com/ava-labs/avalanchego/vms/platformvm/blocks" + "github.com/ava-labs/avalanchego/vms/platformvm/dac" "github.com/ava-labs/avalanchego/vms/platformvm/txs" "github.com/ava-labs/avalanchego/vms/proposervm/block" "github.com/ava-labs/avalanchego/vms/secp256k1fx" @@ -178,7 +190,7 @@ func (w *Writer) Bootstrap(ctx context.Context, conns *utils.Connections, persis txDupCheck := set.NewSet[ids.ID](2*len(gc.Genesis.Camino.AddressStates) + 2*len(gc.Genesis.Camino.ConsortiumMembersNodeIDs)) - addressStateTx := func(addr ids.ShortID, state txs.AddressStateBit) *txs.Tx { + addressStateTx := func(addr ids.ShortID, state as.AddressStateBit) *txs.Tx { tx := &txs.Tx{ Unsigned: &txs.AddressStateTx{ BaseTx: txs.BaseTx{ @@ -242,22 +254,22 @@ func (w *Writer) Bootstrap(ctx context.Context, conns *utils.Connections, persis } } - for _, as := range gc.Genesis.Camino.AddressStates { + for _, addrState := range gc.Genesis.Camino.AddressStates { select { case <-ctx.Done(): default: } - if as.State&txs.AddressStateKYCVerified != 0 { - if tx := addressStateTx(as.Address, txs.AddressStateBitKYCVerified); tx != nil { + if addrState.State&as.AddressStateKYCVerified != 0 { + if tx := addressStateTx(addrState.Address, as.AddressStateBitKYCVerified); tx != nil { err := w.indexTransaction(cCtx, ChainID, tx, true) if err != nil { return err } } } - if as.State&txs.AddressStateConsortiumMember != 0 { - if tx := addressStateTx(as.Address, txs.AddressStateBitConsortium); tx != nil { + if addrState.State&as.AddressStateConsortiumMember != 0 { + if tx := addressStateTx(addrState.Address, as.AddressStateBitConsortium); tx != nil { err := w.indexTransaction(cCtx, ChainID, tx, true) if err != nil { return err @@ -444,6 +456,8 @@ func (w *Writer) indexCommonBlock( //nolint:gocyclo func (w *Writer) indexTransaction(ctx services.ConsumerCtx, blkID ids.ID, tx *txs.Tx, genesis bool) error { + // ctx.Time() isn't strictly correct chaintime (block time) for Apricot blocks, + // but we assume we won't have any of them var ( txID = tx.ID() baseTx avax.BaseTx @@ -590,6 +604,32 @@ func (w *Writer) indexTransaction(ctx services.ConsumerCtx, blkID ids.ID, tx *tx case *txs.AddDepositOfferTx: baseTx = castTx.BaseTx.BaseTx typ = models.TransactionTypeAddDepositOffer + case *txs.AddProposalTx: + baseTx = castTx.BaseTx.BaseTx + typ = models.TransactionTypeAddDACProposal + proposal, err := castTx.Proposal() + if err != nil { + return err + } + if err := w.InsertDACProposal(ctx, proposal, castTx.ProposerAddress, txID); err != nil { + return err + } + case *txs.AddVoteTx: + baseTx = castTx.BaseTx.BaseTx + typ = models.TransactionTypeAddDACVote + vote, err := castTx.Vote() + if err != nil { + return err + } + if err := w.InsertDACVote(ctx, txID, vote, castTx.VoterAddress, ctx.Time(), castTx.ProposalID); err != nil { + return err + } + case *txs.FinishProposalsTx: + baseTx = castTx.BaseTx.BaseTx + typ = models.TransactionTypeFinishDACProposals + if err := w.FinishDACProposals(ctx, castTx, ctx.Time()); err != nil { + return err + } default: return fmt.Errorf("unknown tx type %T", castTx) } @@ -778,3 +818,211 @@ func persistMultisigAliasAddresses(ctx services.ConsumerCtx, addr ids.ShortID, c return nil } + +type dacProposalWrapper struct { + dac.ProposalState `serialize:"true"` +} + +func (w *Writer) InsertDACProposal( + ctx services.ConsumerCtx, + proposal dac.Proposal, + proposerAddr ids.ShortID, + txID ids.ID, +) error { + proposalType, proposalOptions, proposalData, isAdminProposal, err := parseDACProposal(proposal, w.networkID) + if err != nil { + return err + } + + wrapper := dacProposalWrapper{} + if isAdminProposal { + proposalState, err := proposal.CreateFinishedProposalState(0) + if err != nil { + return err + } + wrapper.ProposalState = proposalState + } else { + wrapper.ProposalState = proposal.CreateProposalState([]ids.ShortID{}) + } + proposalBytes, err := dac.Codec.Marshal(txs.Version, &wrapper) + if err != nil { + return err + } + + return ctx.Persist().InsertDACProposal(ctx.Ctx(), ctx.DB(), &db.DACProposal{ + ID: txID.String(), + ProposerAddr: proposerAddr.String(), + StartTime: proposal.StartTime(), + EndTime: proposal.EndTime(), + Type: proposalType, + IsAdminProposal: isAdminProposal, + SerializedBytes: proposalBytes, + Options: proposalOptions, + Data: proposalData, + Status: models.ProposalStatusInProgress, + }) +} + +func (w *Writer) FinishDACProposals(ctx services.ConsumerCtx, tx *txs.FinishProposalsTx, finishedAt time.Time) error { + // Finishing successful proposals + successfulProposalIDsStrs := make([]string, 0, len(tx.EarlyFinishedSuccessfulProposalIDs)+len(tx.ExpiredSuccessfulProposalIDs)) + for _, proposalID := range tx.EarlyFinishedSuccessfulProposalIDs { + successfulProposalIDsStrs = append(successfulProposalIDsStrs, proposalID.String()) + } + for _, proposalID := range tx.ExpiredSuccessfulProposalIDs { + successfulProposalIDsStrs = append(successfulProposalIDsStrs, proposalID.String()) + } + if len(successfulProposalIDsStrs) > 0 { + successfulProposals, err := ctx.Persist().GetDACProposals(ctx.Ctx(), ctx.DB(), successfulProposalIDsStrs) + if err != nil { + return err + } + for _, dbProposal := range successfulProposals { + proposal := dacProposalWrapper{} + if _, err := dac.Codec.Unmarshal(dbProposal.SerializedBytes, &proposal); err != nil { + return err + } + + outcomeBytes, err := json.Marshal(proposal.Outcome()) + if err != nil { + return err + } + + if err := ctx.Persist().FinishDACProposalWithOutcome( + ctx.Ctx(), + ctx.DB(), + dbProposal.ID, + finishedAt, + models.ProposalStatusSuccess, + outcomeBytes, + ); err != nil { + return err + } + } + } + + // Finishing failed proposals + failedProposalIDsStrs := make([]string, 0, len(tx.EarlyFinishedFailedProposalIDs)+len(tx.ExpiredFailedProposalIDs)) + for _, proposalID := range tx.EarlyFinishedFailedProposalIDs { + failedProposalIDsStrs = append(failedProposalIDsStrs, proposalID.String()) + } + for _, proposalID := range tx.ExpiredFailedProposalIDs { + failedProposalIDsStrs = append(failedProposalIDsStrs, proposalID.String()) + } + if len(failedProposalIDsStrs) == 0 { + return nil + } + return ctx.Persist().FinishDACProposals(ctx.Ctx(), ctx.DB(), failedProposalIDsStrs, finishedAt, models.ProposalStatusFailed) +} + +func (w *Writer) InsertDACVote( + ctx services.ConsumerCtx, + voteTxID ids.ID, + vote dac.Vote, + voterAddr ids.ShortID, + votedAt time.Time, + proposalID ids.ID, +) error { + options, err := json.Marshal(vote.VotedOptions()) + if err != nil { + return err + } + + proposals, err := ctx.Persist().GetDACProposals(ctx.Ctx(), ctx.DB(), []string{proposalID.String()}) + switch { + case err != nil: + return err + case len(proposals) == 0: + return dbr.ErrNotFound + case len(proposals) != 1: + return errors.New("db returned multiple proposals for one proposalID") // should never happen + } + + wrapper := dacProposalWrapper{} + if _, err := dac.Codec.Unmarshal(proposals[0].SerializedBytes, &wrapper); err != nil { + return err + } + + updatedProposal, err := wrapper.ForceAddVote(vote) + if err != nil { + return err + } + + wrapper.ProposalState = updatedProposal + proposalBytes, err := dac.Codec.Marshal(txs.Version, &wrapper) + if err != nil { + return err + } + + if err := ctx.Persist().UpdateDACProposal( + ctx.Ctx(), + ctx.DB(), + proposalID.String(), + proposalBytes, + ); err != nil { + return err + } + + return ctx.Persist().InsertDACVote(ctx.Ctx(), ctx.DB(), &db.DACVote{ + VoterAddr: voterAddr.String(), + VotedAt: votedAt, + VoteTxID: voteTxID.String(), + ProposalID: proposalID.String(), + VotedOptions: options, + }) +} + +func parseDACProposal( + proposal dac.Proposal, networkID uint32, +) ( + proposalType models.ProposalType, + options []byte, + data []byte, + isAdminProposal bool, + err error, +) { + adminProposal, isAdminProposal := proposal.(*dac.AdminProposal) + if isAdminProposal { + proposal = adminProposal.Proposal + } + + var proposalData any + switch proposal := proposal.(type) { + case *dac.BaseFeeProposal: + proposalType = models.ProposalTypeBaseFee + case *dac.AddMemberProposal: + proposalType = models.ProposalTypeAddMember + applicantAddress, err := address.Format("P", constants.GetHRP(networkID), proposal.ApplicantAddress[:]) + if err != nil { + return 0, nil, nil, false, err + } + proposalData = applicantAddress + case *dac.ExcludeMemberProposal: + proposalType = models.ProposalTypeExcludeMember + memberAddress, err := address.Format("P", constants.GetHRP(networkID), proposal.MemberAddress[:]) + if err != nil { + return 0, nil, nil, false, err + } + proposalData = memberAddress + case *dac.GeneralProposal: + proposalType = models.ProposalTypeGeneral + case *dac.FeeDistributionProposal: + proposalType = models.ProposalTypeFeeDistribution + default: + return 0, nil, nil, false, fmt.Errorf("unknown proposal type: %T", proposal) + } + + options, err = json.Marshal(proposal.GetOptions()) // always not nil + if err != nil { + return 0, nil, nil, false, err + } + + if proposalData != nil { + data, err = json.Marshal(proposalData) + if err != nil { + return 0, nil, nil, false, err + } + } + + return proposalType, options, data, isAdminProposal, nil +}