diff --git a/components/restapi/component.go b/components/restapi/component.go index 4e624c6e2..59447c3d1 100644 --- a/components/restapi/component.go +++ b/components/restapi/component.go @@ -8,6 +8,7 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/labstack/gommon/bytes" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/host" "go.uber.org/dig" @@ -88,7 +89,12 @@ func provide(c *dig.Container) error { } if err := c.Provide(func(deps requestHandlerDeps) *requesthandler.RequestHandler { - return requesthandler.New(deps.Protocol) + maxCacheSizeBytes, err := bytes.Parse(ParamsRestAPI.MaxCacheSize) + if err != nil { + Component.LogPanicf("parameter %s invalid", Component.App().Config().GetParameterPath(&(ParamsRestAPI.MaxCacheSize))) + } + + return requesthandler.New(deps.Protocol, requesthandler.WithCacheMaxSizeOptions(int(maxCacheSizeBytes))) }); err != nil { Component.LogPanic(err.Error()) } diff --git a/components/restapi/core/accounts.go b/components/restapi/core/accounts.go index 5e7a4babf..c6869c53e 100644 --- a/components/restapi/core/accounts.go +++ b/components/restapi/core/accounts.go @@ -61,20 +61,23 @@ func validators(c echo.Context) (*api.ValidatorsResponse, error) { var err error pageSize := httpserver.ParsePageSizeQueryParam(c, api.ParameterPageSize, restapi.ParamsRestAPI.MaxPageSize) latestCommittedSlot := deps.RequestHandler.GetLatestCommitment().Slot() + currentEpoch := deps.RequestHandler.APIProvider().APIForSlot(latestCommittedSlot).TimeProvider().EpochFromSlot(latestCommittedSlot) + requestedEpoch := currentEpoch // no cursor provided will be the first request - requestedSlot := latestCommittedSlot var cursorIndex uint32 if len(c.QueryParam(api.ParameterCursor)) != 0 { - requestedSlot, cursorIndex, err = httpserver.ParseSlotCursorQueryParam(c, api.ParameterCursor) + requestedEpoch, cursorIndex, err = httpserver.ParseEpochCursorQueryParam(c, api.ParameterCursor) if err != nil { return nil, err } } - slotRange := uint32(requestedSlot) / restapi.ParamsRestAPI.RequestsMemoryCacheGranularity + if requestedEpoch > currentEpoch || requestedEpoch <= deps.RequestHandler.GetNodeStatus().PruningEpoch { + return nil, ierrors.Wrapf(echo.ErrBadRequest, "epoch %d is larger than current epoch or already pruned", requestedEpoch) + } - return deps.RequestHandler.Validators(slotRange, cursorIndex, pageSize) + return deps.RequestHandler.Validators(requestedEpoch, cursorIndex, pageSize) } func validatorByAccountAddress(c echo.Context) (*api.ValidatorResponse, error) { diff --git a/components/restapi/params.go b/components/restapi/params.go index 12f3d6a3d..0fdf1bce4 100644 --- a/components/restapi/params.go +++ b/components/restapi/params.go @@ -16,10 +16,8 @@ type ParametersRestAPI struct { DebugRequestLoggerEnabled bool `default:"false" usage:"whether the debug logging for requests should be enabled"` // MaxPageSize defines the maximum number of results per page. MaxPageSize uint32 `default:"100" usage:"the maximum number of results per page"` - // RequestsMemoryCacheGranularity defines per how many slots a cache is created for big API requests. - RequestsMemoryCacheGranularity uint32 `default:"10" usage:"defines per how many slots a cache is created for big API requests"` - // MaxRequestedSlotAge defines the maximum age of a request that will be processed. - MaxRequestedSlotAge uint32 `default:"10" usage:"the maximum age of a request that will be processed"` + // MaxCacheSize defines the maximum size of cache for results. + MaxCacheSize string `default:"50MB" usage:"the maximum size of cache for results"` JWTAuth struct { // salt used inside the JWT tokens for the REST API. Change this to a different value to invalidate JWT tokens not matching this new value diff --git a/config_defaults.json b/config_defaults.json index f216849fd..5ef1234ff 100644 --- a/config_defaults.json +++ b/config_defaults.json @@ -64,8 +64,7 @@ ], "debugRequestLoggerEnabled": false, "maxPageSize": 100, - "requestsMemoryCacheGranularity": 10, - "maxRequestedSlotAge": 10, + "maxCacheSize": "50MB", "jwtAuth": { "salt": "IOTA" }, diff --git a/documentation/configuration.md b/documentation/configuration.md index d81021021..1c635f49d 100644 --- a/documentation/configuration.md +++ b/documentation/configuration.md @@ -166,17 +166,16 @@ Example: ## 5. RestAPI -| Name | Description | Type | Default value | -| ------------------------------ | ---------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| bindAddress | The bind address on which the REST API listens on | string | "0.0.0.0:14265" | -| publicRoutes | The HTTP REST routes which can be called without authorization. Wildcards using \* are allowed | array | /health
/api/routes
/api/core/v3/info
/api/core/v3/network\*
/api/core/v3/blocks\*
/api/core/v3/transactions\*
/api/core/v3/commitments\*
/api/core/v3/outputs\*
/api/core/v3/accounts\*
/api/core/v3/validators\*
/api/core/v3/rewards\*
/api/core/v3/committee\*
/api/debug/v2/\*
/api/indexer/v2/\*
/api/mqtt/v2
/api/blockissuer/v1/\* | -| protectedRoutes | The HTTP REST routes which need to be called with authorization. Wildcards using \* are allowed | array | /api/\* | -| debugRequestLoggerEnabled | Whether the debug logging for requests should be enabled | boolean | false | -| maxPageSize | The maximum number of results per page | uint | 100 | -| requestsMemoryCacheGranularity | Defines per how many slots a cache is created for big API requests | uint | 10 | -| maxRequestedSlotAge | The maximum age of a request that will be processed | uint | 10 | -| [jwtAuth](#restapi_jwtauth) | Configuration for jwtAuth | object | | -| [limits](#restapi_limits) | Configuration for limits | object | | +| Name | Description | Type | Default value | +| --------------------------- | ---------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| bindAddress | The bind address on which the REST API listens on | string | "0.0.0.0:14265" | +| publicRoutes | The HTTP REST routes which can be called without authorization. Wildcards using \* are allowed | array | /health
/api/routes
/api/core/v3/info
/api/core/v3/network\*
/api/core/v3/blocks\*
/api/core/v3/transactions\*
/api/core/v3/commitments\*
/api/core/v3/outputs\*
/api/core/v3/accounts\*
/api/core/v3/validators\*
/api/core/v3/rewards\*
/api/core/v3/committee\*
/api/debug/v2/\*
/api/indexer/v2/\*
/api/mqtt/v2
/api/blockissuer/v1/\* | +| protectedRoutes | The HTTP REST routes which need to be called with authorization. Wildcards using \* are allowed | array | /api/\* | +| debugRequestLoggerEnabled | Whether the debug logging for requests should be enabled | boolean | false | +| maxPageSize | The maximum number of results per page | uint | 100 | +| maxCacheSize | The maximum size of cache for results | string | "50MB" | +| [jwtAuth](#restapi_jwtauth) | Configuration for jwtAuth | object | | +| [limits](#restapi_limits) | Configuration for limits | object | | ### JwtAuth @@ -220,8 +219,7 @@ Example: ], "debugRequestLoggerEnabled": false, "maxPageSize": 100, - "requestsMemoryCacheGranularity": 10, - "maxRequestedSlotAge": 10, + "maxCacheSize": "50MB", "jwtAuth": { "salt": "IOTA" }, diff --git a/go.mod b/go.mod index 34af69c59..f5d7c920f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22.0 replace github.com/goccy/go-graphviz => github.com/alexsporn/go-graphviz v0.0.0-20231011102718-04f10f0a9b59 require ( + github.com/VictoriaMetrics/fastcache v1.12.2 github.com/fjl/memsize v0.0.2 github.com/goccy/go-graphviz v0.1.2 github.com/golang-jwt/jwt v3.2.2+incompatible @@ -79,6 +80,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect github.com/google/go-github v17.0.0+incompatible // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gopacket v1.1.19 // indirect diff --git a/go.sum b/go.sum index 7fab99c65..ae424462a 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= +github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -17,6 +19,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alexsporn/go-graphviz v0.0.0-20231011102718-04f10f0a9b59 h1:6900O8HwoKgovNmOy7q4E2R6DurhAOrqe1WQv0EvUv8= github.com/alexsporn/go-graphviz v0.0.0-20231011102718-04f10f0a9b59/go.mod h1:lpnwvVDjskayq84ZxG8tGCPeZX/WxP88W+OJajh+gFk= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= @@ -183,6 +187,9 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -840,6 +847,7 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/pkg/requesthandler/accounts.go b/pkg/requesthandler/accounts.go index 4317ed163..34069291e 100644 --- a/pkg/requesthandler/accounts.go +++ b/pkg/requesthandler/accounts.go @@ -33,35 +33,40 @@ func (r *RequestHandler) CongestionByAccountAddress(accountAddress *iotago.Accou }, nil } -func (r *RequestHandler) Validators(slotRange, cursorIndex, pageSize uint32) (*api.ValidatorsResponse, error) { - latestCommittedSlot := r.protocol.Engines.Main.Get().SyncManager.LatestCommitment().Slot() - latestEpoch := r.protocol.APIForSlot(latestCommittedSlot).TimeProvider().EpochFromSlot(latestCommittedSlot) - - // TODO: Move into the api cache package - //registeredValidators, exists := r.protocol.Engines.Main.Get().BlockRetainer.RegisteredValidatorsCache(slotRange) - //if !exists { - // registeredValidators, err := r.protocol.Engines.Main.Get().SybilProtection.OrderedRegisteredCandidateValidatorsList(latestEpoch) - // if err != nil { - // return nil, ierrors.Wrapf(echo.ErrInternalServerError, "failed to get ordered registered validators list for epoch %d : %s", latestEpoch, err) - // } - // r.protocol.Engines.Main.Get().BlockRetainer.RetainRegisteredValidatorsCache(slotRange, registeredValidators) - //} - - registeredValidators, err := r.protocol.Engines.Main.Get().SybilProtection.OrderedRegisteredCandidateValidatorsList(latestEpoch) +func (r *RequestHandler) Validators(epochIndex iotago.EpochIndex, cursorIndex, pageSize uint32) (*api.ValidatorsResponse, error) { + apiForEpoch := r.APIProvider().APIForEpoch(epochIndex) + currentSlot := r.protocol.Engines.Main.Get().SyncManager.LatestCommitment().Slot() + currentEpoch := apiForEpoch.TimeProvider().EpochFromSlot(currentSlot) + var registeredValidators []*api.ValidatorResponse + var err error + + key := epochIndex.MustBytes() + // The key of validators cache for current epoch is the combination of current epoch and current slot. So the results is updated when new commitment is created. + if epochIndex == currentEpoch { + key = append(key, currentSlot.MustBytes()...) + } + + // get registered validators from cache, if not found, get from engine and store in cache. + registeredValidators, err = r.cache.GetOrCreateRegisteredValidators(apiForEpoch, key, func() ([]*api.ValidatorResponse, error) { + return r.protocol.Engines.Main.Get().SybilProtection.OrderedRegisteredCandidateValidatorsList(epochIndex) + }) if err != nil { - return nil, ierrors.Join(echo.ErrInternalServerError, ierrors.Wrapf(err, "failed to get ordered registered validators list for epoch %d", latestEpoch)) + return nil, ierrors.Wrapf(err, "failed to get registered validators for epoch %d", epochIndex) + } + + if cursorIndex >= uint32(len(registeredValidators)) { + return nil, ierrors.Wrapf(echo.ErrBadRequest, "invalid pagination cursorIndex, cursorIndex %d is larger than the number of registered validators %d", cursorIndex, len(registeredValidators)) } - page := registeredValidators[cursorIndex:lo.Min(cursorIndex+pageSize, uint32(len(registeredValidators)))] + pageEndIndex := lo.Min(cursorIndex+pageSize, uint32(len(registeredValidators))) + page := registeredValidators[cursorIndex:pageEndIndex] resp := &api.ValidatorsResponse{ Validators: page, PageSize: pageSize, } // this is the last page - if int(cursorIndex+pageSize) > len(registeredValidators) { - resp.Cursor = "" - } else { - resp.Cursor = fmt.Sprintf("%d,%d", slotRange, cursorIndex+pageSize) + if int(cursorIndex+pageSize) <= len(registeredValidators) { + resp.Cursor = fmt.Sprintf("%d,%d", epochIndex, cursorIndex+pageSize) } return resp, nil diff --git a/pkg/requesthandler/cache/cache.go b/pkg/requesthandler/cache/cache.go new file mode 100644 index 000000000..647af6b0e --- /dev/null +++ b/pkg/requesthandler/cache/cache.go @@ -0,0 +1,71 @@ +package cache + +import ( + "github.com/VictoriaMetrics/fastcache" + "github.com/labstack/echo/v4" + + "github.com/iotaledger/hive.go/ierrors" + "github.com/iotaledger/hive.go/serializer/v2/serix" + iotago "github.com/iotaledger/iota.go/v4" + "github.com/iotaledger/iota.go/v4/api" +) + +var validatorResponsesTypeSettings = serix.TypeSettings{}.WithLengthPrefixType(serix.LengthPrefixTypeAsByte) + +type Cache struct { + cache *fastcache.Cache +} + +func NewCache(maxSize int) *Cache { + return &Cache{ + cache: fastcache.New(maxSize), + } +} + +func (c *Cache) Set(key, value []byte) { + c.cache.Set(key, value) +} + +func (c *Cache) Get(key []byte) []byte { + if c.cache.Has(key) { + value := make([]byte, 0) + + return c.cache.Get(value, key) + } + + return nil +} + +func (c *Cache) Reset() { + c.cache.Reset() +} + +func (c *Cache) GetOrCreateRegisteredValidators(apiForEpoch iotago.API, key []byte, defaultValueFunc func() ([]*api.ValidatorResponse, error)) ([]*api.ValidatorResponse, error) { + + registeredValidatorsBytes := c.Get(key) + if registeredValidatorsBytes == nil { + // get the ordered registered validators list from engine. + registeredValidators, err := defaultValueFunc() + if err != nil { + return nil, ierrors.Wrapf(echo.ErrNotFound, "ordered registered validators list not found: %w", err) + } + + // store validator responses in cache. + registeredValidatorsBytes, err := apiForEpoch.Encode(registeredValidators, serix.WithTypeSettings(validatorResponsesTypeSettings)) + if err != nil { + return nil, ierrors.Wrapf(echo.ErrInternalServerError, "failed to encode registered validators list: %w", err) + } + + c.Set(key, registeredValidatorsBytes) + + return registeredValidators, nil + } + + validatorResp := make([]*api.ValidatorResponse, 0) + _, err := apiForEpoch.Decode(registeredValidatorsBytes, &validatorResp, serix.WithTypeSettings(validatorResponsesTypeSettings)) + if err != nil { + return nil, err + } + + return validatorResp, nil +} diff --git a/pkg/requesthandler/requesthandler.go b/pkg/requesthandler/requesthandler.go index ca28431dd..6d06ae753 100644 --- a/pkg/requesthandler/requesthandler.go +++ b/pkg/requesthandler/requesthandler.go @@ -1,22 +1,30 @@ package requesthandler import ( + "github.com/iotaledger/hive.go/runtime/options" "github.com/iotaledger/hive.go/runtime/workerpool" "github.com/iotaledger/iota-core/pkg/protocol" + "github.com/iotaledger/iota-core/pkg/requesthandler/cache" ) // RequestHandler contains the logic to handle api requests. type RequestHandler struct { workerPool *workerpool.WorkerPool + cache *cache.Cache protocol *protocol.Protocol + + optsCacheMaxSize int } -func New(p *protocol.Protocol) *RequestHandler { - return &RequestHandler{ - workerPool: p.Workers.CreatePool("BlockHandler"), - protocol: p, - } +func New(p *protocol.Protocol, opts ...options.Option[RequestHandler]) *RequestHandler { + return options.Apply(&RequestHandler{ + workerPool: p.Workers.CreatePool("BlockHandler"), + protocol: p, + optsCacheMaxSize: 50 << 20, // 50MB + }, opts, func(r *RequestHandler) { + r.cache = cache.NewCache(r.optsCacheMaxSize) + }) } // Shutdown shuts down the block issuer. @@ -24,3 +32,9 @@ func (r *RequestHandler) Shutdown() { r.workerPool.Shutdown() r.workerPool.ShutdownComplete.Wait() } + +func WithCacheMaxSizeOptions(size int) options.Option[RequestHandler] { + return func(r *RequestHandler) { + r.optsCacheMaxSize = size + } +} diff --git a/pkg/requesthandler/requesthandler_test.go b/pkg/requesthandler/requesthandler_test.go new file mode 100644 index 000000000..b78d18f9e --- /dev/null +++ b/pkg/requesthandler/requesthandler_test.go @@ -0,0 +1,195 @@ +package requesthandler + +import ( + "testing" + + "github.com/iotaledger/hive.go/lo" + "github.com/iotaledger/hive.go/runtime/options" + "github.com/iotaledger/iota-core/pkg/protocol" + "github.com/iotaledger/iota-core/pkg/protocol/engine/notarization/slotnotarization" + "github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/seatmanager/topstakers" + "github.com/iotaledger/iota-core/pkg/protocol/sybilprotection/sybilprotectionv1" + "github.com/iotaledger/iota-core/pkg/testsuite" + iotago "github.com/iotaledger/iota.go/v4" + "github.com/iotaledger/iota.go/v4/api" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +func Test_ValidatorsAPI(t *testing.T) { + ts := testsuite.NewTestSuite(t, + testsuite.WithProtocolParametersOptions( + iotago.WithTimeProviderOptions( + 0, + testsuite.GenesisTimeWithOffsetBySlots(200, testsuite.DefaultSlotDurationInSeconds), + testsuite.DefaultSlotDurationInSeconds, + 4, + ), + iotago.WithLivenessOptions( + 10, + 10, + 2, + 4, + 5, + ), + iotago.WithTargetCommitteeSize(3), + ), + ) + defer ts.Shutdown() + + node1 := ts.AddValidatorNode("node1", testsuite.WithWalletAmount(1_000_006)) + ts.AddValidatorNode("node2", testsuite.WithWalletAmount(1_000_005)) + ts.AddValidatorNode("node3", testsuite.WithWalletAmount(1_000_004)) + ts.AddValidatorNode("node4", testsuite.WithWalletAmount(1_000_003)) + ts.AddDefaultWallet(node1) + + nodeOpts := []options.Option[protocol.Protocol]{ + protocol.WithNotarizationProvider( + slotnotarization.NewProvider(), + ), + protocol.WithSybilProtectionProvider( + sybilprotectionv1.NewProvider( + sybilprotectionv1.WithSeatManagerProvider( + topstakers.NewProvider(), + ), + ), + ), + } + + ts.Run(true, map[string][]options.Option[protocol.Protocol]{ + "node1": nodeOpts, + "node2": nodeOpts, + "node3": nodeOpts, + "node4": nodeOpts, + }) + + ts.AssertSybilProtectionCommittee(0, []iotago.AccountID{ + ts.Node("node1").Validator.AccountID, + ts.Node("node2").Validator.AccountID, + ts.Node("node3").Validator.AccountID, + }, ts.Nodes()...) + + requestHandler := New(ts.DefaultWallet().Node.Protocol) + hrp := ts.API.ProtocolParameters().Bech32HRP() + + // Epoch 0, assert that node1 and node4 are the only candidates. + { + ts.IssueBlocksAtSlots("wave-1:", []iotago.SlotIndex{1, 2, 3, 4}, 4, "Genesis", ts.Nodes(), true, false) + + ts.IssueCandidacyAnnouncementInSlot("node1-candidacy:1", 4, "wave-1:4.3", ts.Wallet("node1")) + ts.IssueCandidacyAnnouncementInSlot("node4-candidacy:1", 5, "node1-candidacy:1", ts.Wallet("node4")) + + ts.IssueBlocksAtSlots("wave-2:", []iotago.SlotIndex{5, 6, 7, 8}, 4, "node4-candidacy:1", ts.Nodes(), true, false) + + ts.AssertSybilProtectionCandidates(0, []iotago.AccountID{ + ts.Node("node1").Validator.AccountID, + ts.Node("node4").Validator.AccountID, + }, ts.Nodes()...) + + ts.AssertSybilProtectionRegisteredValidators(0, []string{ + ts.Node("node1").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node4").Validator.AccountID.ToAddress().Bech32(hrp), + }, ts.Nodes()...) + + assertValidatorsFromRequestHandler(t, []string{ + ts.Node("node1").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node4").Validator.AccountID.ToAddress().Bech32(hrp), + }, requestHandler, 0) + } + + // Epoch 0, assert that node1, node2 and node4 are the only candidates. + { + ts.IssueCandidacyAnnouncementInSlot("node2-candidacy:1", 9, "wave-2:8.3", ts.Wallet("node2")) + ts.IssueBlocksAtSlots("wave-3:", []iotago.SlotIndex{9, 10}, 4, "node2-candidacy:1", ts.Nodes(), true, false) + + ts.AssertSybilProtectionCandidates(0, []iotago.AccountID{ + ts.Node("node1").Validator.AccountID, + ts.Node("node2").Validator.AccountID, + ts.Node("node4").Validator.AccountID, + }, ts.Nodes()...) + + ts.AssertSybilProtectionRegisteredValidators(0, []string{ + ts.Node("node1").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node2").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node4").Validator.AccountID.ToAddress().Bech32(hrp), + }, ts.Nodes()...) + + assertValidatorsFromRequestHandler(t, []string{ + ts.Node("node1").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node2").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node4").Validator.AccountID.ToAddress().Bech32(hrp), + }, requestHandler, 0) + } + + // Epoch 0, assert that node1, node2 and node4 are the only registered validators. Since node3 issued a candidacy payload after epoch nearing threshold (slot 11), it should not be a registered validators. + { + ts.IssueCandidacyAnnouncementInSlot("node3-candidacy:1", 11, "wave-3:10.3", ts.Wallet("node3")) + ts.IssueBlocksAtSlots("wave-5:", []iotago.SlotIndex{11}, 4, "node3-candidacy:1", ts.Nodes(), true, false) + + ts.AssertSybilProtectionCandidates(0, []iotago.AccountID{ + ts.Node("node1").Validator.AccountID, + ts.Node("node2").Validator.AccountID, + ts.Node("node4").Validator.AccountID, + }, ts.Nodes()...) + + ts.AssertSybilProtectionRegisteredValidators(0, []string{ + ts.Node("node1").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node2").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node4").Validator.AccountID.ToAddress().Bech32(hrp), + }, ts.Nodes()...) + + assertValidatorsFromRequestHandler(t, []string{ + ts.Node("node1").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node2").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node4").Validator.AccountID.ToAddress().Bech32(hrp), + }, requestHandler, 0) + } + + // Epoch 1, assert that node1, node2 and node4 are the only registered validators in Epoch 0. And node2 and node3 are the only registered validators in Epoch 1. + { + ts.IssueBlocksAtSlots("wave-6:", []iotago.SlotIndex{12, 13, 14, 15, 16}, 4, "wave-5:11.3", ts.Nodes(), true, false) + + ts.IssueCandidacyAnnouncementInSlot("node2-candidacy:2", 17, "wave-6:16.3", ts.Wallet("node2")) + ts.IssueCandidacyAnnouncementInSlot("node3-candidacy:2", 17, "node2-candidacy:2", ts.Wallet("node3")) + ts.IssueBlocksAtSlots("wave-7:", []iotago.SlotIndex{18}, 4, "node3-candidacy:2", ts.Nodes(), true, false) + + // request registered validators of epoch 0, the validator cache should be used. + assertValidatorsFromRequestHandler(t, []string{ + ts.Node("node1").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node2").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node4").Validator.AccountID.ToAddress().Bech32(hrp), + }, requestHandler, 0) + + // epoch 1 should have 2 validators (node2, node3) + ts.AssertSybilProtectionCandidates(1, []iotago.AccountID{ + ts.Node("node2").Validator.AccountID, + ts.Node("node3").Validator.AccountID, + }, ts.Nodes()...) + + ts.AssertSybilProtectionRegisteredValidators(1, []string{ + ts.Node("node2").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node3").Validator.AccountID.ToAddress().Bech32(hrp), + }, ts.Nodes()...) + + assertValidatorsFromRequestHandler(t, []string{ + ts.Node("node2").Validator.AccountID.ToAddress().Bech32(hrp), + ts.Node("node3").Validator.AccountID.ToAddress().Bech32(hrp), + }, requestHandler, 1) + } + + // error returned, requesting with invalid cursor index. + { + _, err := requestHandler.Validators(1, 6, 10) + require.ErrorAs(t, echo.ErrBadRequest, &err) + } +} + +func assertValidatorsFromRequestHandler(t *testing.T, expectedValidators []string, requestHandler *RequestHandler, requestedEpoch iotago.EpochIndex) { + resp, err := requestHandler.Validators(requestedEpoch, 0, 10) + require.NoError(t, err) + actualValidators := lo.Map(resp.Validators, func(validator *api.ValidatorResponse) string { + return validator.AddressBech32 + }) + + require.ElementsMatch(t, expectedValidators, actualValidators) +} diff --git a/pkg/testsuite/mock/blockissuer.go b/pkg/testsuite/mock/blockissuer.go index 3b382e19e..c5fac1c94 100644 --- a/pkg/testsuite/mock/blockissuer.go +++ b/pkg/testsuite/mock/blockissuer.go @@ -213,7 +213,7 @@ func (i *BlockIssuer) retrieveAPI(blockParams *BlockHeaderParams, node *Node) (i // CreateBlock creates a new block with the options. func (i *BlockIssuer) CreateBasicBlock(ctx context.Context, alias string, node *Node, opts ...options.Option[BasicBlockParams]) (*blocks.Block, error) { - blockParams := options.Apply(&BasicBlockParams{}, opts) + blockParams := options.Apply(&BasicBlockParams{BlockHeader: &BlockHeaderParams{}}, opts) if blockParams.BlockHeader.IssuingTime == nil { issuingTime := time.Now().UTC() diff --git a/pkg/testsuite/sybilprotection.go b/pkg/testsuite/sybilprotection.go index 57d8e282f..85da211ff 100644 --- a/pkg/testsuite/sybilprotection.go +++ b/pkg/testsuite/sybilprotection.go @@ -10,6 +10,7 @@ import ( "github.com/iotaledger/iota-core/pkg/protocol/engine/accounts" "github.com/iotaledger/iota-core/pkg/testsuite/mock" iotago "github.com/iotaledger/iota.go/v4" + "github.com/iotaledger/iota.go/v4/api" ) func (t *TestSuite) AssertSybilProtectionCommittee(epoch iotago.EpochIndex, expectedAccounts []iotago.AccountID, nodes ...*mock.Node) { @@ -79,6 +80,30 @@ func (t *TestSuite) AssertReelectedCommitteeSeatIndices(prevEpoch iotago.EpochIn } } +func (t *TestSuite) AssertSybilProtectionRegisteredValidators(epoch iotago.EpochIndex, expectedAccounts []string, nodes ...*mock.Node) { + mustNodes(nodes) + + for _, node := range nodes { + t.Eventually(func() error { + candidates, err := node.Protocol.Engines.Main.Get().SybilProtection.OrderedRegisteredCandidateValidatorsList(epoch) + candidateIDs := lo.Map(candidates, func(candidate *api.ValidatorResponse) string { + return candidate.AddressBech32 + }) + require.NoError(t.Testing, err) + + if !assert.ElementsMatch(t.fakeTesting, expectedAccounts, candidateIDs) { + return ierrors.Errorf("AssertSybilProtectionRegisteredValidators: %s: expected %s, got %s", node.Name, expectedAccounts, candidateIDs) + } + + if len(expectedAccounts) != len(candidates) { + return ierrors.Errorf("AssertSybilProtectionRegisteredValidators: %s: expected %v, got %v", node.Name, len(expectedAccounts), len(candidateIDs)) + } + + return nil + }) + } +} + func (t *TestSuite) AssertSybilProtectionCandidates(epoch iotago.EpochIndex, expectedAccounts []iotago.AccountID, nodes ...*mock.Node) { mustNodes(nodes) diff --git a/tools/docker-network/tests/coreapi_test.go b/tools/docker-network/tests/coreapi_test.go index 2c6355c23..3778f9466 100644 --- a/tools/docker-network/tests/coreapi_test.go +++ b/tools/docker-network/tests/coreapi_test.go @@ -6,17 +6,83 @@ import ( "context" "fmt" "net/http" + "sync" "testing" "time" - "github.com/stretchr/testify/require" - "github.com/iotaledger/hive.go/lo" iotago "github.com/iotaledger/iota.go/v4" "github.com/iotaledger/iota.go/v4/api" + "github.com/iotaledger/iota.go/v4/nodeclient" "github.com/iotaledger/iota.go/v4/tpkg" + "github.com/stretchr/testify/require" ) +// Test_ValidatorsAPI tests if the validators API returns the expected validators. +// 1. Run docker network. +// 2. Create 50 new accounts with staking feature. +// 3. Wait until next epoch then issue candidacy payload for each account. +// 4. Check if all 54 validators are returned from the validators API with pageSize 10, the pagination of api is also tested. +// 5. Wait until next epoch then check again if the results remain. +func Test_ValidatorsAPI(t *testing.T) { + d := NewDockerTestFramework(t, + WithProtocolParametersOptions( + iotago.WithTimeProviderOptions(5, time.Now().Unix(), 10, 4), + iotago.WithLivenessOptions(10, 10, 2, 4, 8), + iotago.WithRewardsOptions(8, 10, 2, 384), + iotago.WithTargetCommitteeSize(4), + )) + defer d.Stop() + + d.AddValidatorNode("V1", "docker-network-inx-validator-1-1", "http://localhost:8050", "rms1pzg8cqhfxqhq7pt37y8cs4v5u4kcc48lquy2k73ehsdhf5ukhya3y5rx2w6") + d.AddValidatorNode("V2", "docker-network-inx-validator-2-1", "http://localhost:8060", "rms1pqm4xk8e9ny5w5rxjkvtp249tfhlwvcshyr3pc0665jvp7g3hc875k538hl") + d.AddValidatorNode("V3", "docker-network-inx-validator-3-1", "http://localhost:8070", "rms1pp4wuuz0y42caz48vv876qfpmffswsvg40zz8v79sy8cp0jfxm4kunflcgt") + d.AddValidatorNode("V4", "docker-network-inx-validator-4-1", "http://localhost:8040", "rms1pr8cxs3dzu9xh4cduff4dd4cxdthpjkpwmz2244f75m0urslrsvtsshrrjw") + d.AddNode("node5", "docker-network-node-5-1", "http://localhost:8090") + + runErr := d.Run() + require.NoError(t, runErr) + + d.WaitUntilNetworkReady() + hrp := d.wallet.DefaultClient().CommittedAPI().ProtocolParameters().Bech32HRP() + + // Create registered validators + var wg sync.WaitGroup + clt := d.wallet.DefaultClient() + status := d.NodeStatus("V1") + currentEpoch := clt.CommittedAPI().TimeProvider().EpochFromSlot(status.LatestAcceptedBlockSlot) + + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + account := d.CreateAccount(WithStakingFeature(100, 1, 0)) + + // issue candidacy payload in the next epoch (currentEpoch + 1), in order to issue it before epochNearingThreshold + d.AwaitCommitment(clt.CommittedAPI().TimeProvider().EpochEnd(currentEpoch)) + blkID := d.IssueCandidacyPayloadFromAccount(account.ID) + fmt.Println("Candidacy payload:", blkID.ToHex(), blkID.Slot()) + d.AwaitCommitment(blkID.Slot()) + }() + } + wg.Wait() + + expectedValidators := d.AccountsFromNodes(d.Nodes()...) + for _, v := range d.wallet.Accounts() { + expectedValidators = append(expectedValidators, v.Address.Bech32(hrp)) + } + // get all validators of currentEpoch+1 with pageSize 10 + actualValidators := getAllValidatorsOnEpoch(t, clt, 0, 10) + require.ElementsMatch(t, expectedValidators, actualValidators) + + // wait until currentEpoch+3 and check the results again + targetSlot := clt.CommittedAPI().TimeProvider().EpochEnd(currentEpoch + 2) + d.AwaitCommitment(targetSlot) + actualValidators = getAllValidatorsOnEpoch(t, clt, currentEpoch+1, 10) + require.ElementsMatch(t, expectedValidators, actualValidators) +} + func Test_CoreAPI(t *testing.T) { d := NewDockerTestFramework(t, WithProtocolParametersOptions( @@ -570,3 +636,27 @@ func Test_CoreAPI_BadRequests(t *testing.T) { }) } } + +func getAllValidatorsOnEpoch(t *testing.T, clt *nodeclient.Client, epoch iotago.EpochIndex, pageSize uint64) []string { + actualValidators := make([]string, 0) + cursor := "" + if epoch != 0 { + cursor = fmt.Sprintf("%d,%d", epoch, 0) + } + + for { + resp, err := clt.Validators(context.Background(), pageSize, cursor) + require.NoError(t, err) + + for _, v := range resp.Validators { + actualValidators = append(actualValidators, v.AddressBech32) + } + + cursor = resp.Cursor + if cursor == "" { + break + } + } + + return actualValidators +} diff --git a/tools/docker-network/tests/dockerframework.go b/tools/docker-network/tests/dockerframework.go index 6fde74dd2..6d73a8be5 100644 --- a/tools/docker-network/tests/dockerframework.go +++ b/tools/docker-network/tests/dockerframework.go @@ -363,6 +363,16 @@ func (d *DockerTestFramework) StopIssueCandidacyPayload(nodes ...*Node) { require.NoError(d.Testing, err) } +func (d *DockerTestFramework) IssueCandidacyPayloadFromAccount(issuerId iotago.AccountID) iotago.BlockID { + issuer := d.wallet.Account(issuerId) + ctx := context.TODO() + clt := d.wallet.DefaultClient() + + issuerResp, congestionResp := d.PrepareBlockIssuance(ctx, clt, issuer.Address) + + return d.SubmitPayload(ctx, &iotago.CandidacyAnnouncement{}, issuerId, congestionResp, issuerResp) +} + // CreateTaggedDataBlock creates a block of a tagged data payload. func (d *DockerTestFramework) CreateTaggedDataBlock(issuerID iotago.AccountID, tag []byte) *iotago.Block { issuer := d.wallet.Account(issuerID) diff --git a/tools/docker-network/tests/wallet.go b/tools/docker-network/tests/wallet.go index dd2bdd5ed..e43439343 100644 --- a/tools/docker-network/tests/wallet.go +++ b/tools/docker-network/tests/wallet.go @@ -5,6 +5,7 @@ package tests import ( "crypto/ed25519" "math/big" + "sync" "sync/atomic" "testing" "time" @@ -33,8 +34,11 @@ type DockerWallet struct { lastUsedIndex atomic.Uint32 - outputs map[iotago.OutputID]*OutputData - accounts map[iotago.AccountID]*AccountData + outputs map[iotago.OutputID]*OutputData + outputsLock sync.RWMutex + + accounts map[iotago.AccountID]*AccountData + accountsLock sync.RWMutex } // OutputData holds the details of an output that can be used to build a transaction. @@ -78,14 +82,23 @@ func (w *DockerWallet) DefaultClient() *nodeclient.Client { } func (w *DockerWallet) AddOutput(outputID iotago.OutputID, output *OutputData) { + w.outputsLock.Lock() + defer w.outputsLock.Unlock() + w.outputs[outputID] = output } func (w *DockerWallet) AddAccount(accountID iotago.AccountID, data *AccountData) { + w.accountsLock.Lock() + defer w.accountsLock.Unlock() + w.accounts[accountID] = data } func (w *DockerWallet) Output(outputName iotago.OutputID) *OutputData { + w.outputsLock.RLock() + defer w.outputsLock.RUnlock() + output, exists := w.outputs[outputName] if !exists { panic(ierrors.Errorf("output %s not registered in wallet", outputName)) @@ -95,6 +108,9 @@ func (w *DockerWallet) Output(outputName iotago.OutputID) *OutputData { } func (w *DockerWallet) Account(accountID iotago.AccountID) *AccountData { + w.accountsLock.RLock() + defer w.accountsLock.RUnlock() + acc, exists := w.accounts[accountID] if !exists { panic(ierrors.Errorf("account %s not registered in wallet", accountID.ToHex())) @@ -103,6 +119,28 @@ func (w *DockerWallet) Account(accountID iotago.AccountID) *AccountData { return acc } +func (w *DockerWallet) Accounts(accountIds ...iotago.AccountID) []*AccountData { + w.accountsLock.RLock() + defer w.accountsLock.RUnlock() + + accounts := make([]*AccountData, 0) + if len(accountIds) == 0 { + for _, acc := range w.accounts { + accounts = append(accounts, acc) + } + } + + for _, id := range accountIds { + acc, exists := w.accounts[id] + if !exists { + panic(ierrors.Errorf("account %s not registered in wallet", id.ToHex())) + } + accounts = append(accounts, acc) + } + + return accounts +} + func (w *DockerWallet) Address(index ...uint32) (uint32, *iotago.Ed25519Address) { if len(index) == 0 { index = append(index, w.lastUsedIndex.Add(1))