diff --git a/go.mod b/go.mod index 81342e5d8e..db1eebfc13 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( gorm.io/datatypes v1.0.7 gorm.io/driver/mysql v1.4.3 gorm.io/gorm v1.24.6 + gotest.tools v2.2.0+incompatible ) require ( @@ -57,6 +58,7 @@ require ( github.com/golang-jwt/jwt/v4 v4.4.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/google/uuid v1.3.0 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect diff --git a/go.sum b/go.sum index de48a670c1..ef02b7d5a3 100644 --- a/go.sum +++ b/go.sum @@ -451,6 +451,7 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/packages/eventindexer/event.go b/packages/eventindexer/event.go index 1e0b67bfb6..750f2a680c 100644 --- a/packages/eventindexer/event.go +++ b/packages/eventindexer/event.go @@ -8,7 +8,8 @@ import ( ) var ( - EventNameBlockProven = "BlockProven" + EventNameBlockProven = "BlockProven" + EventNameBlockProposed = "BlockProposed" ) // Event represents a stored EVM event. The fields will be serialized @@ -37,10 +38,19 @@ type UniqueProversResponse struct { Count int `json:"count"` } +type UniqueProposersResponse struct { + Address string `json:"address"` + Count int `json:"count"` +} + // EventRepository is used to interact with events in the store type EventRepository interface { Save(ctx context.Context, opts SaveEventOpts) (*Event, error) FindUniqueProvers( ctx context.Context, ) ([]UniqueProversResponse, error) + FindUniqueProposers( + ctx context.Context, + ) ([]UniqueProposersResponse, error) + GetCountByAddressAndEventName(ctx context.Context, address string, event string) (int, error) } diff --git a/packages/eventindexer/http/get_count_by_address_and_event.go b/packages/eventindexer/http/get_count_by_address_and_event.go new file mode 100644 index 0000000000..800c2d725e --- /dev/null +++ b/packages/eventindexer/http/get_count_by_address_and_event.go @@ -0,0 +1,27 @@ +package http + +import ( + "net/http" + + "github.com/cyberhorsey/webutils" + "github.com/labstack/echo/v4" +) + +type GetCountByAddressAndEventNameResp struct { + Count int `json:"count"` +} + +func (srv *Server) GetCountByAddressAndEventName(c echo.Context) error { + count, err := srv.eventRepo.GetCountByAddressAndEventName( + c.Request().Context(), + c.QueryParam("address"), + c.QueryParam("event"), + ) + if err != nil { + return webutils.LogAndRenderErrors(c, http.StatusUnprocessableEntity, err) + } + + return c.JSON(http.StatusOK, &GetCountByAddressAndEventNameResp{ + Count: count, + }) +} diff --git a/packages/eventindexer/http/get_count_by_address_and_event_test.go b/packages/eventindexer/http/get_count_by_address_and_event_test.go new file mode 100644 index 0000000000..bbcacc9ecf --- /dev/null +++ b/packages/eventindexer/http/get_count_by_address_and_event_test.go @@ -0,0 +1,68 @@ +package http + +import ( + "context" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cyberhorsey/webutils/testutils" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" +) + +func Test_GetCountByAddressAndEvent(t *testing.T) { + srv := newTestServer("") + + _, err := srv.eventRepo.Save(context.Background(), eventindexer.SaveEventOpts{ + Name: "name", + Data: `{"Owner": "0x0000000000000000000000000000000000000123"}`, + ChainID: big.NewInt(167001), + Address: "0x123", + Event: eventindexer.EventNameBlockProposed, + }) + + assert.Equal(t, nil, err) + + tests := []struct { + name string + address string + event string + wantStatus int + wantBodyRegexpMatches []string + }{ + { + "successZeroCount", + "0xhasntProposedAnything", + eventindexer.EventNameBlockProposed, + http.StatusOK, + []string{`{"count":0`}, + }, + { + "success", + "0x123", + eventindexer.EventNameBlockProposed, + http.StatusOK, + []string{`{"count":1`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := testutils.NewUnauthenticatedRequest( + echo.GET, + fmt.Sprintf("/eventByAddress?address=%v&event=%v", tt.address, tt.event), + nil, + ) + + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + testutils.AssertStatusAndBody(t, rec, tt.wantStatus, tt.wantBodyRegexpMatches) + }) + } +} diff --git a/packages/eventindexer/http/get_unique_proposers.go b/packages/eventindexer/http/get_unique_proposers.go new file mode 100644 index 0000000000..f29f94a466 --- /dev/null +++ b/packages/eventindexer/http/get_unique_proposers.go @@ -0,0 +1,28 @@ +package http + +import ( + "net/http" + + "github.com/cyberhorsey/webutils" + "github.com/labstack/echo/v4" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" +) + +type uniqueProposersResp struct { + Proposers []eventindexer.UniqueProposersResponse `json:"proposers"` + UniqueProposers int `json:"uniqueProposers"` +} + +func (srv *Server) GetUniqueProposers(c echo.Context) error { + proposers, err := srv.eventRepo.FindUniqueProposers( + c.Request().Context(), + ) + if err != nil { + return webutils.LogAndRenderErrors(c, http.StatusUnprocessableEntity, err) + } + + return c.JSON(http.StatusOK, &uniqueProposersResp{ + Proposers: proposers, + UniqueProposers: len(proposers), + }) +} diff --git a/packages/eventindexer/http/get_unique_proposers_test.go b/packages/eventindexer/http/get_unique_proposers_test.go new file mode 100644 index 0000000000..709a960780 --- /dev/null +++ b/packages/eventindexer/http/get_unique_proposers_test.go @@ -0,0 +1,56 @@ +package http + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cyberhorsey/webutils/testutils" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" +) + +func Test_GetUniqueProposers(t *testing.T) { + srv := newTestServer("") + + _, err := srv.eventRepo.Save(context.Background(), eventindexer.SaveEventOpts{ + Name: "name", + Data: `{"Owner": "0x0000000000000000000000000000000000000123"}`, + ChainID: big.NewInt(167001), + Address: "0x123", + Event: eventindexer.EventNameBlockProposed, + }) + + assert.Equal(t, nil, err) + + tests := []struct { + name string + wantStatus int + wantBodyRegexpMatches []string + }{ + { + "successEmptyList", + http.StatusOK, + []string{`\[\]`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := testutils.NewUnauthenticatedRequest( + echo.GET, + "/uniqueProposers", + nil, + ) + + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + testutils.AssertStatusAndBody(t, rec, tt.wantStatus, tt.wantBodyRegexpMatches) + }) + } +} diff --git a/packages/eventindexer/http/get_unique_provers_test.go b/packages/eventindexer/http/get_unique_provers_test.go new file mode 100644 index 0000000000..e6bf5bc426 --- /dev/null +++ b/packages/eventindexer/http/get_unique_provers_test.go @@ -0,0 +1,56 @@ +package http + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cyberhorsey/webutils/testutils" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" +) + +func Test_GetUniqueProvers(t *testing.T) { + srv := newTestServer("") + + _, err := srv.eventRepo.Save(context.Background(), eventindexer.SaveEventOpts{ + Name: "name", + Data: `{"Owner": "0x0000000000000000000000000000000000000123"}`, + ChainID: big.NewInt(167001), + Address: "0x123", + Event: eventindexer.EventNameBlockProven, + }) + + assert.Equal(t, nil, err) + + tests := []struct { + name string + wantStatus int + wantBodyRegexpMatches []string + }{ + { + "successEmptyList", + http.StatusOK, + []string{`\[\]`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := testutils.NewUnauthenticatedRequest( + echo.GET, + "/uniqueProvers", + nil, + ) + + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + testutils.AssertStatusAndBody(t, rec, tt.wantStatus, tt.wantBodyRegexpMatches) + }) + } +} diff --git a/packages/eventindexer/http/routes.go b/packages/eventindexer/http/routes.go index 7e45ea4112..18ad1ff48d 100644 --- a/packages/eventindexer/http/routes.go +++ b/packages/eventindexer/http/routes.go @@ -5,4 +5,6 @@ func (srv *Server) configureRoutes() { srv.echo.GET("/", srv.Health) srv.echo.GET("/uniqueProvers", srv.GetUniqueProvers) + srv.echo.GET("/uniqueProposers", srv.GetUniqueProposers) + srv.echo.GET("/eventByAddress", srv.GetCountByAddressAndEventName) } diff --git a/packages/eventindexer/http/server_test.go b/packages/eventindexer/http/server_test.go new file mode 100644 index 0000000000..dd55f0c44c --- /dev/null +++ b/packages/eventindexer/http/server_test.go @@ -0,0 +1,125 @@ +package http + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/joho/godotenv" + echo "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" + "github.com/taikoxyz/taiko-mono/packages/eventindexer/mock" + "github.com/taikoxyz/taiko-mono/packages/eventindexer/repo" +) + +func newTestServer(url string) *Server { + _ = godotenv.Load("../.test.env") + + srv := &Server{ + echo: echo.New(), + eventRepo: mock.NewEventRepository(), + } + + srv.configureMiddleware([]string{"*"}) + srv.configureRoutes() + srv.configureAndStartPrometheus() + + return srv +} + +func Test_NewServer(t *testing.T) { + tests := []struct { + name string + opts NewServerOpts + wantErr error + }{ + { + "success", + NewServerOpts{ + Echo: echo.New(), + EventRepo: &repo.EventRepository{}, + CorsOrigins: make([]string, 0), + }, + nil, + }, + { + "noEventRepo", + NewServerOpts{ + Echo: echo.New(), + CorsOrigins: make([]string, 0), + }, + eventindexer.ErrNoEventRepository, + }, + { + "noCorsOrigins", + NewServerOpts{ + Echo: echo.New(), + EventRepo: &repo.EventRepository{}, + }, + eventindexer.ErrNoCORSOrigins, + }, + { + "noHttpFramework", + NewServerOpts{ + EventRepo: &repo.EventRepository{}, + CorsOrigins: make([]string, 0), + }, + ErrNoHTTPFramework, + }, + } + + for _, tt := range tests { + _, err := NewServer(tt.opts) + assert.Equal(t, tt.wantErr, err) + } +} + +func Test_Health(t *testing.T) { + srv := newTestServer("") + + req, _ := http.NewRequest(echo.GET, "/healthz", nil) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("Test_Health expected code %v, got %v", http.StatusOK, rec.Code) + } +} + +func Test_Root(t *testing.T) { + srv := newTestServer("") + + req, _ := http.NewRequest(echo.GET, "/", nil) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("Test_Root expected code %v, got %v", http.StatusOK, rec.Code) + } +} + +func Test_Metrics(t *testing.T) { + srv := newTestServer("") + + req, _ := http.NewRequest(echo.GET, "/metrics", nil) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("Test_Metrics expected code %v, got %v", http.StatusOK, rec.Code) + } +} + +func Test_StartShutdown(t *testing.T) { + srv := newTestServer("") + + go func() { + _ = srv.Start(":3928") + }() + assert.Nil(t, srv.Shutdown(context.Background())) +} diff --git a/packages/eventindexer/indexer/filter_then_subscribe.go b/packages/eventindexer/indexer/filter_then_subscribe.go index 5afcbca189..a623e3cd40 100644 --- a/packages/eventindexer/indexer/filter_then_subscribe.go +++ b/packages/eventindexer/indexer/filter_then_subscribe.go @@ -69,6 +69,16 @@ func (svc *Service) FilterThenSubscribe( return errors.Wrap(err, "svc.saveBlockProvenEvents") } + blockProposedEvents, err := svc.taikol1.FilterBlockProposed(filterOpts, nil) + if err != nil { + return errors.Wrap(err, "svc.taikol1.FilterBlockProposed") + } + + err = svc.saveBlockProposedEvents(ctx, chainID, blockProposedEvents) + if err != nil { + return errors.Wrap(err, "svc.saveBlockProposedEvents") + } + header, err := svc.ethClient.HeaderByNumber(ctx, big.NewInt(int64(end))) if err != nil { return errors.Wrap(err, "svc.ethClient.HeaderByNumber") diff --git a/packages/eventindexer/indexer/save_block_proposed_event.go b/packages/eventindexer/indexer/save_block_proposed_event.go new file mode 100644 index 0000000000..a903a7ba01 --- /dev/null +++ b/packages/eventindexer/indexer/save_block_proposed_event.go @@ -0,0 +1,77 @@ +package indexer + +import ( + "context" + "encoding/json" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" + "github.com/taikoxyz/taiko-mono/packages/eventindexer/contracts/taikol1" +) + +func (svc *Service) saveBlockProposedEvents( + ctx context.Context, + chainID *big.Int, + events *taikol1.TaikoL1BlockProposedIterator, +) error { + if !events.Next() || events.Event == nil { + log.Infof("no blockProposed events") + return nil + } + + for { + event := events.Event + + if event.Raw.Removed { + continue + } + + tx, _, err := svc.ethClient.TransactionByHash(ctx, event.Raw.TxHash) + if err != nil { + return errors.Wrap(err, "svc.ethClient.TransactionByHash") + } + + sender, err := svc.ethClient.TransactionSender(ctx, tx, event.Raw.BlockHash, event.Raw.TxIndex) + if err != nil { + return errors.Wrap(err, "svc.ethClient.TransactionSender") + } + + log.Infof("blockProposed by: %v", sender.Hex()) + + if err := svc.saveBlockProposedEvent(ctx, chainID, event, sender); err != nil { + return errors.Wrap(err, "svc.saveBlockProposedEvent") + } + + if !events.Next() { + return nil + } + } +} + +func (svc *Service) saveBlockProposedEvent( + ctx context.Context, + chainID *big.Int, + event *taikol1.TaikoL1BlockProposed, + sender common.Address, +) error { + marshaled, err := json.Marshal(event) + if err != nil { + return errors.Wrap(err, "json.Marshal(event)") + } + + _, err = svc.eventRepo.Save(ctx, eventindexer.SaveEventOpts{ + Name: eventindexer.EventNameBlockProposed, + Data: string(marshaled), + ChainID: chainID, + Event: eventindexer.EventNameBlockProposed, + Address: sender.Hex(), + }) + if err != nil { + return errors.Wrap(err, "svc.eventRepo.Save") + } + + return nil +} diff --git a/packages/eventindexer/indexer/service.go b/packages/eventindexer/indexer/service.go index eb9b66c1d1..8788a5fdfd 100644 --- a/packages/eventindexer/indexer/service.go +++ b/packages/eventindexer/indexer/service.go @@ -1,14 +1,10 @@ package indexer import ( - "context" - "math/big" "time" "github.com/cyberhorsey/errors" - "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" "github.com/taikoxyz/taiko-mono/packages/eventindexer" @@ -19,16 +15,10 @@ var ( ZeroAddress = common.HexToAddress("0x0000000000000000000000000000000000000000") ) -type ethClient interface { - ChainID(ctx context.Context) (*big.Int, error) - HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) - SubscribeNewHead(ctx context.Context, ch chan<- *types.Header) (ethereum.Subscription, error) -} - type Service struct { eventRepo eventindexer.EventRepository blockRepo eventindexer.BlockRepository - ethClient ethClient + ethClient *ethclient.Client processingBlockHeight uint64 diff --git a/packages/eventindexer/mock/block_repository.go b/packages/eventindexer/mock/block_repository.go new file mode 100644 index 0000000000..0ad40f1e7f --- /dev/null +++ b/packages/eventindexer/mock/block_repository.go @@ -0,0 +1,31 @@ +package mock + +import ( + "errors" + "math/big" + + "github.com/taikoxyz/taiko-mono/packages/eventindexer" +) + +var ( + LatestBlock = &eventindexer.Block{ + Height: 100, + Hash: "0x", + ChainID: MockChainID.Int64(), + } +) + +type BlockRepository struct { +} + +func (r *BlockRepository) Save(opts eventindexer.SaveBlockOpts) error { + return nil +} + +func (r *BlockRepository) GetLatestBlockProcessedForEvent(chainID *big.Int) (*eventindexer.Block, error) { + if chainID.Int64() != MockChainID.Int64() { + return nil, errors.New("error getting latest block processed for event") + } + + return LatestBlock, nil +} diff --git a/packages/eventindexer/mock/event_repository.go b/packages/eventindexer/mock/event_repository.go new file mode 100644 index 0000000000..9c9fe9a34c --- /dev/null +++ b/packages/eventindexer/mock/event_repository.go @@ -0,0 +1,59 @@ +package mock + +import ( + "context" + "math/rand" + + "github.com/taikoxyz/taiko-mono/packages/eventindexer" + "gorm.io/datatypes" +) + +type EventRepository struct { + events []*eventindexer.Event +} + +func NewEventRepository() *EventRepository { + return &EventRepository{ + events: make([]*eventindexer.Event, 0), + } +} +func (r *EventRepository) Save(ctx context.Context, opts eventindexer.SaveEventOpts) (*eventindexer.Event, error) { + r.events = append(r.events, &eventindexer.Event{ + ID: rand.Int(), // nolint: gosec + Data: datatypes.JSON(opts.Data), + ChainID: opts.ChainID.Int64(), + Name: opts.Name, + Event: opts.Event, + Address: opts.Address, + }) + + return nil, nil +} + +func (r *EventRepository) FindUniqueProposers( + ctx context.Context, +) ([]eventindexer.UniqueProposersResponse, error) { + return make([]eventindexer.UniqueProposersResponse, 0), nil +} + +func (r *EventRepository) FindUniqueProvers( + ctx context.Context, +) ([]eventindexer.UniqueProversResponse, error) { + return make([]eventindexer.UniqueProversResponse, 0), nil +} + +func (r *EventRepository) GetCountByAddressAndEventName( + ctx context.Context, + address string, + event string, +) (int, error) { + var count int = 0 + + for _, e := range r.events { + if e.Address == address && e.Event == event { + count++ + } + } + + return count, nil +} diff --git a/packages/eventindexer/mock/types.go b/packages/eventindexer/mock/types.go new file mode 100644 index 0000000000..cb5819b8a9 --- /dev/null +++ b/packages/eventindexer/mock/types.go @@ -0,0 +1,10 @@ +package mock + +import ( + "math/big" +) + +var ( + MockChainID = big.NewInt(167001) + LatestBlockNumber = big.NewInt(10) +) diff --git a/packages/eventindexer/repo/block_test.go b/packages/eventindexer/repo/block_test.go new file mode 100644 index 0000000000..eb25aba9bc --- /dev/null +++ b/packages/eventindexer/repo/block_test.go @@ -0,0 +1,99 @@ +package repo + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" + "github.com/taikoxyz/taiko-mono/packages/eventindexer/db" + "gopkg.in/go-playground/assert.v1" +) + +func Test_NewBlockRepo(t *testing.T) { + tests := []struct { + name string + db eventindexer.DB + wantErr error + }{ + { + "success", + &db.DB{}, + nil, + }, + { + "noDb", + nil, + eventindexer.ErrNoDB, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewBlockRepository(tt.db) + assert.Equal(t, tt.wantErr, err) + }) + } +} + +func TestIntegration_Block_Save(t *testing.T) { + db, close, err := testMysql(t) + assert.Equal(t, nil, err) + + defer close() + + blockRepo, err := NewBlockRepository(db) + assert.Equal(t, nil, err) + tests := []struct { + name string + opts eventindexer.SaveBlockOpts + wantErr error + }{ + { + "success", + eventindexer.SaveBlockOpts{ + ChainID: big.NewInt(1), + Height: 100, + Hash: common.HexToHash("0x1234"), + }, + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err = blockRepo.Save(tt.opts) + assert.Equal(t, tt.wantErr, err) + }) + } +} + +func TestIntegration_Block_GetLatestBlockProcessedForEvent(t *testing.T) { + db, close, err := testMysql(t) + assert.Equal(t, nil, err) + + defer close() + + blockRepo, err := NewBlockRepository(db) + assert.Equal(t, nil, err) + tests := []struct { + name string + eventName string + chainID *big.Int + wantErr error + }{ + { + "success", + eventindexer.EventNameBlockProposed, + big.NewInt(1), + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := blockRepo.GetLatestBlockProcessed(tt.chainID) + assert.Equal(t, tt.wantErr, err) + }) + } +} diff --git a/packages/eventindexer/repo/containers_test.go b/packages/eventindexer/repo/containers_test.go new file mode 100644 index 0000000000..53eff7bcc8 --- /dev/null +++ b/packages/eventindexer/repo/containers_test.go @@ -0,0 +1,77 @@ +package repo + +import ( + "context" + "fmt" + "testing" + + "github.com/pressly/goose/v3" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" + "github.com/taikoxyz/taiko-mono/packages/eventindexer/db" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var ( + dbName = "indexer" + dbUsername = "root" + dbPassword = "password" +) + +func testMysql(t *testing.T) (eventindexer.DB, func(), error) { + req := testcontainers.ContainerRequest{ + Image: "mysql:latest", + ExposedPorts: []string{"3306/tcp", "33060/tcp"}, + Env: map[string]string{ + "MYSQL_ROOT_PASSWORD": dbPassword, + "MYSQL_DATABASE": dbName, + }, + WaitingFor: wait.ForLog("port: 3306 MySQL Community Server - GPL"), + } + + ctx := context.Background() + + mysqlC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + + if err != nil { + t.Fatal(err) + } + + closeContainer := func() { + err := mysqlC.Terminate(ctx) + if err != nil { + t.Fatal(err) + } + } + + host, _ := mysqlC.Host(ctx) + p, _ := mysqlC.MappedPort(ctx, "3306/tcp") + port := p.Int() + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?tls=skip-verify&parseTime=true&multiStatements=true", + dbUsername, dbPassword, host, port, dbName) + + gormDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatal(err) + } + + if err := goose.SetDialect("mysql"); err != nil { + t.Fatal(err) + } + + sqlDB, _ := gormDB.DB() + if err := goose.Up(sqlDB, "../migrations"); err != nil { + t.Fatal(err) + } + + return db.New(gormDB), closeContainer, nil +} diff --git a/packages/eventindexer/repo/event.go b/packages/eventindexer/repo/event.go index c2a93c1457..fd877465fd 100644 --- a/packages/eventindexer/repo/event.go +++ b/packages/eventindexer/repo/event.go @@ -44,10 +44,42 @@ func (r *EventRepository) FindUniqueProvers( addrs := make([]eventindexer.UniqueProversResponse, 0) if err := r.db.GormDB(). - Raw("SELECT address, count(*) AS count FROM events GROUP BY address"). + Raw("SELECT address, count(*) AS count FROM events WHERE event = ? GROUP BY address", + eventindexer.EventNameBlockProven). FirstOrInit(&addrs).Error; err != nil { return nil, errors.Wrap(err, "r.db.FirstOrInit") } return addrs, nil } + +func (r *EventRepository) FindUniqueProposers( + ctx context.Context, +) ([]eventindexer.UniqueProposersResponse, error) { + addrs := make([]eventindexer.UniqueProposersResponse, 0) + + if err := r.db.GormDB(). + Raw("SELECT address, count(*) AS count FROM events WHERE event = ? GROUP BY address", + eventindexer.EventNameBlockProposed). + FirstOrInit(&addrs).Error; err != nil { + return nil, errors.Wrap(err, "r.db.FirstOrInit") + } + + return addrs, nil +} + +func (r *EventRepository) GetCountByAddressAndEventName( + ctx context.Context, + address string, + event string, +) (int, error) { + var count int + + if err := r.db.GormDB(). + Raw("SELECT count(*) AS count FROM events WHERE event = ? AND address = ?", event, address). + FirstOrInit(&count).Error; err != nil { + return 0, errors.Wrap(err, "r.db.FirstOrInit") + } + + return count, nil +} diff --git a/packages/eventindexer/repo/event_test.go b/packages/eventindexer/repo/event_test.go new file mode 100644 index 0000000000..1bdfa17de8 --- /dev/null +++ b/packages/eventindexer/repo/event_test.go @@ -0,0 +1,211 @@ +package repo + +import ( + "context" + "math/big" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/taikoxyz/taiko-mono/packages/eventindexer" + "gotest.tools/assert" +) + +var ( + dummyProveEventOpts = eventindexer.SaveEventOpts{ + Name: eventindexer.EventNameBlockProven, + Address: "0x123", + Data: "{\"data\":\"something\"}", + Event: eventindexer.EventNameBlockProven, + ChainID: big.NewInt(1), + } + dummyProposeEventOpts = eventindexer.SaveEventOpts{ + Name: eventindexer.EventNameBlockProposed, + Address: "0x123", + Data: "{\"data\":\"something\"}", + Event: eventindexer.EventNameBlockProposed, + ChainID: big.NewInt(1), + } +) + +func TestIntegration_Event_Save(t *testing.T) { + db, close, err := testMysql(t) + assert.Equal(t, nil, err) + + defer close() + + eventRepo, err := NewEventRepository(db) + assert.Equal(t, nil, err) + tests := []struct { + name string + opts eventindexer.SaveEventOpts + wantErr error + }{ + { + "success", + eventindexer.SaveEventOpts{ + Name: "test", + ChainID: big.NewInt(1), + Data: "{\"data\":\"something\"}", + Event: eventindexer.EventNameBlockProposed, + Address: "0x123", + }, + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err = eventRepo.Save(context.Background(), tt.opts) + assert.Equal(t, tt.wantErr, err) + }) + } +} + +func TestIntegration_Event_FindUniqueProvers(t *testing.T) { + db, close, err := testMysql(t) + assert.Equal(t, nil, err) + + defer close() + + eventRepo, err := NewEventRepository(db) + assert.Equal(t, nil, err) + + _, err = eventRepo.Save(context.Background(), dummyProveEventOpts) + + assert.Equal(t, nil, err) + + _, err = eventRepo.Save(context.Background(), dummyProposeEventOpts) + + assert.Equal(t, nil, err) + + tests := []struct { + name string + wantResp []eventindexer.UniqueProversResponse + wantErr error + }{ + { + "success", + []eventindexer.UniqueProversResponse{ + { + Address: "0x123", + Count: 1, + }, + }, + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := eventRepo.FindUniqueProvers(context.Background()) + assert.Equal(t, tt.wantErr, err) + assert.Equal(t, len(tt.wantResp), len(resp)) + for k, v := range resp { + assert.Equal(t, tt.wantResp[k].Address, v.Address) + assert.Equal(t, tt.wantResp[k].Count, v.Count) + } + }) + } +} + +func TestIntegration_Event_FindUniqueProposers(t *testing.T) { + db, close, err := testMysql(t) + assert.Equal(t, nil, err) + + defer close() + + eventRepo, err := NewEventRepository(db) + assert.Equal(t, nil, err) + + _, err = eventRepo.Save(context.Background(), dummyProveEventOpts) + + assert.Equal(t, nil, err) + + _, err = eventRepo.Save(context.Background(), dummyProposeEventOpts) + + assert.Equal(t, nil, err) + + tests := []struct { + name string + wantResp []eventindexer.UniqueProposersResponse + wantErr error + }{ + { + "success", + []eventindexer.UniqueProposersResponse{ + { + Address: dummyProposeEventOpts.Address, + Count: 1, + }, + }, + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := eventRepo.FindUniqueProposers(context.Background()) + spew.Dump(resp) + assert.Equal(t, tt.wantErr, err) + assert.Equal(t, len(tt.wantResp), len(resp)) + for k, v := range resp { + assert.Equal(t, tt.wantResp[k].Address, v.Address) + assert.Equal(t, tt.wantResp[k].Count, v.Count) + } + }) + } +} + +func TestIntegration_Event_GetCountByAddressAndEventName(t *testing.T) { + db, close, err := testMysql(t) + assert.Equal(t, nil, err) + + defer close() + + eventRepo, err := NewEventRepository(db) + assert.Equal(t, nil, err) + + _, err = eventRepo.Save(context.Background(), dummyProveEventOpts) + + assert.Equal(t, nil, err) + + _, err = eventRepo.Save(context.Background(), dummyProposeEventOpts) + + assert.Equal(t, nil, err) + + tests := []struct { + name string + address string + event string + wantResp int + wantErr error + }{ + { + "success", + dummyProposeEventOpts.Address, + dummyProposeEventOpts.Event, + 1, + nil, + }, + { + "none", + "0xfake", + dummyProposeEventOpts.Event, + 0, + nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := eventRepo.GetCountByAddressAndEventName( + context.Background(), + tt.address, + tt.event, + ) + spew.Dump(resp) + assert.Equal(t, tt.wantErr, err) + assert.Equal(t, tt.wantResp, resp) + }) + } +} diff --git a/packages/status-page/src/pages/home/Home.svelte b/packages/status-page/src/pages/home/Home.svelte index 7b3ac22c83..0cb3f766e0 100644 --- a/packages/status-page/src/pages/home/Home.svelte +++ b/packages/status-page/src/pages/home/Home.svelte @@ -18,6 +18,7 @@ import { truncateString } from "../../utils/truncateString"; import TaikoL1 from "../../constants/abi/TaikoL1"; import { getNumProvers } from "../../utils/getNumProvers"; + import { getNumProposers } from "../../utils/getNumProposers"; import DetailsModal from "../../components/DetailsModal.svelte"; import { addressSubsection } from "../../utils/addressSubsection"; @@ -32,6 +33,7 @@ export let eventIndexerApiUrl: string; let proverDetailsOpen: boolean = false; + let proposerDetailsOpen: boolean = false; let statusIndicators: StatusIndicatorProp[] = [ { @@ -52,6 +54,24 @@ tooltip: "The number of unique provers who successfully submitted a proof to the TaikoL1 smart contract.", }, + { + statusFunc: async ( + provider: ethers.providers.JsonRpcProvider, + address: string + ) => (await getNumProposers(eventIndexerApiUrl)).uniqueProposers, + provider: l1Provider, + contractAddress: l1TaikoAddress, + header: "Unique Proposers", + intervalInMs: 0, + colorFunc: (value: Status) => { + return "green"; + }, + onClick: (value: Status) => { + proposerDetailsOpen = true; + }, + tooltip: + "The number of unique proposers who successfully submitted a proposed block to the TaikoL1 smart contract.", + }, { statusFunc: getLatestSyncedHeader, watchStatusFunc: watchHeaderSynced, @@ -256,7 +276,7 @@ (id, parentHash, blockHash, prover, provenAt, ...args) => { // ignore oracle prover if (prover.toLowerCase() !== oracleProverAddress.toLowerCase()) { - if(!provenAt.eq(0)) { + if (!provenAt.eq(0)) { onEvent(new Date(provenAt.toNumber() * 1000).toString()); } } @@ -380,3 +400,27 @@ {/if} + +{#if proposerDetailsOpen} + +
+ {#await getNumProposers(eventIndexerApiUrl) then proposers} + {#each proposers.proposers as proposer} + + {addressSubsection(proposer.address)} + +
{proposer.count}
+ {/each} + {:catch error} +

{error.message}

+ {/await} +
+
+{/if} diff --git a/packages/status-page/src/utils/getNumProposers.ts b/packages/status-page/src/utils/getNumProposers.ts new file mode 100644 index 0000000000..a37ae0988f --- /dev/null +++ b/packages/status-page/src/utils/getNumProposers.ts @@ -0,0 +1,23 @@ +import axios from "axios"; + +export type UniqueProposer = { + address: string; + count: number; +}; +export type UniqueProverResponse = { + uniqueProposers: number; + proposers: UniqueProposer[]; +}; + +export const getNumProposers = async ( + eventIndexerApiUrl: string +): Promise => { + const uniqueProposersResp = await axios.get( + `${eventIndexerApiUrl}/uniqueProposers` + ); + if (uniqueProposersResp.data) { + uniqueProposersResp.data.proposers.sort((a, b) => b.count - a.count); + } + + return uniqueProposersResp.data || { uniqueProposers: 0, proposers: [] }; +};