mirror of
https://github.com/wealdtech/ethdo.git
synced 2026-01-11 06:58:02 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77abe0e158 | ||
|
|
547f8d9e71 | ||
|
|
e144217f25 | ||
|
|
d919810ce1 |
@@ -1,3 +1,10 @@
|
||||
1.24.1:
|
||||
- fix potential crash when new validators are activated
|
||||
- add "sepolia" to the list of supported networks
|
||||
|
||||
1.24.0:
|
||||
- add "validator yield"
|
||||
|
||||
1.23.1:
|
||||
- do not fetch future state for chain eth1votes
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ type command struct {
|
||||
allowInsecureConnections bool
|
||||
|
||||
// Input.
|
||||
epoch string
|
||||
xepoch string
|
||||
xperiod string
|
||||
|
||||
// Data access.
|
||||
eth2Client eth2client.Service
|
||||
@@ -47,6 +48,7 @@ type command struct {
|
||||
|
||||
// Output.
|
||||
slot phase0.Slot
|
||||
epoch phase0.Epoch
|
||||
period uint64
|
||||
incumbent *phase0.ETH1Data
|
||||
eth1DataVotes []*phase0.ETH1Data
|
||||
@@ -72,9 +74,8 @@ func newCommand(ctx context.Context) (*command, error) {
|
||||
}
|
||||
c.timeout = viper.GetDuration("timeout")
|
||||
|
||||
if viper.GetString("epoch") != "" {
|
||||
c.epoch = viper.GetString("epoch")
|
||||
}
|
||||
c.xepoch = viper.GetString("epoch")
|
||||
c.xperiod = viper.GetString("period")
|
||||
|
||||
if viper.GetString("connection") == "" {
|
||||
return nil, errors.New("connection is required")
|
||||
|
||||
@@ -24,8 +24,9 @@ import (
|
||||
)
|
||||
|
||||
type jsonOutput struct {
|
||||
Slot phase0.Slot `json:"slot"`
|
||||
Period uint64 `json:"period"`
|
||||
Epoch phase0.Epoch `json:"epoch"`
|
||||
Slot phase0.Slot `json:"slot"`
|
||||
Incumbent *phase0.ETH1Data `json:"incumbent"`
|
||||
Votes []*vote `json:"votes"`
|
||||
}
|
||||
@@ -56,8 +57,9 @@ func (c *command) outputJSON(ctx context.Context) (string, error) {
|
||||
})
|
||||
|
||||
output := &jsonOutput{
|
||||
Slot: c.slot,
|
||||
Period: c.period,
|
||||
Epoch: c.epoch,
|
||||
Slot: c.slot,
|
||||
Incumbent: c.incumbent,
|
||||
Votes: votes,
|
||||
}
|
||||
@@ -72,11 +74,6 @@ func (c *command) outputJSON(ctx context.Context) (string, error) {
|
||||
func (c *command) outputText(ctx context.Context) (string, error) {
|
||||
builder := strings.Builder{}
|
||||
|
||||
if c.verbose {
|
||||
builder.WriteString("Slot: ")
|
||||
builder.WriteString(fmt.Sprintf("%d\n", c.slot))
|
||||
}
|
||||
|
||||
builder.WriteString("Voting period: ")
|
||||
builder.WriteString(fmt.Sprintf("%d\n", c.period))
|
||||
|
||||
@@ -103,8 +100,9 @@ func (c *command) outputText(ctx context.Context) (string, error) {
|
||||
slot = c.slot
|
||||
}
|
||||
|
||||
slotsThroughPeriod := slot + 1 - phase0.Slot(c.period*(c.slotsPerEpoch*c.epochsPerEth1VotingPeriod))
|
||||
builder.WriteString("Slots through period: ")
|
||||
builder.WriteString(fmt.Sprintf("%d\n", slot-phase0.Slot(c.period*(c.slotsPerEpoch*c.epochsPerEth1VotingPeriod))))
|
||||
builder.WriteString(fmt.Sprintf("%d (%d)\n", slotsThroughPeriod, c.slot))
|
||||
|
||||
builder.WriteString("Votes this period: ")
|
||||
builder.WriteString(fmt.Sprintf("%d\n", totalVotes))
|
||||
@@ -114,13 +112,12 @@ func (c *command) outputText(ctx context.Context) (string, error) {
|
||||
for _, vote := range votes {
|
||||
builder.WriteString(fmt.Sprintf(" block %#x, deposit count %d: %d vote", vote.Vote.BlockHash, vote.Vote.DepositCount, vote.Count))
|
||||
if vote.Count != 1 {
|
||||
builder.WriteString("s\n")
|
||||
} else {
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("s")
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf(" (%0.2f%%)\n", 100.0*float64(vote.Count)/float64(slotsThroughPeriod)))
|
||||
}
|
||||
} else {
|
||||
builder.WriteString(fmt.Sprintf("Leading vote is for block %#x with %d votes\n", votes[0].Vote.BlockHash, votes[0].Count))
|
||||
builder.WriteString(fmt.Sprintf("Leading vote is for block %#x with %d votes (%0.2f%%)\n", votes[0].Vote.BlockHash, votes[0].Count, 100.0*float64(votes[0].Count)/float64(slotsThroughPeriod)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
eth2client "github.com/attestantio/go-eth2-client"
|
||||
"github.com/attestantio/go-eth2-client/spec"
|
||||
@@ -32,13 +33,28 @@ func (c *command) process(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
epoch, err := util.ParseEpoch(ctx, c.chainTime, c.epoch)
|
||||
if err != nil {
|
||||
return err
|
||||
var err error
|
||||
if c.xperiod != "" {
|
||||
period, err := strconv.ParseUint(c.xperiod, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.epoch = phase0.Epoch(c.epochsPerEth1VotingPeriod*(period+1)) - 1
|
||||
} else {
|
||||
c.epoch, err = util.ParseEpoch(ctx, c.chainTime, c.xepoch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Do not fetch from the future.
|
||||
if c.epoch > c.chainTime.CurrentEpoch() {
|
||||
c.epoch = c.chainTime.CurrentEpoch()
|
||||
}
|
||||
|
||||
// Need to fetch the state from the last slot of the epoch.
|
||||
fetchSlot := c.chainTime.FirstSlotOfEpoch(epoch+1) - 1
|
||||
fetchSlot := c.chainTime.FirstSlotOfEpoch(c.epoch+1) - 1
|
||||
// Do not fetch from the future.
|
||||
if fetchSlot > c.chainTime.CurrentSlot() {
|
||||
fetchSlot = c.chainTime.CurrentSlot()
|
||||
}
|
||||
@@ -74,7 +90,7 @@ func (c *command) process(ctx context.Context) error {
|
||||
return fmt.Errorf("unhandled beacon state version %v", state.Version)
|
||||
}
|
||||
|
||||
c.period = uint64(c.slot) / (c.slotsPerEpoch * c.epochsPerEth1VotingPeriod)
|
||||
c.period = uint64(c.epoch) / c.epochsPerEth1VotingPeriod
|
||||
|
||||
c.votes = make(map[string]*vote)
|
||||
for _, eth1Vote := range c.eth1DataVotes {
|
||||
|
||||
@@ -50,6 +50,7 @@ func init() {
|
||||
chainCmd.AddCommand(chainEth1VotesCmd)
|
||||
chainFlags(chainEth1VotesCmd)
|
||||
chainEth1VotesCmd.Flags().String("epoch", "", "epoch for which to fetch the votes")
|
||||
chainEth1VotesCmd.Flags().String("period", "", "period for which to fetch the votes")
|
||||
chainEth1VotesCmd.Flags().Bool("json", false, "output data in JSON format")
|
||||
}
|
||||
|
||||
@@ -57,6 +58,9 @@ func chainEth1VotesBindings() {
|
||||
if err := viper.BindPFlag("epoch", chainEth1VotesCmd.Flags().Lookup("epoch")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("period", chainEth1VotesCmd.Flags().Lookup("period")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("json", chainEth1VotesCmd.Flags().Lookup("json")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,9 @@ func (c *command) processAttesterDuties(ctx context.Context) error {
|
||||
c.summary.NonParticipatingValidators = make([]*nonParticipatingValidator, 0, len(activeValidators)-len(votes))
|
||||
for activeValidatorIndex := range activeValidators {
|
||||
if _, exists := votes[activeValidatorIndex]; !exists {
|
||||
c.summary.NonParticipatingValidators = append(c.summary.NonParticipatingValidators, participations[activeValidatorIndex])
|
||||
if _, exists := participations[activeValidatorIndex]; exists {
|
||||
c.summary.NonParticipatingValidators = append(c.summary.NonParticipatingValidators, participations[activeValidatorIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(c.summary.NonParticipatingValidators, func(i int, j int) bool {
|
||||
|
||||
@@ -125,6 +125,8 @@ func includeCommandBindings(cmd *cobra.Command) {
|
||||
validatorInfoBindings()
|
||||
case "validator/keycheck":
|
||||
validatorKeycheckBindings()
|
||||
case "validator/yield":
|
||||
validatorYieldBindings()
|
||||
case "validator/expectation":
|
||||
validatorExpectationBindings()
|
||||
case "wallet/create":
|
||||
|
||||
@@ -111,6 +111,7 @@ func validatorDepositDataOutputLaunchpad(datum *dataOut) (string, error) {
|
||||
[4]byte{0x00, 0x00, 0x20, 0x09}: "pyrmont",
|
||||
[4]byte{0x00, 0x00, 0x10, 0x20}: "prater",
|
||||
[4]byte{0x80, 0x00, 0x00, 0x69}: "ropsten",
|
||||
[4]byte{0x90, 0x00, 0x00, 0x69}: "sepolia",
|
||||
}
|
||||
|
||||
if datum.validatorPubKey == nil {
|
||||
|
||||
84
cmd/validator/yield/command.go
Normal file
84
cmd/validator/yield/command.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright © 2022 Weald Technology Trading.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validatoryield
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
eth2client "github.com/attestantio/go-eth2-client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
quiet bool
|
||||
verbose bool
|
||||
debug bool
|
||||
json bool
|
||||
|
||||
// Beacon node connection.
|
||||
timeout time.Duration
|
||||
connection string
|
||||
allowInsecureConnections bool
|
||||
|
||||
// Input.
|
||||
validators string
|
||||
|
||||
// Data access.
|
||||
eth2Client eth2client.Service
|
||||
|
||||
// Output.
|
||||
results *output
|
||||
}
|
||||
|
||||
type output struct {
|
||||
BaseReward decimal.Decimal `json:"base_reward"`
|
||||
ActiveValidators decimal.Decimal `json:"active_validators"`
|
||||
ActiveValidatorBalance decimal.Decimal `json:"active_validator_balance"`
|
||||
ValidatorRewardsPerEpoch decimal.Decimal `json:"validator_rewards_per_epoch"`
|
||||
ValidatorRewardsPerYear decimal.Decimal `json:"validator_rewards_per_year"`
|
||||
ValidatorRewardsAllCorrect decimal.Decimal `json:"validator_rewards_all_correct"`
|
||||
ExpectedValidatorRewardsPerEpoch decimal.Decimal `json:"expected_validator_rewards_per_epoch"`
|
||||
MaxIssuancePerEpoch decimal.Decimal `json:"max_issuance_per_epoch"`
|
||||
MaxIssuancePerYear decimal.Decimal `json:"max_issuance_per_year"`
|
||||
Yield decimal.Decimal `json:"yield"`
|
||||
}
|
||||
|
||||
func newCommand(ctx context.Context) (*command, error) {
|
||||
c := &command{
|
||||
quiet: viper.GetBool("quiet"),
|
||||
verbose: viper.GetBool("verbose"),
|
||||
debug: viper.GetBool("debug"),
|
||||
json: viper.GetBool("json"),
|
||||
results: &output{},
|
||||
}
|
||||
|
||||
// Timeout.
|
||||
if viper.GetDuration("timeout") == 0 {
|
||||
return nil, errors.New("timeout is required")
|
||||
}
|
||||
c.timeout = viper.GetDuration("timeout")
|
||||
|
||||
if viper.GetString("connection") == "" {
|
||||
return nil, errors.New("connection is required")
|
||||
}
|
||||
c.connection = viper.GetString("connection")
|
||||
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")
|
||||
|
||||
c.validators = viper.GetString("validators")
|
||||
|
||||
return c, nil
|
||||
}
|
||||
73
cmd/validator/yield/command_internal_test.go
Normal file
73
cmd/validator/yield/command_internal_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright © 2021 Weald Technology Trading.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validatoryield
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInput(t *testing.T) {
|
||||
if os.Getenv("ETHDO_TEST_CONNECTION") == "" {
|
||||
t.Skip("ETHDO_TEST_CONNECTION not configured; cannot run tests")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
vars map[string]interface{}
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "TimeoutMissing",
|
||||
vars: map[string]interface{}{},
|
||||
err: "timeout is required",
|
||||
},
|
||||
{
|
||||
name: "ConnectionMissing",
|
||||
vars: map[string]interface{}{
|
||||
"validators": "1",
|
||||
"timeout": "5s",
|
||||
},
|
||||
err: "connection is required",
|
||||
},
|
||||
{
|
||||
name: "Good",
|
||||
vars: map[string]interface{}{
|
||||
"validators": "1",
|
||||
"timeout": "5s",
|
||||
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
for k, v := range test.vars {
|
||||
viper.Set(k, v)
|
||||
}
|
||||
_, err := newCommand(context.Background())
|
||||
if test.err != "" {
|
||||
require.EqualError(t, err, test.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
67
cmd/validator/yield/output.go
Normal file
67
cmd/validator/yield/output.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright © 2021 Weald Technology Trading.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validatoryield
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/wealdtech/go-string2eth"
|
||||
)
|
||||
|
||||
func (c *command) output(ctx context.Context) (string, error) {
|
||||
if c.quiet {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if c.json {
|
||||
data, err := json.Marshal(c.results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
builder := strings.Builder{}
|
||||
|
||||
if c.verbose {
|
||||
builder.WriteString("Per-validator rewards per epoch: ")
|
||||
builder.WriteString(string2eth.WeiToGWeiString(c.results.ValidatorRewardsPerEpoch.BigInt()))
|
||||
builder.WriteString("\n")
|
||||
|
||||
builder.WriteString("Per-validator rewards per year: ")
|
||||
builder.WriteString(string2eth.WeiToString(c.results.ValidatorRewardsPerYear.BigInt(), true))
|
||||
builder.WriteString("\n")
|
||||
|
||||
builder.WriteString("Expected per-validator rewards per epoch (with full participation): ")
|
||||
builder.WriteString(string2eth.WeiToGWeiString(c.results.ExpectedValidatorRewardsPerEpoch.BigInt()))
|
||||
builder.WriteString("\n")
|
||||
|
||||
builder.WriteString("Maximum chain issuance per epoch: ")
|
||||
builder.WriteString(string2eth.WeiToString(c.results.MaxIssuancePerEpoch.BigInt(), true))
|
||||
builder.WriteString("\n")
|
||||
|
||||
builder.WriteString("Maximum chain issuance per year: ")
|
||||
builder.WriteString(string2eth.WeiToString(c.results.MaxIssuancePerYear.BigInt(), true))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
builder.WriteString("Yield: ")
|
||||
builder.WriteString(c.results.Yield.Mul(decimal.New(100, 0)).StringFixed(2))
|
||||
builder.WriteString("%\n")
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
170
cmd/validator/yield/process.go
Normal file
170
cmd/validator/yield/process.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright © 2022 Weald Technology Trading.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validatoryield
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
|
||||
eth2client "github.com/attestantio/go-eth2-client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/shopspring/decimal"
|
||||
standardchaintime "github.com/wealdtech/ethdo/services/chaintime/standard"
|
||||
"github.com/wealdtech/ethdo/util"
|
||||
)
|
||||
|
||||
func (c *command) process(ctx context.Context) error {
|
||||
// Obtain information we need to process.
|
||||
if err := c.setup(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.debug {
|
||||
fmt.Printf("Active validators: %v\n", c.results.ActiveValidators)
|
||||
fmt.Printf("Active validator balance: %v\n", c.results.ActiveValidatorBalance)
|
||||
}
|
||||
|
||||
return c.calculateYield(ctx)
|
||||
}
|
||||
|
||||
var weiPerGwei = decimal.New(1e9, 0)
|
||||
var one = decimal.New(1, 0)
|
||||
var epochsPerYear = decimal.New(225*365, 0)
|
||||
|
||||
// calculateYield calculates yield from the number of active validators.
|
||||
func (c *command) calculateYield(ctx context.Context) error {
|
||||
|
||||
spec, err := c.eth2Client.(eth2client.SpecProvider).Spec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, exists := spec["BASE_REWARD_FACTOR"]
|
||||
if !exists {
|
||||
return errors.New("spec missing BASE_REWARD_FACTOR")
|
||||
}
|
||||
baseReward, isType := tmp.(uint64)
|
||||
if !isType {
|
||||
return errors.New("BASE_REWARD_FACTOR of incorrect type")
|
||||
}
|
||||
if c.debug {
|
||||
fmt.Printf("Base reward: %v\n", baseReward)
|
||||
}
|
||||
c.results.BaseReward = decimal.New(int64(baseReward), 0)
|
||||
|
||||
numerator := decimal.New(32, 0).Mul(weiPerGwei).Mul(c.results.BaseReward)
|
||||
if c.debug {
|
||||
fmt.Printf("Numerator: %v\n", numerator)
|
||||
}
|
||||
activeValidatorsBalanceInGwei := c.results.ActiveValidatorBalance.Div(weiPerGwei)
|
||||
denominator := decimal.NewFromBigInt(new(big.Int).Sqrt(activeValidatorsBalanceInGwei.BigInt()), 0)
|
||||
if c.debug {
|
||||
fmt.Printf("Denominator: %v\n", denominator)
|
||||
}
|
||||
c.results.ValidatorRewardsPerEpoch = numerator.Div(denominator).RoundDown(0).Mul(weiPerGwei)
|
||||
if c.debug {
|
||||
fmt.Printf("Validator rewards per epoch: %v\n", c.results.ValidatorRewardsPerEpoch)
|
||||
}
|
||||
c.results.ValidatorRewardsPerYear = c.results.ValidatorRewardsPerEpoch.Mul(epochsPerYear)
|
||||
if c.debug {
|
||||
fmt.Printf("Validator rewards per year: %v\n", c.results.ValidatorRewardsPerYear)
|
||||
}
|
||||
// Expected validator rewards assume that there is no proposal and no sync committee participation,
|
||||
// but that head/source/target are correct and timely: this gives 54/64 of the reward.
|
||||
// These values are obtained from https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/beacon-chain.md#incentivization-weights
|
||||
c.results.ExpectedValidatorRewardsPerEpoch = c.results.ValidatorRewardsPerEpoch.Mul(decimal.New(54, 0)).Div(decimal.New(64, 0)).Div(weiPerGwei).RoundDown(0).Mul(weiPerGwei)
|
||||
if c.debug {
|
||||
fmt.Printf("Expected validator rewards per epoch: %v\n", c.results.ExpectedValidatorRewardsPerEpoch)
|
||||
}
|
||||
|
||||
c.results.MaxIssuancePerEpoch = c.results.ValidatorRewardsPerEpoch.Mul(c.results.ActiveValidators)
|
||||
if c.debug {
|
||||
fmt.Printf("Chain rewards per epoch: %v\n", c.results.MaxIssuancePerEpoch)
|
||||
}
|
||||
c.results.MaxIssuancePerYear = c.results.MaxIssuancePerEpoch.Mul(epochsPerYear)
|
||||
if c.debug {
|
||||
fmt.Printf("Chain rewards per year: %v\n", c.results.MaxIssuancePerYear)
|
||||
}
|
||||
|
||||
c.results.Yield = c.results.ValidatorRewardsPerYear.Div(weiPerGwei).Div(weiPerGwei).Div(decimal.New(32, 0))
|
||||
if c.debug {
|
||||
fmt.Printf("Yield: %v\n", c.results.Yield)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *command) setup(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
// Connect to the client.
|
||||
c.eth2Client, err = util.ConnectToBeaconNode(ctx, c.connection, c.timeout, c.allowInsecureConnections)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to connect to beacon node")
|
||||
}
|
||||
|
||||
if c.validators == "" {
|
||||
chainTime, err := standardchaintime.New(ctx,
|
||||
standardchaintime.WithSpecProvider(c.eth2Client.(eth2client.SpecProvider)),
|
||||
standardchaintime.WithForkScheduleProvider(c.eth2Client.(eth2client.ForkScheduleProvider)),
|
||||
standardchaintime.WithGenesisTimeProvider(c.eth2Client.(eth2client.GenesisTimeProvider)),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to set up chaintime service")
|
||||
}
|
||||
|
||||
// Obtain the number of active validators.
|
||||
var isProvider bool
|
||||
validatorsProvider, isProvider := c.eth2Client.(eth2client.ValidatorsProvider)
|
||||
if !isProvider {
|
||||
return errors.New("connection does not provide validator information")
|
||||
}
|
||||
|
||||
validators, err := validatorsProvider.Validators(ctx, "head", nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to obtain validators")
|
||||
}
|
||||
|
||||
currentEpoch := chainTime.CurrentEpoch()
|
||||
activeValidators := decimal.Zero
|
||||
activeValidatorBalance := decimal.Zero
|
||||
for _, validator := range validators {
|
||||
if validator.Validator.ActivationEpoch <= currentEpoch &&
|
||||
validator.Validator.ExitEpoch > currentEpoch {
|
||||
activeValidators = activeValidators.Add(one)
|
||||
activeValidatorBalance = activeValidatorBalance.Add(decimal.NewFromInt(int64(validator.Validator.EffectiveBalance)))
|
||||
}
|
||||
}
|
||||
c.results.ActiveValidators = activeValidators
|
||||
c.results.ActiveValidatorBalance = activeValidatorBalance.Mul(weiPerGwei)
|
||||
} else {
|
||||
activeValidators, err := strconv.ParseInt(c.validators, 0, 64)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to parse number of validators")
|
||||
}
|
||||
if activeValidators <= 0 {
|
||||
return errors.New("number of validators must be greater than 0")
|
||||
}
|
||||
|
||||
c.results.ActiveValidators = decimal.New(activeValidators, 0)
|
||||
c.results.ActiveValidatorBalance = decimal.New(32, 0).Mul(c.results.ActiveValidators).Mul(weiPerGwei).Mul(weiPerGwei)
|
||||
if c.debug {
|
||||
fmt.Println("Assuming 32Ξ per validator")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
90
cmd/validator/yield/process_internal_test.go
Normal file
90
cmd/validator/yield/process_internal_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright © 2021 Weald Technology Trading.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validatoryield
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProcess(t *testing.T) {
|
||||
if os.Getenv("ETHDO_TEST_CONNECTION") == "" {
|
||||
t.Skip("ETHDO_TEST_CONNECTION not configured; cannot run tests")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
vars map[string]interface{}
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "InvalidData",
|
||||
vars: map[string]interface{}{
|
||||
"timeout": "60s",
|
||||
"validators": "1",
|
||||
"data": "[[",
|
||||
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValidatorsInvalid",
|
||||
vars: map[string]interface{}{
|
||||
"timeout": "60s",
|
||||
"validators": "invalid",
|
||||
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
|
||||
},
|
||||
err: "failed to parse number of validators: strconv.ParseInt: parsing \"invalid\": invalid syntax",
|
||||
},
|
||||
{
|
||||
name: "ValidatorsNegative",
|
||||
vars: map[string]interface{}{
|
||||
"timeout": "60s",
|
||||
"validators": "-1",
|
||||
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
|
||||
},
|
||||
err: "number of validators must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "ValidatorsZero",
|
||||
vars: map[string]interface{}{
|
||||
"timeout": "60s",
|
||||
"validators": "0",
|
||||
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
|
||||
},
|
||||
err: "number of validators must be greater than 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
for k, v := range test.vars {
|
||||
viper.Set(k, v)
|
||||
}
|
||||
cmd, err := newCommand(context.Background())
|
||||
require.NoError(t, err)
|
||||
err = cmd.process(context.Background())
|
||||
if test.err != "" {
|
||||
require.EqualError(t, err, test.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
50
cmd/validator/yield/run.go
Normal file
50
cmd/validator/yield/run.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright © 2021 Weald Technology Trading.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package validatoryield
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Run runs the command.
|
||||
func Run(cmd *cobra.Command) (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
c, err := newCommand(ctx)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to set up command")
|
||||
}
|
||||
|
||||
// Further errors do not need a usage report.
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
if err := c.process(ctx); err != nil {
|
||||
return "", errors.Wrap(err, "failed to process")
|
||||
}
|
||||
|
||||
if viper.GetBool("quiet") {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
results, err := c.output(ctx)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to obtain output")
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
// validatorCredentialsCmd represents the validator credentials command
|
||||
var validatorCredentialsCmd = &cobra.Command{
|
||||
Use: "credentials",
|
||||
Short: "Manage Ethereum consensu validator credentials",
|
||||
Long: `Manage Ethereum consensu validator credentials.`,
|
||||
Short: "Manage Ethereum consensus validator credentials",
|
||||
Long: `Manage Ethereum consensus validator credentials.`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -91,9 +91,14 @@ In quiet mode this will return 0 if the validator information can be obtained, o
|
||||
os.Exit(_exitSuccess)
|
||||
}
|
||||
|
||||
if validator.Status.IsPending() || validator.Status.HasActivated() {
|
||||
fmt.Printf("Index: %d\n", validator.Index)
|
||||
}
|
||||
if verbose {
|
||||
if validator.Status.IsPending() {
|
||||
fmt.Printf("Activation eligibility epoch: %d\n", validator.Validator.ActivationEligibilityEpoch)
|
||||
}
|
||||
if validator.Status.HasActivated() {
|
||||
fmt.Printf("Index: %d\n", validator.Index)
|
||||
fmt.Printf("Activation epoch: %d\n", validator.Validator.ActivationEpoch)
|
||||
}
|
||||
fmt.Printf("Public key: %#x\n", validator.Validator.PublicKey)
|
||||
|
||||
61
cmd/validatoryield.go
Normal file
61
cmd/validatoryield.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright © 2022 Weald Technology Trading
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
validatoryield "github.com/wealdtech/ethdo/cmd/validator/yield"
|
||||
)
|
||||
|
||||
var validatorYieldCmd = &cobra.Command{
|
||||
Use: "yield",
|
||||
Short: "Calculate yield for validators",
|
||||
Long: `Calculate yield for validators. For example:
|
||||
|
||||
ethdo validator yield
|
||||
|
||||
It is important to understand the yield is both probabilistic and dependent on network conditions.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
res, err := validatoryield.Run(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if viper.GetBool("quiet") {
|
||||
return nil
|
||||
}
|
||||
res = strings.TrimRight(res, "\n")
|
||||
fmt.Println(res)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
validatorCmd.AddCommand(validatorYieldCmd)
|
||||
validatorFlags(validatorYieldCmd)
|
||||
validatorYieldCmd.Flags().String("validators", "", "Number of active validators (default fetches from chain)")
|
||||
validatorYieldCmd.Flags().Bool("json", false, "JSON output")
|
||||
}
|
||||
|
||||
func validatorYieldBindings() {
|
||||
if err := viper.BindPFlag("validators", validatorYieldCmd.Flags().Lookup("validators")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := viper.BindPFlag("json", validatorYieldCmd.Flags().Lookup("json")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
|
||||
// ReleaseVersion is the release version of the codebase.
|
||||
// Usually overridden by tag names when building binaries.
|
||||
var ReleaseVersion = "local build (latest release 1.23.1)"
|
||||
var ReleaseVersion = "local build (latest release 1.24.1)"
|
||||
|
||||
// versionCmd represents the version command
|
||||
var versionCmd = &cobra.Command{
|
||||
|
||||
@@ -698,6 +698,17 @@ $ ethdo attester inclusion --account=Validators/1 --epoch=6484
|
||||
Attestation included in block 207492 (inclusion delay 1)
|
||||
```
|
||||
|
||||
#### `yield`
|
||||
|
||||
`ethdo validator yield` calculates the expected yield given the number of validators. Options include:
|
||||
- `validators` use a specified number of validators rather than the current number of active validators
|
||||
- `json` obtain detailed information in JSON format
|
||||
|
||||
```sh
|
||||
$ ethdo validator yield
|
||||
Yield: 4.64%
|
||||
```
|
||||
|
||||
## Maintainers
|
||||
|
||||
Jim McDonald: [@mcdee](https://github.com/mcdee).
|
||||
|
||||
3
go.mod
3
go.mod
@@ -22,6 +22,7 @@ require (
|
||||
github.com/prysmaticlabs/go-bitfield v0.0.0-20210809151128-385d8c5e3fb7
|
||||
github.com/prysmaticlabs/go-ssz v0.0.0-20210121151755-f6208871c388
|
||||
github.com/rs/zerolog v1.26.1
|
||||
github.com/shopspring/decimal v1.3.1
|
||||
github.com/spf13/afero v1.8.0 // indirect
|
||||
github.com/spf13/cobra v1.3.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
@@ -42,7 +43,7 @@ require (
|
||||
github.com/wealdtech/go-eth2-wallet-store-s3 v1.10.0
|
||||
github.com/wealdtech/go-eth2-wallet-store-scratch v1.7.0
|
||||
github.com/wealdtech/go-eth2-wallet-types/v2 v2.9.0
|
||||
github.com/wealdtech/go-string2eth v1.1.0
|
||||
github.com/wealdtech/go-string2eth v1.2.0
|
||||
golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed // indirect
|
||||
golang.org/x/text v0.3.7
|
||||
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350 // indirect
|
||||
|
||||
9
go.sum
9
go.sum
@@ -75,8 +75,6 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI
|
||||
github.com/attestantio/dirk v1.1.0 h1:hwMTYZkwj/Y0um3OD0LQxg2xSl4/5xqVWV2MRePE4ec=
|
||||
github.com/attestantio/dirk v1.1.0/go.mod h1:2jkOw/XHjvIDdhDcmj+Z3kuVPpxMcQ6zxzzjSSv71PY=
|
||||
github.com/attestantio/go-eth2-client v0.8.1/go.mod h1:kEK9iAAOBoADO5wEkd84FEOzjT1zXgVWveQsqn+uBGg=
|
||||
github.com/attestantio/go-eth2-client v0.10.0 h1:nmOmzErfz4I2gEkucHKOaFwkbwD4i6JbIX38Z8Dm4Tc=
|
||||
github.com/attestantio/go-eth2-client v0.10.0/go.mod h1:ijuXoXJCBFMexUYaBOl8PXfZKwYUFJy7cV03TMdw8Bo=
|
||||
github.com/attestantio/go-eth2-client v0.11.0 h1:8/Jn5AAfd+4tOggLi+FvOv9/ORaObECv42ab7vK2FJc=
|
||||
github.com/attestantio/go-eth2-client v0.11.0/go.mod h1:zXL/BxC0cBBhxj+tP7QG7t9Ufoa8GwQLdlbvZRd9+dM=
|
||||
github.com/aws/aws-sdk-go v1.33.17/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
|
||||
@@ -354,7 +352,6 @@ github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQ
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.6/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.11 h1:i2lw1Pm7Yi/4O6XCSyJWqEHI2MDw2FzUK6o/D21xn2A=
|
||||
github.com/klauspost/cpuid/v2 v2.0.11/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
@@ -495,6 +492,8 @@ github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDN
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 h1:Xuk8ma/ibJ1fOy4Ee11vHhUFHQNpHhrBneOCNHVXS5w=
|
||||
github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0/go.mod h1:7AwjWCpdPhkSmNAgUv5C7EJ4AbmjEB3r047r3DXWu3Y=
|
||||
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
|
||||
@@ -589,6 +588,8 @@ github.com/wealdtech/go-indexer v1.0.0/go.mod h1:u1cjsbsOXsm5jzJDyLmZY7GsrdX8KYX
|
||||
github.com/wealdtech/go-majordomo v1.0.1/go.mod h1:QoT4S1nUQwdQK19+CfepDwV+Yr7cc3dbF+6JFdQnIqY=
|
||||
github.com/wealdtech/go-string2eth v1.1.0 h1:USJQmysUrBYYmZs7d45pMb90hRSyEwizP7lZaOZLDAw=
|
||||
github.com/wealdtech/go-string2eth v1.1.0/go.mod h1:RUzsLjJtbZaJ/3UKn9kY19a/vCCUHtEWoUW3uiK6yGU=
|
||||
github.com/wealdtech/go-string2eth v1.2.0 h1:C0E5p78tecZTsGccJc9r/kreFah4EfDs5uUPnS6XXMs=
|
||||
github.com/wealdtech/go-string2eth v1.2.0/go.mod h1:RUzsLjJtbZaJ/3UKn9kY19a/vCCUHtEWoUW3uiK6yGU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -843,8 +844,6 @@ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs=
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
||||
Reference in New Issue
Block a user