diff --git a/RELEASES.md b/RELEASES.md index 4f64902a0026..acfc626281e7 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -6,6 +6,7 @@ - Added `platform.getL1Validator` - Added `platform.getProposedHeight` +- Updated `platform.getValidatorsAt` to accept `"proposed"` as valid `height` input ### Configs diff --git a/tests/e2e/p/l1.go b/tests/e2e/p/l1.go index c5e62d75ac55..a14ae37c1f74 100644 --- a/tests/e2e/p/l1.go +++ b/tests/e2e/p/l1.go @@ -44,6 +44,7 @@ import ( p2ppb "github.com/ava-labs/avalanchego/proto/pb/p2p" platformvmpb "github.com/ava-labs/avalanchego/proto/pb/platformvm" snowvalidators "github.com/ava-labs/avalanchego/snow/validators" + platformapi "github.com/ava-labs/avalanchego/vms/platformvm/api" platformvmvalidators "github.com/ava-labs/avalanchego/vms/platformvm/validators" warpmessage "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" ) @@ -156,7 +157,7 @@ var _ = e2e.DescribePChain("[L1]", func() { height, err := pClient.GetHeight(tc.DefaultContext()) require.NoError(err) - subnetValidators, err := pClient.GetValidatorsAt(tc.DefaultContext(), subnetID, height) + subnetValidators, err := pClient.GetValidatorsAt(tc.DefaultContext(), subnetID, platformapi.Height(height)) require.NoError(err) require.Equal(expectedValidators, subnetValidators) } diff --git a/tests/e2e/p/validator_sets.go b/tests/e2e/p/validator_sets.go index 78c8e4ac4bb4..7be620cc8716 100644 --- a/tests/e2e/p/validator_sets.go +++ b/tests/e2e/p/validator_sets.go @@ -18,6 +18,8 @@ import ( "github.com/ava-labs/avalanchego/vms/platformvm" "github.com/ava-labs/avalanchego/vms/platformvm/txs" "github.com/ava-labs/avalanchego/vms/secp256k1fx" + + platformapi "github.com/ava-labs/avalanchego/vms/platformvm/api" ) var _ = e2e.DescribePChain("[Validator Sets]", func() { @@ -101,7 +103,7 @@ var _ = e2e.DescribePChain("[Validator Sets]", func() { validatorSet, err := pvmClient.GetValidatorsAt( tc.DefaultContext(), constants.PrimaryNetworkID, - height, + platformapi.Height(height), ) require.NoError(err) if observedValidatorSet == nil { diff --git a/vms/platformvm/api/height.go b/vms/platformvm/api/height.go new file mode 100644 index 000000000000..0a4a2660dcc4 --- /dev/null +++ b/vms/platformvm/api/height.go @@ -0,0 +1,49 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package api + +import ( + "errors" + "math" + + avajson "github.com/ava-labs/avalanchego/utils/json" +) + +type Height avajson.Uint64 + +const ( + ProposedHeightFlag = "proposed" + ProposedHeight = math.MaxUint64 +) + +var errInvalidHeight = errors.New("invalid height") + +func (h Height) MarshalJSON() ([]byte, error) { + if h == ProposedHeight { + return []byte(`"` + ProposedHeightFlag + `"`), nil + } + return avajson.Uint64(h).MarshalJSON() +} + +func (h *Height) UnmarshalJSON(b []byte) error { + if string(b) == ProposedHeightFlag { + *h = ProposedHeight + return nil + } + err := (*avajson.Uint64)(h).UnmarshalJSON(b) + if err != nil { + return errInvalidHeight + } + // MaxUint64 is reserved for proposed height + // return an error if supplied numerically + if uint64(*h) == ProposedHeight { + *h = 0 + return errInvalidHeight + } + return nil +} + +func (h Height) IsProposed() bool { + return h == ProposedHeight +} diff --git a/vms/platformvm/api/height_test.go b/vms/platformvm/api/height_test.go new file mode 100644 index 000000000000..c20a9a5a1efc --- /dev/null +++ b/vms/platformvm/api/height_test.go @@ -0,0 +1,45 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package api + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMarshallHeight(t *testing.T) { + require := require.New(t) + h := Height(56) + bytes, err := h.MarshalJSON() + require.NoError(err) + require.Equal(`"56"`, string(bytes)) + + h = Height(ProposedHeight) + bytes, err = h.MarshalJSON() + require.NoError(err) + require.Equal(`"proposed"`, string(bytes)) +} + +func TestUnmarshallHeight(t *testing.T) { + require := require.New(t) + + var h Height + + require.NoError(h.UnmarshalJSON([]byte("56"))) + require.Equal(Height(56), h) + + require.NoError(h.UnmarshalJSON([]byte(ProposedHeightFlag))) + require.Equal(Height(ProposedHeight), h) + require.True(h.IsProposed()) + + err := h.UnmarshalJSON([]byte("invalid")) + require.ErrorIs(err, errInvalidHeight) + require.Equal(Height(0), h) + + err = h.UnmarshalJSON([]byte(`"` + strconv.FormatUint(uint64(ProposedHeight), 10) + `"`)) + require.ErrorIs(err, errInvalidHeight) + require.Equal(Height(0), h) +} diff --git a/vms/platformvm/client.go b/vms/platformvm/client.go index f19a3ce10393..86a459933dfa 100644 --- a/vms/platformvm/client.go +++ b/vms/platformvm/client.go @@ -21,6 +21,8 @@ import ( "github.com/ava-labs/avalanchego/vms/platformvm/fx" "github.com/ava-labs/avalanchego/vms/platformvm/status" "github.com/ava-labs/avalanchego/vms/secp256k1fx" + + platformapi "github.com/ava-labs/avalanchego/vms/platformvm/api" ) var _ Client = (*client)(nil) @@ -119,11 +121,12 @@ type Client interface { // GetTimestamp returns the current chain timestamp GetTimestamp(ctx context.Context, options ...rpc.Option) (time.Time, error) // GetValidatorsAt returns the weights of the validator set of a provided - // subnet at the specified height. + // subnet at the specified height or at proposerVM height if set to + // [platformapi.ProposedHeight] GetValidatorsAt( ctx context.Context, subnetID ids.ID, - height uint64, + height platformapi.Height, options ...rpc.Option, ) (map[ids.NodeID]*validators.GetValidatorOutput, error) // GetBlock returns the block with the given id. @@ -572,13 +575,13 @@ func (c *client) GetTimestamp(ctx context.Context, options ...rpc.Option) (time. func (c *client) GetValidatorsAt( ctx context.Context, subnetID ids.ID, - height uint64, + height platformapi.Height, options ...rpc.Option, ) (map[ids.NodeID]*validators.GetValidatorOutput, error) { res := &GetValidatorsAtReply{} err := c.requester.SendRequest(ctx, "platform.getValidatorsAt", &GetValidatorsAtArgs{ SubnetID: subnetID, - Height: json.Uint64(height), + Height: height, }, res, options...) return res.Validators, err } diff --git a/vms/platformvm/service.go b/vms/platformvm/service.go index 14a32cd9acaa..c5a9df6c21a5 100644 --- a/vms/platformvm/service.go +++ b/vms/platformvm/service.go @@ -1792,8 +1792,8 @@ func (s *Service) GetTimestamp(_ *http.Request, _ *struct{}, reply *GetTimestamp // GetValidatorsAtArgs is the response from GetValidatorsAt type GetValidatorsAtArgs struct { - Height avajson.Uint64 `json:"height"` - SubnetID ids.ID `json:"subnetID"` + Height platformapi.Height `json:"height"` + SubnetID ids.ID `json:"subnetID"` } type jsonGetValidatorOutput struct { @@ -1863,11 +1863,11 @@ type GetValidatorsAtReply struct { // GetValidatorsAt returns the weights of the validator set of a provided subnet // at the specified height. func (s *Service) GetValidatorsAt(r *http.Request, args *GetValidatorsAtArgs, reply *GetValidatorsAtReply) error { - height := uint64(args.Height) s.vm.ctx.Log.Debug("API called", zap.String("service", "platform"), zap.String("method", "getValidatorsAt"), - zap.Uint64("height", height), + zap.Uint64("height", uint64(args.Height)), + zap.Bool("isProposed", args.Height.IsProposed()), zap.Stringer("subnetID", args.SubnetID), ) @@ -1876,6 +1876,14 @@ func (s *Service) GetValidatorsAt(r *http.Request, args *GetValidatorsAtArgs, re ctx := r.Context() var err error + height := uint64(args.Height) + if args.Height.IsProposed() { + height, err = s.vm.GetMinimumHeight(ctx) + if err != nil { + return fmt.Errorf("failed to get proposed height: %w", err) + } + } + reply.Validators, err = s.vm.GetValidatorSet(ctx, height, args.SubnetID) if err != nil { return fmt.Errorf("failed to get validator set: %w", err) diff --git a/vms/platformvm/service.md b/vms/platformvm/service.md index c69dafb609de..1d803744bda6 100644 --- a/vms/platformvm/service.md +++ b/vms/platformvm/service.md @@ -1891,13 +1891,14 @@ Get the validators and their weights of a Subnet or the Primary Network at a giv ```sh platform.getValidatorsAt( { - height: int, + height: [int|string], subnetID: string, // optional } ) ``` -- `height` is the P-Chain height to get the validator set at. +- `height` is the P-Chain height to get the validator set at, or the string literal "proposed" + to return the validator set at this node's ProposerVM height. - `subnetID` is the Subnet ID to get the validator set of. If not given, gets validator set of the Primary Network. diff --git a/vms/platformvm/service_test.go b/vms/platformvm/service_test.go index cc95a5c5f93b..19db953251b7 100644 --- a/vms/platformvm/service_test.go +++ b/vms/platformvm/service_test.go @@ -819,6 +819,99 @@ func TestGetCurrentValidators(t *testing.T) { } } +func TestGetValidatorsAt(t *testing.T) { + require := require.New(t) + service, _ := defaultService(t, upgradetest.Latest) + + genesis := genesistest.New(t, genesistest.Config{}) + + args := GetValidatorsAtArgs{} + response := GetValidatorsAtReply{} + + service.vm.ctx.Lock.Lock() + lastAccepted := service.vm.manager.LastAccepted() + lastAcceptedBlk, err := service.vm.manager.GetBlock(lastAccepted) + require.NoError(err) + + service.vm.ctx.Lock.Unlock() + + // Confirm that it returns the genesis validators given the latest height + args.Height = pchainapi.Height(lastAcceptedBlk.Height()) + require.NoError(service.GetValidatorsAt(&http.Request{}, &args, &response)) + require.Len(response.Validators, len(genesis.Validators)) + + service.vm.ctx.Lock.Lock() + + wallet := newWallet(t, service.vm, walletConfig{}) + rewardsOwner := &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + } + + sk, err := bls.NewSecretKey() + require.NoError(err) + + tx, err := wallet.IssueAddPermissionlessValidatorTx( + &txs.SubnetValidator{ + Validator: txs.Validator{ + NodeID: ids.GenerateTestNodeID(), + Start: uint64(service.vm.clock.Time().Add(txexecutor.SyncBound).Unix()), + End: uint64(service.vm.clock.Time().Add(txexecutor.SyncBound).Add(defaultMinStakingDuration).Unix()), + Wght: service.vm.MinValidatorStake, + }, + Subnet: constants.PrimaryNetworkID, + }, + signer.NewProofOfPossession(sk), + service.vm.ctx.AVAXAssetID, + rewardsOwner, + rewardsOwner, + 0, + common.WithMemo([]byte{}), + ) + + require.NoError(err) + + service.vm.ctx.Lock.Unlock() + require.NoError(service.vm.Network.IssueTxFromRPC(tx)) + service.vm.ctx.Lock.Lock() + + block, err := service.vm.BuildBlock(context.Background()) + require.NoError(err) + + blk := block.(*blockexecutor.Block) + require.NoError(blk.Verify(context.Background())) + + require.NoError(blk.Accept(context.Background())) + service.vm.ctx.Lock.Unlock() + + newLastAccepted := service.vm.manager.LastAccepted() + newLastAcceptedBlk, err := service.vm.manager.GetBlock(newLastAccepted) + require.NoError(err) + require.NotEqual(newLastAccepted, lastAccepted) + + // Confirm that it returns the genesis validators + the new validator given the latest height + args.Height = pchainapi.Height(newLastAcceptedBlk.Height()) + require.NoError(service.GetValidatorsAt(&http.Request{}, &args, &response)) + require.Len(response.Validators, len(genesis.Validators)+1) + + // Confirm that [IsProposed] works. The proposed height should be the genesis height + args.Height = pchainapi.Height(pchainapi.ProposedHeight) + require.NoError(service.GetValidatorsAt(&http.Request{}, &args, &response)) + require.Len(response.Validators, len(genesis.Validators)) + + service.vm.ctx.Lock.Lock() + + // set clock beyond the [validators.recentlyAcceptedWindowTTL] to bump the + // proposerVM height + service.vm.clock.Set(newLastAcceptedBlk.Timestamp().Add(40 * time.Second)) + service.vm.ctx.Lock.Unlock() + + // Resending the same request with [IsProposed] set to true should now + // include the new validator + require.NoError(service.GetValidatorsAt(&http.Request{}, &args, &response)) + require.Len(response.Validators, len(genesis.Validators)+1) +} + func TestGetTimestamp(t *testing.T) { require := require.New(t) service, _ := defaultService(t, upgradetest.Latest)