diff --git a/api/v2.go b/api/v2.go index 727b0f76..5550b93d 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/votes", (*V2Context).GetDACProposalVotes) } // 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) GetDACProposalVotes(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_votes", proposalID), + CacheableFn: func(ctx context.Context) (interface{}, error) { + return c.avaxReader.GetDACProposalVotes(ctx, proposalID) + }, + }) +} diff --git a/db/dbmodel.go b/db/dbmodel.go index 68170c95..84b44365 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 { @@ -440,6 +443,45 @@ type Persist interface { dbr.SessionRunner, *Reward, ) error + + InsertDACProposal( + ctx context.Context, + session dbr.SessionRunner, + proposal *DACProposal, + ) error + + UpdateDACProposalsStatus( + ctx context.Context, + session dbr.SessionRunner, + proposalIDs []string, + proposalStatus models.ProposalStatus, + ) error + + FinishDACProposal( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, + proposalStatus models.ProposalStatus, + outcome []byte, + ) error + + QueryDACProposals( + ctx context.Context, + session dbr.SessionRunner, + params *params.ListDACProposalsParams, + ) ([]DACProposal, error) + + InsertDACVote( + ctx context.Context, + session dbr.SessionRunner, + vote *DACVote, + ) error + + QueryDACProposalVotes( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, + ) ([]DACVote, error) } type persist struct{} @@ -2390,3 +2432,154 @@ 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 + Options []byte `db:"options"` // proposal votable options + Memo []byte `db:"memo"` // addProposalTx memo + 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("options", proposal.Options). + Pair("status", proposal.Status). + ExecContext(ctx) + if err != nil { + return EventErr(TableDACProposals, false, err) + } + return nil +} + +func (p *persist) UpdateDACProposalsStatus( + ctx context.Context, + session dbr.SessionRunner, + proposalIDs []string, + proposalStatus models.ProposalStatus, +) error { + _, err := session. + Update(TableDACProposals). + Set("status", proposalStatus). + Where("id IN ?", proposalIDs). + ExecContext(ctx) + if err != nil { + return EventErr(TableDACProposals, false, err) + } + return nil +} + +func (p *persist) FinishDACProposal( + ctx context.Context, + session dbr.SessionRunner, + proposalID string, + proposalStatus models.ProposalStatus, + outcome []byte, +) error { + _, err := session. + Update(TableDACProposals). + Set("status", proposalStatus). + Set("outcome", outcome). + Where("id = ?", proposalID). + ExecContext(ctx) + if err != nil { + return EventErr(TableDACProposals, false, err) + } + return nil +} + +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.options", + "T.memo", + "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.ListParams.StartTimeProvided { + query.Where("P.start_time <= ?", params.StartTime) + } + 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 = ? OR P.status = ?", 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 +} diff --git a/db/dbmodel_mock.go b/db/dbmodel_mock.go index e70dcd8e..4285692f 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. @@ -7,7 +17,10 @@ import ( "context" "sync" + "github.com/chain4travel/magellan/models" + "github.com/chain4travel/magellan/services/indexes/params" "github.com/gocraft/dbr/v2" + "golang.org/x/exp/slices" ) type MockPersist struct { @@ -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 { @@ -665,3 +680,86 @@ 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 +} + +// 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) UpdateDACProposalsStatus(ctx context.Context, session dbr.SessionRunner, proposalIDs []string, proposalStatus models.ProposalStatus) error { + m.lock.Lock() + defer m.lock.Unlock() + for _, v := range m.DACProposals { + if slices.Contains(proposalIDs, v.ID) { + v.Status = proposalStatus + } + } + return nil +} + +func (m *MockPersist) FinishDACProposal(ctx context.Context, session dbr.SessionRunner, proposalID string, proposalStatus models.ProposalStatus, outcome []byte) error { + m.lock.Lock() + defer m.lock.Unlock() + proposal, ok := m.DACProposals[proposalID] + if !ok { + return nil + } + proposal.Status = proposalStatus + proposal.Outcome = outcome + return 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 +} diff --git a/db/dbmodel_test.go b/db/dbmodel_test.go index 51f898d1..2c8591e0 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,276 @@ 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(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, + Options: []byte("serialized proposal options"), + 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 TestUpdateDACProposalsStatus(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(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, + Options: []byte("serialized proposal options"), + 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)) + + require.NoError(t, p.UpdateDACProposalsStatus( + ctx, + rawDBConn.NewSession(stream), + []string{proposal.ID}, + models.ProposalStatusFailed, + )) + + expectedProposal := proposal + expectedProposal.Status = models.ProposalStatusFailed + resultProposals, err := p.QueryDACProposals(ctx, rawDBConn.NewSession(stream), ¶ms.ListDACProposalsParams{}) + require.NoError(t, err) + require.Equal(t, []DACProposal{expectedProposal}, resultProposals) +} + +func TestFinishDACProposal(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(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, + Options: []byte("serialized proposal options"), + 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)) + + outcome := []byte{} + require.NoError(t, p.FinishDACProposal( + ctx, + rawDBConn.NewSession(stream), + proposal.ID, + models.ProposalStatusSuccess, + outcome, + )) + + expectedProposal := proposal + expectedProposal.Status = models.ProposalStatusSuccess + expectedProposal.Outcome = outcome + resultProposals, err := p.QueryDACProposals(ctx, rawDBConn.NewSession(stream), ¶ms.ListDACProposalsParams{}) + require.NoError(t, err) + require.Equal(t, []DACProposal{expectedProposal}, 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(TableDACProposals).Exec() + require.NoError(t, err) + now := time.Now().UTC().Truncate(time.Second) + + baseFeeProposalType := models.ProposalTypeBaseFee + proposalStatusSuccess := models.ProposalStatusSuccess + params := ¶ms.ListDACProposalsParams{ + ListParams: params.ListParams{ + Limit: 2, + Offset: 1, + StartTime: now.Add(100 * time.Second), + EndTime: now.Add(105 * time.Second), + StartTimeProvided: true, + EndTimeProvided: true, + }, + ProposalType: &baseFeeProposalType, + ProposalStatus: &proposalStatusSuccess, + } + + proposals := []*DACProposal{ + { // 0 + ID: "1111111111111111111111111111111111111111111111111", + ProposerAddr: "111111111111111111111111111111111", + StartTime: params.StartTime, + EndTime: params.EndTime, + Type: *params.ProposalType, + Options: []byte("1 serialized proposal options 1"), + Memo: []byte("1 serialized proposal memo 1"), + Status: models.ProposalStatusInProgress, // different proposal status + }, + { // 1 + ID: "2222222222222222222222222222222222222222222222222", + ProposerAddr: "222222222222222222222222222222222", + StartTime: params.StartTime, + EndTime: params.EndTime, + Type: models.ProposalType(100), // different proposal type + Options: []byte("2 serialized proposal options 2"), + Memo: []byte("2 serialized proposal memo 2"), + Status: *params.ProposalStatus, + }, + { // 2 + ID: "3333333333333333333333333333333333333333333333333", + ProposerAddr: "333333333333333333333333333333333", + StartTime: params.StartTime.Add(time.Second), // starttime is after + EndTime: params.EndTime, + Type: *params.ProposalType, + Options: []byte("3 serialized proposal options 3"), + Memo: []byte("3 serialized proposal memo 3"), + Status: *params.ProposalStatus, + }, + { // 3 + ID: "4444444444444444444444444444444444444444444444444", + ProposerAddr: "444444444444444444444444444444444", + StartTime: params.StartTime, + EndTime: params.EndTime.Add(-time.Second), // endtime is before + Type: *params.ProposalType, + Options: []byte("4 serialized proposal options 4"), + Memo: []byte("4 serialized proposal memo 4"), + Status: *params.ProposalStatus, + }, + { // 4 // cut by offset + ID: "5555555555555555555555555555555555555555555555555", + ProposerAddr: "555555555555555555555555555555555", + StartTime: params.StartTime, + EndTime: params.EndTime, + Type: *params.ProposalType, + Options: []byte("5 serialized proposal options 5"), + Memo: []byte("5 serialized proposal memo 5"), + Status: *params.ProposalStatus, + }, + { // 5 + ID: "6666666666666666666666666666666666666666666666666", + ProposerAddr: "666666666666666666666666666666666", + StartTime: params.StartTime, + EndTime: params.EndTime, + Type: *params.ProposalType, + Options: []byte("6 serialized proposal options 6"), + Memo: []byte("6 serialized proposal memo 6"), + Status: *params.ProposalStatus, + }, + { // 6 + ID: "7777777777777777777777777777777777777777777777777", + ProposerAddr: "777777777777777777777777777777777", + StartTime: params.StartTime, + EndTime: params.EndTime, + Type: *params.ProposalType, + Options: []byte("7 serialized proposal options 7"), + Memo: []byte("7 serialized proposal memo 7"), + Status: *params.ProposalStatus, + }, + { // 7 // cut by limit + ID: "8888888888888888888888888888888888888888888888888", + ProposerAddr: "888888888888888888888888888888888", + StartTime: params.StartTime, + EndTime: params.EndTime, + Type: *params.ProposalType, + Options: []byte("8 serialized proposal options 8"), + Memo: []byte("8 serialized proposal memo 8"), + Status: *params.ProposalStatus, + }, + } + + 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), params) + require.NoError(t, err) + require.Equal(t, []DACProposal{*proposals[5], *proposals[6]}, 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(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..7baa805e 160000 --- a/dependencies/caminoethvm +++ b/dependencies/caminoethvm @@ -1 +1 @@ -Subproject commit c70cf2d6a4363a0cb6eadf85720c918c3ee708cb +Subproject commit 7baa805edf473b332171770fe6c96a666c4ff0ae diff --git a/models/collections.go b/models/collections.go index 2e5eceb4..bcadc2b6 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,49 @@ import ( "github.com/chain4travel/magellan/modelsc" ) +type DACProposalsList struct { + DACProposals []DACProposal `json:"dacProposals"` +} + +type ProposalStatus int + +const ( + ProposalStatusInProgress ProposalStatus = iota + ProposalStatusSuccess + ProposalStatusFailed + ProposalStatusCompleted // both success and failed +) + +type ProposalType int + +const ( + ProposalTypeBaseFee ProposalType = iota +) + +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 + Type ProposalType `json:"type"` // proposal type + Options []byte `json:"options"` // proposal votable options + 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 +} + +type DACVotesList struct { + DACVotes []DACVote `json:"dacVotes"` +} + +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 + ProposalID string `json:"proposalID"` // id of proposal that was voted on + 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..ab9e707d --- /dev/null +++ b/services/db/migrations/054_dac_proposals_and_votes.down.sql @@ -0,0 +1,5 @@ +DROP INDEX dac_proposals_by_id; +DROP TABLE dac_proposals; + +DROP INDEX dac_votes_by_proposal_id; +DROP TABLE dac_votes; \ No newline at end of file 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..a8f9d2a4 --- /dev/null +++ b/services/db/migrations/054_dac_proposals_and_votes.up.sql @@ -0,0 +1,24 @@ +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, + options VARBINARY(1024) NOT NULL, + outcome VARBINARY(1024), + status TINYINT NOT NULL +); + +CREATE 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 +); + + +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..ed324649 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. @@ -1040,3 +1040,41 @@ 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("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.DACProposal, 0, len(proposals)) + for _, v := range proposals { + proposalsList = append(proposalsList, models.DACProposal(v)) + } + + return &models.DACProposalsList{DACProposals: proposalsList}, nil +} + +func (r *Reader) GetDACProposalVotes(ctx context.Context, proposalID string) (*models.DACVotesList, error) { + dbRunner, err := r.conns.DB().NewSession("dac_proposal_votes", cfg.RequestTimeout) + if err != nil { + return nil, err + } + + votes, err := r.sc.Persist.QueryDACProposalVotes(ctx, dbRunner, proposalID) + if err != nil { + return nil, err + } + + votesList := make([]models.DACVote, 0, len(votes)) + for _, v := range votes { + votesList = append(votesList, models.DACVote(v)) + } + + return &models.DACVotesList{DACVotes: votesList}, nil +} diff --git a/services/indexes/params/collections.go b/services/indexes/params/collections.go index 2816a0d3..1ea3d498 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" ) @@ -384,7 +383,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 } diff --git a/services/indexes/params/params.go b/services/indexes/params/params.go index afd8b1bb..f75468da 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. @@ -20,6 +20,7 @@ import ( "time" "github.com/ava-labs/avalanchego/ids" + "github.com/chain4travel/magellan/models" "github.com/gocraft/dbr/v2" ) @@ -47,6 +48,8 @@ const ( KeyOutputOutputType = "outputOutputType" KeyOutputGroupID = "outputGroupId" KeyTransactionID = "transactionId" + KeyProposalType = "proposalType" + KeyProposalStatus = "proposalStatus" KeyRPC = "rpc" KeyRaw = "raw" @@ -212,3 +215,35 @@ func (p ListParams) ApplyPk(listTable string, b *dbr.SelectBuilder, primaryKey s return b } + +type ListDACProposalsParams struct { + ListParams + 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) + } + + return p.ListParams.ForValuesAllowOffset(v, q) +} + +func (p *ListDACProposalsParams) CacheKey() []string { + return p.ListParams.CacheKey() +} diff --git a/services/indexes/pvm/writer.go b/services/indexes/pvm/writer.go index d8211ef2..9cabbc21 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. @@ -27,6 +37,7 @@ import ( "github.com/ava-labs/avalanchego/vms/components/multisig" "github.com/ava-labs/avalanchego/vms/components/verify" "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" @@ -444,6 +455,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 +603,44 @@ 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, castTx.Memo); 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 + for _, proposalID := range castTx.EarlyFinishedProposalIDs { + if err := w.FinishDACProposalSuccess(ctx, proposalID); err != nil { + return err + } + } + for _, proposalID := range castTx.ExpiredSuccessfulProposalIDs { + if err := w.FinishDACProposalSuccess(ctx, proposalID); err != nil { + return err + } + } + if len(castTx.ExpiredFailedProposalIDs) > 0 { + if err := w.FinishDACProposalsFail(ctx, castTx.ExpiredFailedProposalIDs); err != nil { + return err + } + } default: return fmt.Errorf("unknown tx type %T", castTx) } @@ -745,6 +796,151 @@ func (w *Writer) InsertMultisigAlias( return nil } +func (w *Writer) InsertDACProposal( + ctx services.ConsumerCtx, + proposal dac.Proposal, + proposerAddr ids.ShortID, + txID ids.ID, + memo []byte, +) error { + var proposalType models.ProposalType + switch proposal := proposal.(type) { + case *dac.BaseFeeProposal: + proposalType = models.ProposalTypeBaseFee + default: + return fmt.Errorf("unknown proposal type: %T", proposal) + } + + options, err := json.Marshal(proposal.GetOptions()) + 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, + Options: options, + Memo: memo, + Status: models.ProposalStatusInProgress, + }) +} + +func (w *Writer) FinishDACProposalsFail( + ctx services.ConsumerCtx, + proposalIDs []ids.ID, +) error { + proposalIDsStrs := make([]string, len(proposalIDs)) + for i := range proposalIDs { + proposalIDsStrs[i] = proposalIDs[i].String() + } + return ctx.Persist().UpdateDACProposalsStatus(ctx.Ctx(), ctx.DB(), proposalIDsStrs, models.ProposalStatusFailed) +} + +func (w *Writer) FinishDACProposalSuccess( + ctx services.ConsumerCtx, + proposalID ids.ID, +) error { + queryResult, err := ctx.Persist().QueryTransactions(ctx.Ctx(), ctx.DB(), &db.Transactions{ID: proposalID.String()}) + if err != nil { + return err + } + + tx, err := txs.Parse(txs.Codec, queryResult.CanonicalSerialization) + if err != nil { + return err + } + + proposalTx, ok := tx.Unsigned.(*txs.AddProposalTx) + if !ok { + return errors.New("wrong proposal tx type") + } + + proposal, err := proposalTx.Proposal() + if err != nil { + return err + } + + votes, err := ctx.Persist().QueryDACProposalVotes(ctx.Ctx(), ctx.DB(), proposalID.String()) + if err != nil { + return err + } + + allowedVoters := make([]ids.ShortID, len(votes)) + for i := range votes { + voterAddr, err := ids.ShortFromString(votes[i].VoterAddr) + if err != nil { + return err + } + allowedVoters[i] = voterAddr + } + + proposalState := proposal.CreateProposalState(allowedVoters) + + for _, dbVote := range votes { + queryResult, err := ctx.Persist().QueryTransactions(ctx.Ctx(), ctx.DB(), &db.Transactions{ID: dbVote.VoteTxID}) + if err != nil { + return err + } + + tx, err := txs.Parse(txs.Codec, queryResult.CanonicalSerialization) + if err != nil { + return err + } + + voteTx, ok := tx.Unsigned.(*txs.AddVoteTx) + if !ok { + return errors.New("wrong proposal tx type") + } + + vote, err := voteTx.Vote() + if err != nil { + return err + } + + proposalState, err = proposalState.AddVote(voteTx.VoterAddress, vote) + if err != nil { + return err + } + } + + outcomeBytes, err := json.Marshal(proposalState.Outcome()) + if err != nil { + return err + } + + return ctx.Persist().FinishDACProposal( + ctx.Ctx(), + ctx.DB(), + proposalID.String(), + models.ProposalStatusSuccess, + outcomeBytes, + ) +} + +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 + } + return ctx.Persist().InsertDACVote(ctx.Ctx(), ctx.DB(), &db.DACVote{ + VoterAddr: voterAddr.String(), + VotedAt: votedAt, + VoteTxID: voteTxID.String(), + ProposalID: proposalID.String(), + VotedOptions: options, + }) +} + func persistMultisigAliasAddresses(ctx services.ConsumerCtx, addr ids.ShortID, chainID string) error { var err error