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))