Compare commits

...

57 Commits

Author SHA1 Message Date
Jim McDonald
d274ab3db0 Release 1.26.0 2022-11-24 15:15:22 +00:00
Jim McDonald
ad3d8606fd Update workflow. 2022-10-31 17:56:35 +00:00
Jim McDonald
30455e7c43 Merge pull request #47 from wealdtech/credentials-set
Set withdrawal credentials.
2022-10-31 17:41:54 +00:00
Jim McDonald
7eb2c68a19 Update operation and docs. 2022-10-29 12:15:14 +01:00
Jim McDonald
2d96f7cb13 Update operation and docs. 2022-10-29 12:10:31 +01:00
Jim McDonald
fd1e4a97bb Initial work on validator summary. 2022-10-16 23:35:44 +01:00
Jim McDonald
be2270c543 Increase security of shamir number generation. 2022-10-16 22:50:30 +01:00
Jim McDonald
0c36239b8b Update output and associated documentation. 2022-10-06 18:19:38 +00:00
Jim McDonald
f78b2922ec Tidy-ups. 2022-10-06 16:15:45 +00:00
Jim McDonald
bcf6ffdaf0 Linting. 2022-10-06 15:33:01 +00:00
Jim McDonald
9fc184f6a1 Update documentation. 2022-10-06 15:24:15 +00:00
Jim McDonald
c9a30a6e4b Update documentation. 2022-10-06 15:20:08 +00:00
Jim McDonald
1ec6ddc914 Set withdrawal credentials. 2022-10-06 15:14:23 +00:00
Jim McDonald
2c96ef958e Use standard function to obtain best public key. 2022-10-05 13:54:41 +00:00
Jim McDonald
3c10131c45 Use util func to obtain validator from input. 2022-10-02 20:53:10 +01:00
Jim McDonald
fe0bfd4f87 Add more information to "epoch summary". 2022-10-01 22:52:20 +01:00
Jim McDonald
290413f115 Update install instructions. 2022-09-27 19:53:39 +01:00
Jim McDonald
4aa6bef6a3 Update release. 2022-08-28 21:28:54 +01:00
Jim McDonald
1b0f4e2803 Bump version. 2022-08-18 14:42:57 +01:00
Jim McDonald
301224748c Update workflow. 2022-08-18 14:28:23 +01:00
Jim McDonald
1e15b836c2 Bump version. 2022-08-11 08:16:55 +01:00
Jim McDonald
1e709b7592 Remove mandatory connection parameter.
The connection parameter is no longer mandatory, in that ethdo will
attempt to obtain a connection using well-known ports if no override is
supplied.  As such the `--connection` parameter can be omitted and so is
not force-required as part of the command initialisation.
2022-08-11 08:11:55 +01:00
Jim McDonald
8744a85cb7 Merge pull request #45 from tcrossland/master
feat: support block analyze on bellatrix
2022-08-06 08:16:58 +01:00
Tom Crossland
92ad77d8f5 feat: support block analyze on bellatrix 2022-08-04 17:25:20 +02:00
Jim McDonald
2298640e4c Merge pull request #44 from aaron-alderman/fix/deposit-message-root-verification
Add deposit message root match verification
2022-07-16 16:27:24 +01:00
Jim McDonald
5baef59672 Tidy up streaming output. 2022-07-13 11:21:09 +01:00
Aaron Alderman
e54e8affa7 Add deposit message root match verification 2022-07-12 10:47:27 +08:00
Jim McDonald
97fa04a7b2 Bump version. 2022-07-10 18:28:54 +01:00
Jim McDonald
4977ee82e5 Add dpeosit signature verirication to "deposit verify". 2022-07-10 12:33:19 +01:00
Jim McDonald
090680366c Do not print 0-value deposit validator information. 2022-06-23 09:52:45 +01:00
Jim McDonald
531c86847f Tidy up tests. 2022-06-22 07:51:22 +01:00
Jim McDonald
446e437531 Update docs. 2022-06-22 07:51:14 +01:00
Jim McDonald
63d8ccf1a0 Add "proposer duties". 2022-06-22 07:50:53 +01:00
Jim McDonald
77abe0e158 Add sepolia support. 2022-06-21 10:05:29 +01:00
Jim McDonald
547f8d9e71 Fix potential crash when new validator is activated. 2022-06-20 16:18:28 +01:00
Jim McDonald
e144217f25 Add "validator yield". 2022-06-12 11:17:59 +01:00
Jim McDonald
d919810ce1 Tidy up eth1 votes output. 2022-06-10 18:13:49 +01:00
Jim McDonald
0bdf68edf6 Do not fetch future states. 2022-06-01 12:42:40 +01:00
Jim McDonald
b24341b7da Do not fetch future states. 2022-06-01 12:42:12 +01:00
Jim McDonald
384ee3dcaa Bump version. 2022-06-01 12:22:23 +01:00
Jim McDonald
3e8b1a6dad Add "chain eth1votes". 2022-06-01 12:21:40 +01:00
Jim McDonald
d2dec4a444 Tidy up formatting. 2022-05-31 13:39:36 +01:00
Jim McDonald
7e171bdb1e Provide more epoch summary information. 2022-05-30 21:52:30 +01:00
Jim McDonald
0cedf79a89 Bump version. 2022-05-30 16:08:58 +01:00
Jim McDonald
65ad1248ce epoch summary no sync committee information pre-Altair. 2022-05-30 16:08:31 +01:00
Jim McDonald
e1180f97ce error on attester inclusion without required params. 2022-05-26 22:29:21 +01:00
Jim McDonald
394b4a7cd2 Bump version. 2022-05-19 13:00:38 +01:00
Jim McDonald
fd574aae34 Tidy up tests. 2022-05-19 13:00:05 +01:00
Jim McDonald
7fe503f51d Add ropsten support. 2022-05-19 12:59:48 +01:00
Jim McDonald
6bfb0ef098 Add validator credentials get command. 2022-05-10 15:02:41 +01:00
Jim McDonald
46c667d387 Add "chain queues". 2022-04-23 08:51:30 +01:00
Jim McDonald
50f4a9cace Update workflow. 2022-04-15 08:09:06 +01:00
Jim McDonald
cd20875744 Provide clearer error if attempting to import to an HD wallet. 2022-03-24 17:28:46 +00:00
Jim McDonald
84f682a0da Guess connection if none supplied. 2022-03-24 09:46:11 +00:00
Jim McDonald
6389b7dfbd Test coverage. 2022-03-23 22:14:34 +00:00
Jim McDonald
0ef65b8bda Allow account import from keystores. 2022-03-23 22:08:10 +00:00
Jim McDonald
4426c3279d Allow account import from keystores. 2022-03-23 22:06:48 +00:00
130 changed files with 7685 additions and 857 deletions

View File

@@ -1,23 +1,22 @@
name: golangci-lint
on: [ push, pull_request ]
on:
push:
branches:
- master
pull_request:
permissions:
contents: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
- uses: actions/setup-go@v3
with:
# Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version.
version: v1.29
# Optional: working directory, useful for monorepos
# working-directory: somedir
# Optional: golangci-lint command line arguments.
args: --timeout=10m
# Optional: show only new issues if it's a pull request. The default value is `false`.
# only-new-issues: true
go-version: 1.17
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout 5m

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- 'v*'
- 't*'
jobs:
build:
@@ -12,10 +13,9 @@ jobs:
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: ^1.16
id: go
go-version: 1.17
- name: Check out code into the Go module directory
uses: actions/checkout@v2
@@ -36,9 +36,9 @@ jobs:
echo "RELEASE_TAG=${RELEASE_TAG}" >> $GITHUB_ENV
echo "::set-output name=RELEASE_TAG::${RELEASE_TAG}"
# Ensure the release tag has expected format.
echo ${RELEASE_TAG} | grep -q '^v' || exit 1
echo ${RELEASE_TAG} | grep -q '^[vt]' || exit 1
# Release version is same as release tag without leading 'v'.
RELEASE_VERSION=$(echo ${GITHUB_REF} | sed -e 's!.*/v!!')
RELEASE_VERSION=$(echo ${GITHUB_REF} | sed -e 's!.*/[vt]!!')
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> $GITHUB_ENV
echo "::set-output name=RELEASE_VERSION::${RELEASE_VERSION}"
@@ -54,7 +54,7 @@ jobs:
- name: Cross-compile linux
run: |
xgo -v -x -ldflags="-X github.com/wealdtech/ethdo/cmd.ReleaseVersion=${RELEASE_VERSION}" --targets="linux/amd64" github.com/wealdtech/ethdo
xgo -v -x -ldflags="-X github.com/wealdtech/ethdo/cmd.ReleaseVersion=${RELEASE_VERSION}" --targets="linux/amd64,linux/arm64" github.com/wealdtech/ethdo
- name: Cross-compile windows
run: |
@@ -72,11 +72,11 @@ jobs:
tar zcf ethdo-${RELEASE_VERSION}-linux-amd64.tar.gz ethdo
sha256sum ethdo-${RELEASE_VERSION}-linux-amd64.tar.gz >ethdo-${RELEASE_VERSION}-linux-amd64.sha256
# - name: Create linux ARM64 tgz file
# run: |
# mv ethdo-linux-arm64 ethdo
# tar zcf ethdo-${RELEASE_VERSION}-linux-arm64.tar.gz ethdo
# sha256sum ethdo-${RELEASE_VERSION}-linux-arm64.tar.gz >ethdo-${RELEASE_VERSION}-linux-arm64.sha256
- name: Create linux ARM64 tgz file
run: |
mv ethdo-linux-arm64 ethdo
tar zcf ethdo-${RELEASE_VERSION}-linux-arm64.tar.gz ethdo
sha256sum ethdo-${RELEASE_VERSION}-linux-arm64.tar.gz >ethdo-${RELEASE_VERSION}-linux-arm64.sha256
- name: Create release
id: create_release
@@ -133,24 +133,24 @@ jobs:
asset_name: ethdo-${{ env.RELEASE_VERSION }}-linux-amd64.tar.gz
asset_content_type: application/gzip
# - name: Upload linux ARM64 checksum file
# id: upload-release-asset-linux-arm64-checksum
# uses: actions/upload-release-asset@v1
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# upload_url: ${{ steps.create_release.outputs.upload_url }}
# asset_path: ./ethdo-${{ env.RELEASE_VERSION }}-linux-arm64.sha256
# asset_name: ethdo-${{ env.RELEASE_VERSION }}-linux-arm64.sha256
# asset_content_type: text/plain
- name: Upload linux ARM64 checksum file
id: upload-release-asset-linux-arm64-checksum
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./ethdo-${{ env.RELEASE_VERSION }}-linux-arm64.sha256
asset_name: ethdo-${{ env.RELEASE_VERSION }}-linux-arm64.sha256
asset_content_type: text/plain
# - name: Upload linux ARM64 tgz file
# id: upload-release-asset-linux-arm64
# uses: actions/upload-release-asset@v1
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# upload_url: ${{ steps.create_release.outputs.upload_url }}
# asset_path: ./ethdo-${{ env.RELEASE_VERSION }}-linux-arm64.tar.gz
# asset_name: ethdo-${{ env.RELEASE_VERSION }}-linux-arm64.tar.gz
# asset_content_type: application/gzip
- name: Upload linux ARM64 tgz file
id: upload-release-asset-linux-arm64
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./ethdo-${{ env.RELEASE_VERSION }}-linux-arm64.tar.gz
asset_name: ethdo-${{ env.RELEASE_VERSION }}-linux-arm64.tar.gz
asset_content_type: application/gzip

3
.gitignore vendored
View File

@@ -18,5 +18,8 @@ coverage.html
# Vim
*.sw?
# Local JSON files
*.json
# Local TODO
TODO.md

View File

@@ -1,3 +1,48 @@
1.26.0
- add commands and documentation to set user validator credentials (not usable until capella)
1.25.3
- add more information to "epoch summary"
- add "validator summary"
1.25.2:
- no longer require connection parameter
- support "block analyze" on bellatrix (thanks @tcrossland)
- check deposit message root match for verifying deposits (thanks @aaron-alderman)
1.25.0:
- add "proposer duties"
- add deposit signature verification to "deposit verify"
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
1.23.0:
- do not fetch sync committee information for epoch summaries prior to Altair
- ensure that "attester inclusion" without validator returns appropriate error
- provide more information in "epoch summary" with verbose flag
- add "chain eth1votes"
1.22.0:
- add "ropsten" to the list of supported networks
1.21.0:
- add "validator credentials get"
1.20.0:
- add "chain queues"
1.19.1:
- add the ability to import keystores to ethdo wallets
- use defaults to connect to beacon nodes if no explicit connection defined
1.19.0:
- add "epoch summary"

View File

@@ -11,6 +11,7 @@ A command-line tool for managing common tasks in Ethereum 2.
- [Binaries](#binaries)
- [Docker](#docker)
- [Source](#source)
- [Setting up](#setting-up)
- [Usage](#usage)
- [Maintainers](#maintainers)
- [Contribute](#contribute)
@@ -34,7 +35,7 @@ docker pull wealdtech/ethdo
`ethdo` is a standard Go program which can be installed with:
```sh
GO111MODULE=on go get github.com/wealdtech/ethdo
go install github.com/wealdtech/ethdo@latest
```
Note that `ethdo` requires at least version 1.13 of go to operate. The version of go can be found with `go version`.
@@ -61,13 +62,37 @@ docker run --network=host ethdo chain status
Alternatively, if the beacon node is running in a separate docker container a shared network can be created with `docker network create eth2` and accessed by adding `--network=eth2` added to both the beacon node and `ethdo` containers.
## Setting up
`ethdo` needs a connection to a beacon node for many of its features. `ethdo` can connect to any beacon node that fully supports the [standard REST API](https://ethereum.github.io/beacon-APIs/). The following changes are required to beacon nodes to make this available.
### Lighthouse
Lighthouse disables the REST API by default. To enable it, the beacon node must be started with the `--http` parameter. If you want to access the REST API from a remote server then you should also look to change the `--http-address` and `--http-allow-origin` options as per the Lighthouse documentation.
The default port for the REST API is 5052, which can be changed with the `--http-port` parameter.
### Nimbus
Nimbus disables the REST API by default. To enable it, the beacon node must be started with the `--rest` parameter. If you want to access the REST API from a remote server then you should also look to change the `--rest-address` and `--rest-allow-origin` options as per the Nimbus documentation.
The default port for the REST API is 5052, which can be changed with the `--rest-port` parameter.
### Prysm
Prysm enables the REST API by default. You will need to add the parameter `--grpc-max-msg-size 268435456` to be obtain to obtain large sets of information such as the list of current validators. If you want to access the REST API from a remote server then you should also look to change the `--grpc-gateway-host` and `--grpc-gateway-corsdomain` options as per the Prysm documentation.
The default port for the REST API is 3500, which can be changed with the `--grpc-gateway-port` parameter.
### Teku
Teku disables the REST API by default. To enable it, the beacon node must be started with the `--rest-api-enabled` parameter. If you want to access the REST API from a remote server then you should also look to change the `--rest-api-interface`, `--rest-api-host-allowlist` and `--rest-api-cors-origins` options as per the Teku documentation.
The default port for the REST API is 5051, which can be changed with the `--rest-api-port` parameter.
## Usage
ethdo contains a large number of features that are useful for day-to-day interactions with the Ethereum 2 blockchain.
`ethdo` contains a large number of features that are useful for day-to-day interactions with the Ethereum 2 blockchain.
### Wallets and accounts
ethdo uses the [go-eth2-wallet](https://github.com/wealdtech/go-eth2-wallet) system to provide unified access to different wallet types. When on the filesystem the locations of the created wallets and accounts are:
`ethdo` uses the [go-eth2-wallet](https://github.com/wealdtech/go-eth2-wallet) system to provide unified access to different wallet types. When on the filesystem the locations of the created wallets and accounts are:
- for Linux: $HOME/.config/ethereum2/wallets
- for OSX: $HOME/Library/Application Support/ethereum2/wallets

View File

@@ -1,315 +0,0 @@
// Copyright © 2019, 2020 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 accountcreate
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"math/rand"
"os"
"testing"
"time"
"github.com/attestantio/dirk/testing/daemon"
"github.com/attestantio/dirk/testing/resources"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
e2types "github.com/wealdtech/go-eth2-types/v2"
dirk "github.com/wealdtech/go-eth2-wallet-dirk"
keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4"
hd "github.com/wealdtech/go-eth2-wallet-hd/v2"
nd "github.com/wealdtech/go-eth2-wallet-nd/v2"
scratch "github.com/wealdtech/go-eth2-wallet-store-scratch"
"google.golang.org/grpc/credentials"
)
func TestProcess(t *testing.T) {
require.NoError(t, e2types.InitBLS())
testNDWallet, err := nd.CreateWallet(context.Background(),
"Test",
scratch.New(),
keystorev4.New(),
)
require.NoError(t, err)
testHDWallet, err := hd.CreateWallet(context.Background(),
"Test",
[]byte("pass"),
scratch.New(),
keystorev4.New(),
[]byte{
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
},
)
require.NoError(t, err)
// #nosec G404
port1 := uint32(12000 + rand.Intn(4000))
// #nosec G404
port2 := uint32(12000 + rand.Intn(4000))
// #nosec G404
port3 := uint32(12000 + rand.Intn(4000))
peers := map[uint64]string{
1: fmt.Sprintf("signer-test01:%d", port1),
2: fmt.Sprintf("signer-test02:%d", port2),
3: fmt.Sprintf("signer-test03:%d", port3),
}
_, path, err := daemon.New(context.Background(), "", 1, port1, peers)
require.NoError(t, err)
defer os.RemoveAll(path)
_, path, err = daemon.New(context.Background(), "", 2, port2, peers)
require.NoError(t, err)
defer os.RemoveAll(path)
_, path, err = daemon.New(context.Background(), "", 3, port3, peers)
require.NoError(t, err)
defer os.RemoveAll(path)
endpoints := []*dirk.Endpoint{
dirk.NewEndpoint("signer-test01", port1),
dirk.NewEndpoint("signer-test02", port2),
dirk.NewEndpoint("signer-test03", port3),
}
credentials, err := credentialsFromCerts(context.Background(), resources.ClientTest01Crt, resources.ClientTest01Key, resources.CACrt)
require.NoError(t, err)
testDistributedWallet, err := dirk.OpenWallet(context.Background(), "Wallet 3", credentials, endpoints)
require.NoError(t, err)
tests := []struct {
name string
dataIn *dataIn
err string
}{
{
name: "Nil",
err: "no data",
},
{
name: "WalletPassphraseIncorrect",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testHDWallet,
accountName: "Good",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "bad",
participants: 1,
signingThreshold: 1,
},
err: "failed to unlock wallet: incorrect passphrase",
},
{
name: "PassphraseMissing",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testHDWallet,
accountName: "Good",
passphrase: "",
walletPassphrase: "pass",
participants: 1,
signingThreshold: 1,
},
err: "passphrase is required",
},
{
name: "PassphraseWeak",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testHDWallet,
accountName: "Good",
passphrase: "poor",
walletPassphrase: "pass",
participants: 1,
signingThreshold: 1,
},
err: "supplied passphrase is weak; use a stronger one or run with the --allow-weak-passphrases flag",
},
{
name: "Good",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testHDWallet,
accountName: "Good",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 1,
signingThreshold: 1,
},
},
{
name: "PathMalformed",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testHDWallet,
accountName: "Pathed",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 1,
signingThreshold: 1,
path: "n/12381/3600/1/2/3",
},
err: "path does not match expected format m/…",
},
{
name: "PathPassphraseMissing",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testHDWallet,
accountName: "Pathed",
passphrase: "",
walletPassphrase: "pass",
participants: 1,
signingThreshold: 1,
path: "m/12381/3600/1/2/3",
},
err: "passphrase is required",
},
{
name: "PathNotSupported",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testNDWallet,
accountName: "Pathed",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 1,
signingThreshold: 1,
path: "m/12381/3600/1/2/3",
},
err: "wallet does not support account creation with an explicit path",
},
{
name: "GoodWithPath",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testHDWallet,
accountName: "Pathed",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 1,
signingThreshold: 1,
path: "m/12381/3600/1/2/3",
},
},
{
name: "DistributedSigningThresholdZero",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testDistributedWallet,
accountName: "Remote",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 3,
signingThreshold: 0,
},
err: "signing threshold required",
},
{
name: "DistributedSigningThresholdNotHalf",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testDistributedWallet,
accountName: "Remote",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 3,
signingThreshold: 1,
},
err: "signing threshold must be more than half the number of participants",
},
{
name: "DistributedSigningThresholdTooHigh",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testDistributedWallet,
accountName: "Remote",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 3,
signingThreshold: 4,
},
err: "signing threshold cannot be higher than the number of participants",
},
{
name: "DistributedNotSupported",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testNDWallet,
accountName: "Remote",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 3,
signingThreshold: 2,
},
err: "wallet does not support distributed account creation",
},
{
name: "DistributedGood",
dataIn: &dataIn{
timeout: 5 * time.Second,
wallet: testDistributedWallet,
accountName: "Remote",
passphrase: "ce%NohGhah4ye5ra",
walletPassphrase: "pass",
participants: 3,
signingThreshold: 2,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
res, err := process(context.Background(), test.dataIn)
if test.err != "" {
require.EqualError(t, err, test.err)
} else {
require.NoError(t, err)
require.Equal(t, test.dataIn.accountName, res.account.Name())
}
})
}
}
func TestNilData(t *testing.T) {
_, err := processStandard(context.Background(), nil)
require.EqualError(t, err, "no data")
_, err = processPathed(context.Background(), nil)
require.EqualError(t, err, "no data")
_, err = processDistributed(context.Background(), nil)
require.EqualError(t, err, "no data")
}
func credentialsFromCerts(ctx context.Context, clientCert []byte, clientKey []byte, caCert []byte) (credentials.TransportCredentials, error) {
clientPair, err := tls.X509KeyPair(clientCert, clientKey)
if err != nil {
return nil, errors.Wrap(err, "failed to load client keypair")
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{clientPair},
MinVersion: tls.VersionTLS13,
}
if caCert != nil {
cp := x509.NewCertPool()
if !cp.AppendCertsFromPEM(caCert) {
return nil, errors.New("failed to add CA certificate")
}
tlsCfg.RootCAs = cp
}
return credentials.NewTLS(tlsCfg), nil
}

View File

@@ -42,11 +42,13 @@ func output(ctx context.Context, data *dataOut) (string, error) {
if data.showPrivateKey {
builder.WriteString(fmt.Sprintf("Private key: %#x\n", data.key.Marshal()))
}
builder.WriteString(fmt.Sprintf("Public key: %#x", data.key.PublicKey().Marshal()))
if data.showWithdrawalCredentials {
withdrawalCredentials := util.SHA256(data.key.PublicKey().Marshal())
withdrawalCredentials[0] = byte(0) // BLS_WITHDRAWAL_PREFIX
builder.WriteString(fmt.Sprintf("\nWithdrawal credentials: %#x", withdrawalCredentials))
builder.WriteString(fmt.Sprintf("Withdrawal credentials: %#x\n", withdrawalCredentials))
}
if !(data.showPrivateKey || data.showWithdrawalCredentials) {
builder.WriteString(fmt.Sprintf("Public key: %#x\n", data.key.PublicKey().Marshal()))
}
return builder.String(), nil

View File

@@ -64,7 +64,7 @@ func TestOutput(t *testing.T) {
key: blsPrivateKey("0x068dce0c90cb428ab37a74af0191eac49648035f1aaef077734b91e05985ec55"),
showPrivateKey: true,
},
needs: []string{"Public key", "Private key"},
needs: []string{"Private key"},
},
{
name: "WithdrawalCredentials",
@@ -72,7 +72,7 @@ func TestOutput(t *testing.T) {
key: blsPrivateKey("0x068dce0c90cb428ab37a74af0191eac49648035f1aaef077734b91e05985ec55"),
showWithdrawalCredentials: true,
},
needs: []string{"Public key", "Withdrawal credentials"},
needs: []string{"Withdrawal credentials"},
},
{
name: "All",
@@ -81,7 +81,7 @@ func TestOutput(t *testing.T) {
showPrivateKey: true,
showWithdrawalCredentials: true,
},
needs: []string{"Public key", "Private key", "Withdrawal credentials"},
needs: []string{"Private key", "Withdrawal credentials"},
},
}

View File

@@ -15,57 +15,32 @@ package accountderive
import (
"context"
"regexp"
"strings"
"github.com/pkg/errors"
"github.com/tyler-smith/go-bip39"
util "github.com/wealdtech/go-eth2-util"
"golang.org/x/text/unicode/norm"
"github.com/wealdtech/ethdo/util"
e2types "github.com/wealdtech/go-eth2-types/v2"
e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2"
)
// pathRegex is the regular expression that matches an HD path.
var pathRegex = regexp.MustCompile("^m/[0-9]+/[0-9]+(/[0-9+])+")
func process(ctx context.Context, data *dataIn) (*dataOut, error) {
if data == nil {
return nil, errors.New("no data")
}
// If there are more than 24 words we treat the additional characters as the passphrase.
mnemonicParts := strings.Split(data.mnemonic, " ")
mnemonicPassphrase := ""
if len(mnemonicParts) > 24 {
data.mnemonic = strings.Join(mnemonicParts[:24], " ")
mnemonicPassphrase = strings.Join(mnemonicParts[24:], " ")
}
// Normalise the input.
data.mnemonic = string(norm.NFKD.Bytes([]byte(data.mnemonic)))
mnemonicPassphrase = string(norm.NFKD.Bytes([]byte(mnemonicPassphrase)))
if !bip39.IsMnemonicValid(data.mnemonic) {
return nil, errors.New("mnemonic is invalid")
}
// Create seed from mnemonic and passphrase.
seed := bip39.NewSeed(data.mnemonic, mnemonicPassphrase)
// Ensure the path is valid.
match := pathRegex.Match([]byte(data.path))
if !match {
return nil, errors.New("path does not match expected format m/…")
}
// Derive private key from seed and path.
key, err := util.PrivateKeyFromSeedAndPath(seed, data.path)
account, err := util.ParseAccount(ctx, data.mnemonic, []string{data.path}, true)
if err != nil {
return nil, errors.Wrap(err, "failed to generate key")
return nil, errors.Wrap(err, "failed to derive account")
}
key, err := account.(e2wtypes.AccountPrivateKeyProvider).PrivateKey(ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to obtain account private key")
}
results := &dataOut{
showPrivateKey: data.showPrivateKey,
showPrivateKey: data.showPrivateKey,
showWithdrawalCredentials: data.showWithdrawalCredentials,
key: key,
key: key.(*e2types.BLSPrivateKey),
}
return results, nil

View File

@@ -40,7 +40,7 @@ func TestProcess(t *testing.T) {
dataIn: &dataIn{
path: "m/12381/3600/0/0",
},
err: "mnemonic is invalid",
err: "failed to derive account: no account specified",
},
{
name: "MnemonicInvalid",
@@ -48,14 +48,14 @@ func TestProcess(t *testing.T) {
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
path: "m/12381/3600/0/0",
},
err: "mnemonic is invalid",
err: "failed to derive account: mnemonic is invalid",
},
{
name: "PathMissing",
dataIn: &dataIn{
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
},
err: "path does not match expected format m/…",
err: "failed to derive account: path does not match expected format m/…",
},
{
name: "PathInvalid",
@@ -63,7 +63,7 @@ func TestProcess(t *testing.T) {
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
path: "n/12381/3600/0/0",
},
err: "path does not match expected format m/…",
err: "failed to derive account: path does not match expected format m/…",
},
{
name: "Good",

View File

@@ -15,6 +15,7 @@ package accountderive
import (
"context"
"strings"
"github.com/pkg/errors"
"github.com/spf13/cobra"
@@ -45,5 +46,5 @@ func Run(cmd *cobra.Command) (string, error) {
return "", errors.Wrap(err, "failed to obtain output")
}
return results, nil
return strings.TrimSuffix(results, "\n"), nil
}

View File

@@ -1,4 +1,4 @@
// Copyright © 2019, 2020 Weald Technology Trading
// Copyright © 2019 - 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
@@ -16,6 +16,7 @@ package accountimport
import (
"context"
"encoding/hex"
"io/ioutil"
"strings"
"time"
@@ -27,12 +28,14 @@ import (
)
type dataIn struct {
timeout time.Duration
wallet e2wtypes.Wallet
key []byte
accountName string
passphrase string
walletPassphrase string
timeout time.Duration
wallet e2wtypes.Wallet
key []byte
accountName string
passphrase string
walletPassphrase string
keystore []byte
keystorePassphrase []byte
}
func input(ctx context.Context) (*dataIn, error) {
@@ -74,14 +77,55 @@ func input(ctx context.Context) (*dataIn, error) {
// Wallet passphrase.
data.walletPassphrase = util.GetWalletPassphrase()
// Key.
if viper.GetString("key") == "" {
return nil, errors.New("key is required")
if viper.GetString("key") == "" && viper.GetString("keystore") == "" {
return nil, errors.New("key or keystore is required")
}
data.key, err = hex.DecodeString(strings.TrimPrefix(viper.GetString("key"), "0x"))
if err != nil {
return nil, errors.Wrap(err, "key is malformed")
if viper.GetString("key") != "" && viper.GetString("keystore") != "" {
return nil, errors.New("only one of key and keystore is required")
}
if viper.GetString("key") != "" {
data.key, err = hex.DecodeString(strings.TrimPrefix(viper.GetString("key"), "0x"))
if err != nil {
return nil, errors.Wrap(err, "key is malformed")
}
}
if viper.GetString("keystore") != "" {
data.keystorePassphrase = []byte(viper.GetString("keystore-passphrase"))
if len(data.keystorePassphrase) == 0 {
return nil, errors.New("must supply keystore passphrase with keystore-passphrase when supplying keystore")
}
data.keystore, err = obtainKeystore(viper.GetString("keystore"))
if err != nil {
return nil, errors.Wrap(err, "invalid keystore")
}
}
return data, nil
}
// obtainKeystore obtains keystore from an input, could be JSON itself or a path to JSON.
func obtainKeystore(input string) ([]byte, error) {
var err error
var data []byte
// Input could be JSON or a path to JSON
if strings.HasPrefix(input, "{") {
// Looks like JSON
data = []byte(input)
} else {
// Assume it's a path to JSON
data, err = ioutil.ReadFile(input)
if err != nil {
return nil, errors.Wrap(err, "failed to find deposit data file")
}
}
return data, nil
// exitData := &util.ValidatorExitData{}
// err = json.Unmarshal(data, exitData)
// if err != nil {
// return nil, errors.Wrap(err, "data is not valid JSON")
// }
// return exitData, nil
}

View File

@@ -102,7 +102,7 @@ func TestInput(t *testing.T) {
"account": "Test wallet/Test account",
"passphrase": "ce%NohGhah4ye5ra",
},
err: "key is required",
err: "key or keystore is required",
},
{
name: "KeyMalformed",
@@ -114,6 +114,26 @@ func TestInput(t *testing.T) {
},
err: "key is malformed: encoding/hex: invalid byte: U+0069 'i'",
},
{
name: "KeyandKeystore",
vars: map[string]interface{}{
"timeout": "5s",
"account": "Test wallet/Test account",
"passphrase": "ce%NohGhah4ye5ra",
"key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866",
"keystore": "{}",
},
err: "only one of key and keystore is required",
},
{
name: "KeystoreNoKeystorePassphrase",
vars: map[string]interface{}{
"timeout": "5s",
"account": "Test wallet/Test account",
"keystore": "{}",
},
err: "must supply keystore passphrase with keystore-passphrase when supplying keystore",
},
{
name: "Good",
vars: map[string]interface{}{

View File

@@ -1,4 +1,4 @@
// Copyright © 2019, 2020 Weald Technology Trading
// Copyright © 2019 -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
@@ -15,9 +15,14 @@ package accountimport
import (
"context"
"fmt"
"github.com/pkg/errors"
"github.com/wealdtech/ethdo/util"
"github.com/wealdtech/go-ecodec"
keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4"
nd "github.com/wealdtech/go-eth2-wallet-nd/v2"
scratch "github.com/wealdtech/go-eth2-wallet-store-scratch"
e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2"
)
@@ -43,9 +48,23 @@ func process(ctx context.Context, data *dataIn) (*dataOut, error) {
}()
}
if len(data.key) > 0 {
return processFromKey(ctx, data)
}
if len(data.keystore) > 0 {
return processFromKeystore(ctx, data)
}
return nil, errors.New("unsupported import mechanism")
}
func processFromKey(ctx context.Context, data *dataIn) (*dataOut, error) {
results := &dataOut{}
account, err := data.wallet.(e2wtypes.WalletAccountImporter).ImportAccount(ctx, data.accountName, data.key, []byte(data.passphrase))
importer, isImporter := data.wallet.(e2wtypes.WalletAccountImporter)
if !isImporter {
return nil, fmt.Errorf("%s wallets do not support importing accounts", data.wallet.Type())
}
account, err := importer.ImportAccount(ctx, data.accountName, data.key, []byte(data.passphrase))
if err != nil {
return nil, errors.Wrap(err, "failed to import account")
}
@@ -53,3 +72,39 @@ func process(ctx context.Context, data *dataIn) (*dataOut, error) {
return results, nil
}
func processFromKeystore(ctx context.Context, data *dataIn) (*dataOut, error) {
// Need to import the keystore in to a temporary wallet to fetch the private key.
store := scratch.New()
encryptor := keystorev4.New()
// Need to add a couple of fields to the keystore to make it compliant.
keystoreData := fmt.Sprintf(`{"name":"Import","encryptor":"keystore",%s`, string(data.keystore[1:]))
walletData := fmt.Sprintf(`{"wallet":{"name":"ImportTest","type":"non-deterministic","uuid":"e1526407-1dc7-4f3f-9d05-ab696f40707c","version":1},"accounts":[%s]}`, keystoreData)
encryptedData, err := ecodec.Encrypt([]byte(walletData), data.keystorePassphrase)
if err != nil {
return nil, err
}
wallet, err := nd.Import(ctx, encryptedData, data.keystorePassphrase, store, encryptor)
if err != nil {
return nil, errors.Wrap(err, "failed to import wallet")
}
account := <-wallet.Accounts(ctx)
privateKeyProvider, isPrivateKeyProvider := account.(e2wtypes.AccountPrivateKeyProvider)
if !isPrivateKeyProvider {
return nil, errors.New("account does not provide its private key")
}
if locker, isLocker := account.(e2wtypes.AccountLocker); isLocker {
if err = locker.Unlock(ctx, data.keystorePassphrase); err != nil {
return nil, errors.Wrap(err, "failed to unlock account")
}
}
key, err := privateKeyProvider.PrivateKey(ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to obtain private key")
}
data.key = key.Marshal()
// We have the key from the keystore; import it.
return processFromKey(ctx, data)
}

View File

@@ -49,7 +49,6 @@ func init() {
accountFlags(accountCreateCmd)
accountCreateCmd.Flags().Uint32("participants", 1, "Number of participants (1 for non-distributed accounts, >1 for distributed accounts)")
accountCreateCmd.Flags().Uint32("signing-threshold", 1, "Signing threshold (1 for non-distributed accounts)")
accountCreateCmd.Flags().String("path", "", "path of account (for hierarchical deterministic accounts)")
}
func accountCreateBindings() {
@@ -59,7 +58,4 @@ func accountCreateBindings() {
if err := viper.BindPFlag("signing-threshold", accountCreateCmd.Flags().Lookup("signing-threshold")); err != nil {
panic(err)
}
if err := viper.BindPFlag("path", accountCreateCmd.Flags().Lookup("path")); err != nil {
panic(err)
}
}

View File

@@ -47,19 +47,11 @@ In quiet mode this will return 0 if the inputs can derive an account account, ot
func init() {
accountCmd.AddCommand(accountDeriveCmd)
accountFlags(accountDeriveCmd)
accountDeriveCmd.Flags().String("mnemonic", "", "mnemonic from which to derive the HD seed")
accountDeriveCmd.Flags().String("path", "", "path from which to derive the account")
accountDeriveCmd.Flags().Bool("show-private-key", false, "show private key for derived account")
accountDeriveCmd.Flags().Bool("show-withdrawal-credentials", false, "show withdrawal credentials for derived account")
}
func accountDeriveBindings() {
if err := viper.BindPFlag("mnemonic", accountDeriveCmd.Flags().Lookup("mnemonic")); err != nil {
panic(err)
}
if err := viper.BindPFlag("path", accountDeriveCmd.Flags().Lookup("path")); err != nil {
panic(err)
}
if err := viper.BindPFlag("show-private-key", accountDeriveCmd.Flags().Lookup("show-private-key")); err != nil {
panic(err)
}

View File

@@ -48,10 +48,18 @@ func init() {
accountCmd.AddCommand(accountImportCmd)
accountFlags(accountImportCmd)
accountImportCmd.Flags().String("key", "", "Private key of the account to import (0x...)")
accountImportCmd.Flags().String("keystore", "", "Keystore, or path to keystore ")
accountImportCmd.Flags().String("keystore-passphrase", "", "Passphrase of keystore")
}
func accountImportBindings() {
if err := viper.BindPFlag("key", accountImportCmd.Flags().Lookup("key")); err != nil {
panic(err)
}
if err := viper.BindPFlag("keystore", accountImportCmd.Flags().Lookup("keystore")); err != nil {
panic(err)
}
if err := viper.BindPFlag("keystore-passphrase", accountImportCmd.Flags().Lookup("keystore-passphrase")); err != nil {
panic(err)
}
}

View File

@@ -73,7 +73,7 @@ func TestInput(t *testing.T) {
"timeout": "5s",
"pubkey": "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c",
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to any beacon node",
},
}

View File

@@ -73,7 +73,7 @@ func TestInput(t *testing.T) {
"timeout": "5s",
"pubkey": "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c",
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to any beacon node",
},
}

View File

@@ -49,7 +49,7 @@ func init() {
attesterFlags(attesterInclusionCmd)
attesterInclusionCmd.Flags().Int64("epoch", -1, "the last complete epoch")
attesterInclusionCmd.Flags().String("pubkey", "", "the public key of the attester")
attesterInclusionCmd.Flags().Int64("index", -1, "the index of the attester")
attesterInclusionCmd.Flags().String("index", "", "the index of the attester")
}
func attesterInclusionBindings() {

View File

@@ -122,9 +122,6 @@ func newCommand(ctx context.Context) (*command, error) {
}
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")

View File

@@ -37,27 +37,10 @@ func TestInput(t *testing.T) {
vars: map[string]interface{}{},
err: "timeout is required",
},
{
name: "ConnectionMissing",
vars: map[string]interface{}{
"validators": "1",
"timeout": "5s",
},
err: "connection is required",
},
{
name: "ValidatorsZero",
vars: map[string]interface{}{
"timeout": "5s",
"validators": "0",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
},
err: "validators must be at least 1",
},
{
name: "Good",
vars: map[string]interface{}{
"validators": "1",
"blockid": "1",
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
},

View File

@@ -425,6 +425,13 @@ func (c *command) analyzeSyncCommittees(ctx context.Context, block *spec.Version
c.analysis.SyncCommitee.Value = c.analysis.SyncCommitee.Score * float64(c.analysis.SyncCommitee.Contributions)
c.analysis.Value += c.analysis.SyncCommitee.Value
return nil
case spec.DataVersionBellatrix:
c.analysis.SyncCommitee.Contributions = int(block.Bellatrix.Message.Body.SyncAggregate.SyncCommitteeBits.Count())
c.analysis.SyncCommitee.PossibleContributions = int(block.Bellatrix.Message.Body.SyncAggregate.SyncCommitteeBits.Len())
c.analysis.SyncCommitee.Score = float64(c.syncRewardWeight) / float64(c.weightDenominator)
c.analysis.SyncCommitee.Value = c.analysis.SyncCommitee.Score * float64(c.analysis.SyncCommitee.Contributions)
c.analysis.Value += c.analysis.SyncCommitee.Value
return nil
default:
return fmt.Errorf("unsupported block version %d", block.Version)
}

View File

@@ -33,13 +33,13 @@ func TestProcess(t *testing.T) {
err string
}{
{
name: "InvalidData",
name: "NoBlock",
vars: map[string]interface{}{
"timeout": "60s",
"validators": "1",
"data": "[[",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
"blockid": "invalid",
},
err: "failed to obtain beacon block: failed to request signed beacon block: GET failed with status 400: {\"code\":400,\"message\":\"Invalid block: invalid\"}",
},
}

View File

@@ -66,7 +66,7 @@ func TestInput(t *testing.T) {
vars: map[string]interface{}{
"timeout": "5s",
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to any beacon node",
},
{
name: "ConnectionBad",
@@ -79,7 +79,7 @@ func TestInput(t *testing.T) {
timeout: 5 * time.Second,
blockID: "justified",
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to beacon node: failed to confirm node connection: failed to fetch genesis: failed to request genesis: failed to call GET endpoint: Get \"http://localhost:1/eth/v1/beacon/genesis\": dial tcp 127.0.0.1:1: connect: connection refused",
},
{
name: "BlockIDNil",

View File

@@ -296,7 +296,7 @@ func outputBellatrixBlockText(ctx context.Context, data *dataOut, signedBlock *b
bodyRoot,
signedBlock.Message.ParentRoot,
signedBlock.Message.StateRoot,
signedBlock.Message.Body.Graffiti,
signedBlock.Message.Body.Graffiti[:],
data.genesisTime,
data.slotDuration,
data.slotsPerEpoch)
@@ -386,7 +386,7 @@ func outputAltairBlockText(ctx context.Context, data *dataOut, signedBlock *alta
bodyRoot,
signedBlock.Message.ParentRoot,
signedBlock.Message.StateRoot,
signedBlock.Message.Body.Graffiti,
signedBlock.Message.Body.Graffiti[:],
data.genesisTime,
data.slotDuration,
data.slotsPerEpoch)
@@ -469,7 +469,7 @@ func outputPhase0BlockText(ctx context.Context, data *dataOut, signedBlock *phas
bodyRoot,
signedBlock.Message.ParentRoot,
signedBlock.Message.StateRoot,
signedBlock.Message.Body.Graffiti,
signedBlock.Message.Body.Graffiti[:],
data.genesisTime,
data.slotDuration,
data.slotsPerEpoch)
@@ -540,6 +540,8 @@ func outputBlockExecutionPayload(ctx context.Context,
if !verbose {
res.WriteString("Execution block number: ")
res.WriteString(fmt.Sprintf("%d\n", payload.BlockNumber))
res.WriteString("Transactions: ")
res.WriteString(fmt.Sprintf("%d\n", len(payload.Transactions)))
} else {
res.WriteString("Execution payload:\n")
res.WriteString(" Execution block number: ")
@@ -573,6 +575,8 @@ func outputBlockExecutionPayload(ctx context.Context,
res.WriteString(fmt.Sprintf("%#x\n", payload.ExtraData))
res.WriteString(" Logs bloom: ")
res.WriteString(fmt.Sprintf("%#x\n", payload.LogsBloom))
res.WriteString(" Transactions: ")
res.WriteString(fmt.Sprintf("%d\n", len(payload.Transactions)))
}
return res.String(), nil

View File

@@ -82,6 +82,9 @@ func process(ctx context.Context, data *dataIn) (*dataOut, error) {
if data.stream {
jsonOutput = data.jsonOutput
sszOutput = data.sszOutput
if !jsonOutput && !sszOutput {
fmt.Println("")
}
err := data.eth2Client.(eth2client.EventsProvider).Events(ctx, []string{"head"}, headEventHandler)
if err != nil {
return nil, errors.Wrap(err, "failed to start block stream")
@@ -101,13 +104,13 @@ func headEventHandler(event *api.Event) {
blockID := fmt.Sprintf("%#x", event.Data.(*api.HeadEvent).Block[:])
signedBlock, err := results.eth2Client.(eth2client.SignedBeaconBlockProvider).SignedBeaconBlock(context.Background(), blockID)
if err != nil {
if !jsonOutput {
if !jsonOutput && !sszOutput {
fmt.Printf("Failed to obtain block: %v\n", err)
}
return
}
if signedBlock == nil {
if !jsonOutput {
if !jsonOutput && !sszOutput {
fmt.Println("Empty beacon block")
}
return
@@ -115,31 +118,34 @@ func headEventHandler(event *api.Event) {
switch signedBlock.Version {
case spec.DataVersionPhase0:
if err := outputPhase0Block(context.Background(), jsonOutput, signedBlock.Phase0); err != nil {
if !jsonOutput {
if !jsonOutput && !sszOutput {
fmt.Printf("Failed to output block: %v\n", err)
}
return
}
case spec.DataVersionAltair:
if err := outputAltairBlock(context.Background(), jsonOutput, sszOutput, signedBlock.Altair); err != nil {
if !jsonOutput {
if !jsonOutput && !sszOutput {
fmt.Printf("Failed to output block: %v\n", err)
}
return
}
case spec.DataVersionBellatrix:
if err := outputBellatrixBlock(context.Background(), jsonOutput, sszOutput, signedBlock.Bellatrix); err != nil {
if !jsonOutput {
if !jsonOutput && !sszOutput {
fmt.Printf("Failed to output block: %v\n", err)
}
return
}
default:
if !jsonOutput {
if !jsonOutput && !sszOutput {
fmt.Printf("Unknown block version: %v\n", signedBlock.Version)
}
return
}
if !jsonOutput && !sszOutput {
fmt.Println("")
}
}
func outputPhase0Block(ctx context.Context, jsonOutput bool, signedBlock *phase0.SignedBeaconBlock) error {
@@ -155,7 +161,7 @@ func outputPhase0Block(ctx context.Context, jsonOutput bool, signedBlock *phase0
if err != nil {
return errors.Wrap(err, "failed to generate text")
}
fmt.Printf("%s\n", data)
fmt.Print(data)
}
return nil
}
@@ -179,7 +185,7 @@ func outputAltairBlock(ctx context.Context, jsonOutput bool, sszOutput bool, sig
if err != nil {
return errors.Wrap(err, "failed to generate text")
}
fmt.Printf("%s\n", data)
fmt.Print(data)
}
return nil
}
@@ -203,7 +209,7 @@ func outputBellatrixBlock(ctx context.Context, jsonOutput bool, sszOutput bool,
if err != nil {
return errors.Wrap(err, "failed to generate text")
}
fmt.Printf("%s\n", data)
fmt.Print(data)
}
return nil
}

View 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 chaineth1votes
import (
"context"
"time"
eth2client "github.com/attestantio/go-eth2-client"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/wealdtech/ethdo/services/chaintime"
)
type command struct {
quiet bool
verbose bool
debug bool
json bool
// Beacon node connection.
timeout time.Duration
connection string
allowInsecureConnections bool
// Input.
xepoch string
xperiod string
// Data access.
eth2Client eth2client.Service
chainTime chaintime.Service
beaconStateProvider eth2client.BeaconStateProvider
slotsPerEpoch uint64
epochsPerEth1VotingPeriod uint64
// Output.
slot phase0.Slot
epoch phase0.Epoch
period uint64
incumbent *phase0.ETH1Data
eth1DataVotes []*phase0.ETH1Data
votes map[string]*vote
}
type vote struct {
Vote *phase0.ETH1Data `json:"vote"`
Count int `json:"count"`
}
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"),
}
// Timeout.
if viper.GetDuration("timeout") == 0 {
return nil, errors.New("timeout is required")
}
c.timeout = viper.GetDuration("timeout")
c.xepoch = viper.GetString("epoch")
c.xperiod = viper.GetString("period")
c.connection = viper.GetString("connection")
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")
return c, nil
}

View File

@@ -0,0 +1,64 @@
// 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 chaineth1votes
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: "Good",
vars: map[string]interface{}{
"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)
}
})
}
}

View File

@@ -0,0 +1,125 @@
// 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 chaineth1votes
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/attestantio/go-eth2-client/spec/phase0"
)
type jsonOutput struct {
Period uint64 `json:"period"`
Epoch phase0.Epoch `json:"epoch"`
Slot phase0.Slot `json:"slot"`
Incumbent *phase0.ETH1Data `json:"incumbent"`
Votes []*vote `json:"votes"`
}
func (c *command) output(ctx context.Context) (string, error) {
if c.quiet {
return "", nil
}
if c.json {
return c.outputJSON(ctx)
}
return c.outputText(ctx)
}
func (c *command) outputJSON(ctx context.Context) (string, error) {
votes := make([]*vote, 0, len(c.votes))
totalVotes := 0
for _, vote := range c.votes {
votes = append(votes, vote)
totalVotes += vote.Count
}
sort.Slice(votes, func(i int, j int) bool {
if votes[i].Count != votes[j].Count {
return votes[i].Count > votes[j].Count
}
return votes[i].Vote.DepositCount < votes[j].Vote.DepositCount
})
output := &jsonOutput{
Period: c.period,
Epoch: c.epoch,
Slot: c.slot,
Incumbent: c.incumbent,
Votes: votes,
}
data, err := json.Marshal(output)
if err != nil {
return "", err
}
return string(data), nil
}
func (c *command) outputText(ctx context.Context) (string, error) {
builder := strings.Builder{}
builder.WriteString("Voting period: ")
builder.WriteString(fmt.Sprintf("%d\n", c.period))
if c.verbose {
builder.WriteString("Incumbent: ")
builder.WriteString(fmt.Sprintf("block %#x, deposit count %d\n", c.incumbent.BlockHash, c.incumbent.DepositCount))
}
votes := make([]*vote, 0, len(c.votes))
totalVotes := 0
for _, vote := range c.votes {
votes = append(votes, vote)
totalVotes += vote.Count
}
sort.Slice(votes, func(i int, j int) bool {
if votes[i].Count != votes[j].Count {
return votes[i].Count > votes[j].Count
}
return votes[i].Vote.DepositCount < votes[j].Vote.DepositCount
})
slot := c.chainTime.CurrentSlot()
if slot > c.slot {
slot = c.slot
}
slotsThroughPeriod := slot + 1 - phase0.Slot(c.period*(c.slotsPerEpoch*c.epochsPerEth1VotingPeriod))
builder.WriteString("Slots through period: ")
builder.WriteString(fmt.Sprintf("%d (%d)\n", slotsThroughPeriod, c.slot))
builder.WriteString("Votes this period: ")
builder.WriteString(fmt.Sprintf("%d\n", totalVotes))
if len(votes) > 0 {
if c.verbose {
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")
}
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 (%0.2f%%)\n", votes[0].Vote.BlockHash, votes[0].Count, 100.0*float64(votes[0].Count)/float64(slotsThroughPeriod)))
}
}
return strings.TrimSuffix(builder.String(), "\n"), nil
}

View File

@@ -0,0 +1,161 @@
// 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 chaineth1votes
import (
"context"
"encoding/json"
"fmt"
"strconv"
eth2client "github.com/attestantio/go-eth2-client"
"github.com/attestantio/go-eth2-client/spec"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
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
}
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(c.epoch+1) - 1
// Do not fetch from the future.
if fetchSlot > c.chainTime.CurrentSlot() {
fetchSlot = c.chainTime.CurrentSlot()
}
state, err := c.beaconStateProvider.BeaconState(ctx, fmt.Sprintf("%d", fetchSlot))
if err != nil {
return errors.Wrap(err, "failed to obtain state")
}
if state == nil {
return errors.New("state not returned by beacon node")
}
if c.debug {
data, err := json.Marshal(state)
if err == nil {
fmt.Printf("%s\n", string(data))
}
}
switch state.Version {
case spec.DataVersionPhase0:
c.slot = phase0.Slot(state.Phase0.Slot)
c.incumbent = state.Phase0.ETH1Data
c.eth1DataVotes = state.Phase0.ETH1DataVotes
case spec.DataVersionAltair:
c.slot = phase0.Slot(state.Altair.Slot)
c.incumbent = state.Altair.ETH1Data
c.eth1DataVotes = state.Altair.ETH1DataVotes
case spec.DataVersionBellatrix:
c.slot = phase0.Slot(state.Bellatrix.Slot)
c.incumbent = state.Bellatrix.ETH1Data
c.eth1DataVotes = state.Bellatrix.ETH1DataVotes
default:
return fmt.Errorf("unhandled beacon state version %v", state.Version)
}
c.period = uint64(c.epoch) / c.epochsPerEth1VotingPeriod
c.votes = make(map[string]*vote)
for _, eth1Vote := range c.eth1DataVotes {
key := fmt.Sprintf("%#x:%d", eth1Vote.BlockHash, eth1Vote.DepositCount)
if _, exists := c.votes[key]; !exists {
c.votes[key] = &vote{
Vote: eth1Vote,
}
}
c.votes[key].Count++
}
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")
}
c.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")
}
var isProvider bool
c.beaconStateProvider, isProvider = c.eth2Client.(eth2client.BeaconStateProvider)
if !isProvider {
return errors.New("connection does not provide beacon state")
}
specProvider, isProvider := c.eth2Client.(eth2client.SpecProvider)
if !isProvider {
return errors.New("connection does not provide spec information")
}
spec, err := specProvider.Spec(ctx)
if err != nil {
return errors.Wrap(err, "failed to obtain spec")
}
tmp, exists := spec["SLOTS_PER_EPOCH"]
if !exists {
return errors.New("spec did not contain SLOTS_PER_EPOCH")
}
var good bool
c.slotsPerEpoch, good = tmp.(uint64)
if !good {
return errors.New("SLOTS_PER_EPOCH value invalid")
}
tmp, exists = spec["EPOCHS_PER_ETH1_VOTING_PERIOD"]
if !exists {
return errors.New("spec did not contain EPOCHS_PER_ETH1_VOTING_PERIOD")
}
c.epochsPerEth1VotingPeriod, good = tmp.(uint64)
if !good {
return errors.New("EPOCHS_PER_ETH1_VOTING_PERIOD value invalid")
}
return nil
}

View File

@@ -0,0 +1,66 @@
// 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 chaineth1votes
import (
"context"
"os"
"testing"
"github.com/rs/zerolog"
"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")
}
zerolog.SetGlobalLevel(zerolog.Disabled)
tests := []struct {
name string
vars map[string]interface{}
err string
}{
{
name: "InvalidEpoch",
vars: map[string]interface{}{
"timeout": "5s",
"epoch": "invalid",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
},
err: "failed to parse epoch: strconv.ParseInt: parsing \"invalid\": invalid syntax",
},
}
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)
}
})
}
}

View File

@@ -0,0 +1,50 @@
// 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 chaineth1votes
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
}

View File

@@ -0,0 +1,72 @@
// 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 chainqueues
import (
"context"
"time"
eth2client "github.com/attestantio/go-eth2-client"
"github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/wealdtech/ethdo/services/chaintime"
)
type command struct {
quiet bool
verbose bool
debug bool
json bool
// Beacon node connection.
timeout time.Duration
connection string
allowInsecureConnections bool
// Input.
epoch string
// Data access.
eth2Client eth2client.Service
validatorsProvider eth2client.ValidatorsProvider
chainTime chaintime.Service
// Output.
activationQueue int
exitQueue int
}
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"),
}
// Timeout.
if viper.GetDuration("timeout") == 0 {
return nil, errors.New("timeout is required")
}
c.timeout = viper.GetDuration("timeout")
if viper.GetString("epoch") != "" {
c.epoch = viper.GetString("epoch")
}
c.connection = viper.GetString("connection")
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")
return c, nil
}

View File

@@ -0,0 +1,64 @@
// 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 chainqueues
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: "Good",
vars: map[string]interface{}{
"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)
}
})
}
}

View File

@@ -0,0 +1,63 @@
// 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 chainqueues
import (
"context"
"encoding/json"
"fmt"
"strings"
)
type jsonOutput struct {
ActivationQueue int `json:"activation_queue"`
ExitQueue int `json:"exit_queue"`
}
func (c *command) output(ctx context.Context) (string, error) {
if c.quiet {
return "", nil
}
if c.json {
return c.outputJSON(ctx)
}
return c.outputText(ctx)
}
func (c *command) outputJSON(ctx context.Context) (string, error) {
output := &jsonOutput{
ActivationQueue: c.activationQueue,
ExitQueue: c.exitQueue,
}
data, err := json.Marshal(output)
if err != nil {
return "", err
}
return string(data), nil
}
func (c *command) outputText(ctx context.Context) (string, error) {
builder := strings.Builder{}
if c.activationQueue > 0 {
builder.WriteString(fmt.Sprintf("Activation queue: %d\n", c.activationQueue))
}
if c.exitQueue > 0 {
builder.WriteString(fmt.Sprintf("Exit queue: %d\n", c.exitQueue))
}
return strings.TrimSuffix(builder.String(), "\n"), nil
}

View File

@@ -0,0 +1,82 @@
// 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 chainqueues
import (
"context"
"fmt"
eth2client "github.com/attestantio/go-eth2-client"
"github.com/pkg/errors"
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
}
epoch, err := util.ParseEpoch(ctx, c.chainTime, c.epoch)
if err != nil {
return err
}
validators, err := c.validatorsProvider.Validators(ctx, fmt.Sprintf("%d", c.chainTime.FirstSlotOfEpoch(epoch)), nil)
if err != nil {
return errors.Wrap(err, "failed to obtain validators")
}
for _, validator := range validators {
if validator.Validator == nil {
continue
}
if validator.Validator.ActivationEligibilityEpoch <= epoch && validator.Validator.ActivationEpoch > epoch {
c.activationQueue++
}
if validator.Validator.ExitEpoch != 0xffffffffffffffff && validator.Validator.ExitEpoch > epoch {
c.exitQueue++
}
}
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")
}
c.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")
}
var isProvider bool
c.validatorsProvider, isProvider = c.eth2Client.(eth2client.ValidatorsProvider)
if !isProvider {
return errors.New("connection does not provide validator information")
}
return nil
}

View File

@@ -0,0 +1,66 @@
// 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 chainqueues
import (
"context"
"os"
"testing"
"github.com/rs/zerolog"
"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")
}
zerolog.SetGlobalLevel(zerolog.Disabled)
tests := []struct {
name string
vars map[string]interface{}
err string
}{
{
name: "InvalidEpoch",
vars: map[string]interface{}{
"timeout": "5s",
"epoch": "invalid",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
},
err: "failed to parse epoch: strconv.ParseInt: parsing \"invalid\": invalid syntax",
},
}
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/chain/queues/run.go Normal file
View File

@@ -0,0 +1,50 @@
// 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 chainqueues
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
}

View File

@@ -71,9 +71,6 @@ func input(ctx context.Context) (*dataIn, error) {
return nil, errors.New("one of timestamp, slot or epoch required")
}
if viper.GetString("connection") == "" {
return nil, errors.New("connection is required")
}
data.connection = viper.GetString("connection")
data.allowInsecureConnections = viper.GetBool("allow-insecure-connections")

View File

@@ -60,14 +60,6 @@ func TestInput(t *testing.T) {
vars: map[string]interface{}{},
err: "timeout is required",
},
{
name: "ConnectionMissing",
vars: map[string]interface{}{
"timeout": "5s",
"slot": "1",
},
err: "connection is required",
},
{
name: "IDMissing",
vars: map[string]interface{}{

View File

@@ -76,9 +76,6 @@ func newCommand(ctx context.Context) (*command, error) {
}
c.data = viper.GetString("data")
if viper.GetString("connection") == "" {
return nil, errors.New("connection is required")
}
c.connection = viper.GetString("connection")
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")

View File

@@ -44,14 +44,6 @@ func TestInput(t *testing.T) {
},
err: "data is required",
},
{
name: "ConnectionMissing",
vars: map[string]interface{}{
"timeout": "5s",
"data": "{}",
},
err: "connection is required",
},
{
name: "Good",
vars: map[string]interface{}{

67
cmd/chaineth1votes.go Normal file
View File

@@ -0,0 +1,67 @@
// 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"
"github.com/spf13/cobra"
"github.com/spf13/viper"
chaineth1votes "github.com/wealdtech/ethdo/cmd/chain/eth1votes"
)
var chainEth1VotesCmd = &cobra.Command{
Use: "eth1votes",
Short: "Show chain execution votes",
Long: `Show beacon chain execution votes. For example:
ethdo chain eth1votes
Note that this will fetch the votes made in blocks up to the end of the provided epoch.
In quiet mode this will return 0 if there is a majority for the votes, otherwise 1.`,
RunE: func(cmd *cobra.Command, args []string) error {
res, err := chaineth1votes.Run(cmd)
if err != nil {
return err
}
if viper.GetBool("quiet") {
return nil
}
if res != "" {
fmt.Println(res)
}
return nil
},
}
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")
}
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)
}
}

View File

@@ -1,4 +1,4 @@
// Copyright © 2020 Weald Technology Trading
// Copyright © 2020, 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
@@ -53,6 +53,11 @@ In quiet mode this will return 0 if the chain information can be obtained, other
os.Exit(_exitSuccess)
}
if viper.GetBool("prepare-offline") {
fmt.Printf("Add the following to your command to run it offline:\n --offline --genesis-validators=root=%#x --fork-version=%#x\n", genesis.GenesisValidatorsRoot, fork.CurrentVersion)
os.Exit(_exitSuccess)
}
if genesis.GenesisTime.Unix() == 0 {
fmt.Println("Genesis time: undefined")
} else {
@@ -84,4 +89,11 @@ In quiet mode this will return 0 if the chain information can be obtained, other
func init() {
chainCmd.AddCommand(chainInfoCmd)
chainFlags(chainInfoCmd)
chainInfoCmd.Flags().Bool("prepare-offline", false, "Provide information useful for offline commands")
}
func chainInfoBindings() {
if err := viper.BindPFlag("prepare-offline", chainInfoCmd.Flags().Lookup("prepare-offline")); err != nil {
panic(err)
}
}

61
cmd/chainqueues.go Normal file
View 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"
"github.com/spf13/cobra"
"github.com/spf13/viper"
chainqueues "github.com/wealdtech/ethdo/cmd/chain/queues"
)
var chainQueuesCmd = &cobra.Command{
Use: "queues",
Short: "Show chain queues",
Long: `Show beacon chain activation and exit queues. For example:
ethdo chain queues
In quiet mode this will return 0 if the entry and exit queues are 0, otherwise 1.`,
RunE: func(cmd *cobra.Command, args []string) error {
res, err := chainqueues.Run(cmd)
if err != nil {
return err
}
if viper.GetBool("quiet") {
return nil
}
if res != "" {
fmt.Println(res)
}
return nil
},
}
func init() {
chainCmd.AddCommand(chainQueuesCmd)
chainFlags(chainQueuesCmd)
chainQueuesCmd.Flags().String("epoch", "", "epoch for which to fetch the queues")
chainQueuesCmd.Flags().Bool("json", false, "output data in JSON format")
}
func chainQueuesBindings() {
if err := viper.BindPFlag("epoch", chainQueuesCmd.Flags().Lookup("epoch")); err != nil {
panic(err)
}
if err := viper.BindPFlag("json", chainQueuesCmd.Flags().Lookup("json")); err != nil {
panic(err)
}
}

View File

@@ -21,7 +21,7 @@ import (
"os"
"strings"
spec "github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/wealdtech/ethdo/util"
@@ -219,15 +219,15 @@ func verifyDeposit(deposit *util.DepositInfo, withdrawalCredentials []byte, vali
outputIf(!quiet, "Validator public key verified")
}
var pubKey spec.BLSPubKey
var pubKey phase0.BLSPubKey
copy(pubKey[:], deposit.PublicKey)
var signature spec.BLSSignature
var signature phase0.BLSSignature
copy(signature[:], deposit.Signature)
depositData := &spec.DepositData{
depositData := &phase0.DepositData{
PublicKey: pubKey,
WithdrawalCredentials: deposit.WithdrawalCredentials,
Amount: spec.Gwei(deposit.Amount),
Amount: phase0.Gwei(deposit.Amount),
Signature: signature,
}
depositDataRoot, err := depositData.HashTreeRoot()
@@ -248,7 +248,7 @@ func verifyDeposit(deposit *util.DepositInfo, withdrawalCredentials []byte, vali
}
} else {
if depositVerifyForkVersion == "" {
outputIf(!quiet, "fork version not supplied; NOT checked")
outputIf(!quiet, "fork version not supplied; not checked")
} else {
forkVersion, err := hex.DecodeString(strings.TrimPrefix(depositVerifyForkVersion, "0x"))
if err != nil {
@@ -260,6 +260,56 @@ func verifyDeposit(deposit *util.DepositInfo, withdrawalCredentials []byte, vali
outputIf(!quiet, "Fork version incorrect")
return false, nil
}
if len(deposit.DepositMessageRoot) != 32 {
outputIf(!quiet, "Deposit message root not supplied; not checked")
} else {
// We can also verify the deposit message signature.
depositMessage := &phase0.DepositMessage{
PublicKey: pubKey,
WithdrawalCredentials: withdrawalCredentials,
Amount: phase0.Gwei(deposit.Amount),
}
depositMessageRoot, err := depositMessage.HashTreeRoot()
if err != nil {
return false, errors.Wrap(err, "failed to generate deposit message root")
}
if bytes.Equal(deposit.DepositMessageRoot, depositMessageRoot[:]) {
outputIf(!quiet, "Deposit message root verified")
} else {
outputIf(!quiet, "Deposit message root incorrect")
return false, nil
}
domainBytes := e2types.Domain(e2types.DomainDeposit, forkVersion, e2types.ZeroGenesisValidatorsRoot)
var domain phase0.Domain
copy(domain[:], domainBytes)
container := &phase0.SigningData{
ObjectRoot: depositMessageRoot,
Domain: domain,
}
containerRoot, err := container.HashTreeRoot()
if err != nil {
return false, errors.New("failed to generate root for container")
}
validatorPubKey, err := e2types.BLSPublicKeyFromBytes(pubKey[:])
if err != nil {
return false, errors.Wrap(err, "failed to generate validator public key")
}
blsSig, err := e2types.BLSSignatureFromBytes(signature[:])
if err != nil {
return false, errors.New("failed to verify BLS signature")
}
signatureVerified := blsSig.Verify(containerRoot[:], validatorPubKey)
if signatureVerified {
outputIf(!quiet, "Deposit message signature verified")
} else {
outputIf(!quiet, "Deposit message signature NOT verified")
return false, nil
}
}
}
}

View File

@@ -40,22 +40,33 @@ type command struct {
jsonOutput bool
// Data access.
eth2Client eth2client.Service
chainTime chaintime.Service
proposerDutiesProvider eth2client.ProposerDutiesProvider
blocksProvider eth2client.SignedBeaconBlockProvider
syncCommitteesProvider eth2client.SyncCommitteesProvider
eth2Client eth2client.Service
chainTime chaintime.Service
proposerDutiesProvider eth2client.ProposerDutiesProvider
blocksProvider eth2client.SignedBeaconBlockProvider
syncCommitteesProvider eth2client.SyncCommitteesProvider
validatorsProvider eth2client.ValidatorsProvider
beaconCommitteesProvider eth2client.BeaconCommitteesProvider
beaconBlockHeadersProvider eth2client.BeaconBlockHeadersProvider
// Results.
summary *epochSummary
}
type epochSummary struct {
Epoch phase0.Epoch `json:"epoch"`
FirstSlot phase0.Slot `json:"first_slot"`
LastSlot phase0.Slot `json:"last_slot"`
Proposals []*epochProposal `json:"proposals"`
SyncCommittee []*epochSyncCommittee `json:"sync_committees"`
Epoch phase0.Epoch `json:"epoch"`
FirstSlot phase0.Slot `json:"first_slot"`
LastSlot phase0.Slot `json:"last_slot"`
Proposals []*epochProposal `json:"proposals"`
SyncCommittee []*epochSyncCommittee `json:"sync_committees"`
ActiveValidators int `json:"active_validators"`
ParticipatingValidators int `json:"participating_validators"`
HeadCorrectValidators int `json:"head_correct_validators"`
HeadTimelyValidators int `json:"head_timely_validators"`
SourceTimelyValidators int `json:"source_timely_validators"`
TargetCorrectValidators int `json:"target_correct_validators"`
TargetTimelyValidators int `json:"target_timely_validators"`
NonParticipatingValidators []*nonParticipatingValidator `json:"nonparticipating_validators"`
}
type epochProposal struct {
@@ -69,6 +80,12 @@ type epochSyncCommittee struct {
Missed int `json:"missed"`
}
type nonParticipatingValidator struct {
Validator phase0.ValidatorIndex `json:"validator_index"`
Slot phase0.Slot `json:"slot"`
Committee phase0.CommitteeIndex `json:"committee_index"`
}
func newCommand(ctx context.Context) (*command, error) {
c := &command{
quiet: viper.GetBool("quiet"),
@@ -83,9 +100,6 @@ func newCommand(ctx context.Context) (*command, error) {
}
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")

View File

@@ -37,13 +37,6 @@ func TestInput(t *testing.T) {
vars: map[string]interface{}{},
err: "timeout is required",
},
{
name: "ConnectionMissing",
vars: map[string]interface{}{
"timeout": "5s",
},
err: "connection is required",
},
{
name: "Good",
vars: map[string]interface{}{

View File

@@ -47,58 +47,61 @@ func (c *command) outputTxt(_ context.Context) (string, error) {
builder.WriteString(fmt.Sprintf("%d:\n", c.summary.Epoch))
proposedBlocks := 0
missedProposals := make([]string, 0, len(c.summary.Proposals))
for _, proposal := range c.summary.Proposals {
if !proposal.Block {
missedProposals = append(missedProposals, fmt.Sprintf("\n Slot %d (validator %d)", proposal.Slot, proposal.Proposer))
} else {
proposedBlocks++
}
}
builder.WriteString(fmt.Sprintf(" Proposals: %d/%d (%0.2f%%)", proposedBlocks, len(missedProposals)+proposedBlocks, 100.0*float64(proposedBlocks)/float64(len(missedProposals)+proposedBlocks)))
if c.verbose {
for _, proposal := range c.summary.Proposals {
builder.WriteString(" Slot ")
builder.WriteString(fmt.Sprintf("%d (%d/%d):\n", proposal.Slot, uint64(proposal.Slot)%uint64(len(c.summary.Proposals)), len(c.summary.Proposals)))
builder.WriteString(" Proposer: ")
builder.WriteString(fmt.Sprintf("%d\n", proposal.Proposer))
builder.WriteString(" Proposed: ")
if proposal.Block {
proposedBlocks++
builder.WriteString("✓\n")
} else {
builder.WriteString("✕\n")
}
}
} else {
missedProposals := make([]string, 0, len(c.summary.Proposals))
for _, proposal := range c.summary.Proposals {
if !proposal.Block {
missedProposals = append(missedProposals, fmt.Sprintf(" Slot %d (validator %d)\n", proposal.Slot, proposal.Proposer))
} else {
proposedBlocks++
}
}
if len(missedProposals) > 0 {
builder.WriteString(" Missed proposals:\n")
for _, missedProposal := range missedProposals {
builder.WriteString(missedProposal)
continue
}
builder.WriteString("\n Slot ")
builder.WriteString(fmt.Sprintf("%d (%d/%d)", proposal.Slot, uint64(proposal.Slot)%uint64(len(c.summary.Proposals)), len(c.summary.Proposals)))
builder.WriteString(" validator ")
builder.WriteString(fmt.Sprintf("%d", proposal.Proposer))
builder.WriteString(" not proposed or not included")
}
}
builder.WriteString(fmt.Sprintf("\n Attestations: %d/%d (%0.2f%%)", c.summary.ParticipatingValidators, c.summary.ActiveValidators, 100.0*float64(c.summary.ParticipatingValidators)/float64(c.summary.ActiveValidators)))
builder.WriteString(fmt.Sprintf("\n Source timely: %d/%d (%0.2f%%)", c.summary.SourceTimelyValidators, c.summary.ActiveValidators, 100.0*float64(c.summary.SourceTimelyValidators)/float64(c.summary.ActiveValidators)))
builder.WriteString(fmt.Sprintf("\n Target correct: %d/%d (%0.2f%%)", c.summary.TargetCorrectValidators, c.summary.ActiveValidators, 100.0*float64(c.summary.TargetCorrectValidators)/float64(c.summary.ActiveValidators)))
builder.WriteString(fmt.Sprintf("\n Target timely: %d/%d (%0.2f%%)", c.summary.TargetTimelyValidators, c.summary.ActiveValidators, 100.0*float64(c.summary.TargetTimelyValidators)/float64(c.summary.ActiveValidators)))
builder.WriteString(fmt.Sprintf("\n Head correct: %d/%d (%0.2f%%)", c.summary.HeadCorrectValidators, c.summary.ActiveValidators, 100.0*float64(c.summary.HeadCorrectValidators)/float64(c.summary.ActiveValidators)))
builder.WriteString(fmt.Sprintf("\n Head timely: %d/%d (%0.2f%%)", c.summary.HeadTimelyValidators, c.summary.ActiveValidators, 100.0*float64(c.summary.HeadTimelyValidators)/float64(c.summary.ActiveValidators)))
if c.verbose {
for _, syncCommittee := range c.summary.SyncCommittee {
builder.WriteString(" Sync committee validator ")
builder.WriteString(fmt.Sprintf("%d:\n", syncCommittee.Index))
builder.WriteString(" Chances: ")
builder.WriteString(fmt.Sprintf("%d\n", proposedBlocks))
builder.WriteString(" Included: ")
builder.WriteString(fmt.Sprintf("%d\n", proposedBlocks-syncCommittee.Missed))
builder.WriteString(" Inclusion %: ")
builder.WriteString(fmt.Sprintf("%0.2f\n", 100.0*float64(proposedBlocks-syncCommittee.Missed)/float64(proposedBlocks)))
// Sort list by validator index.
for _, validator := range c.summary.NonParticipatingValidators {
builder.WriteString("\n Slot ")
builder.WriteString(fmt.Sprintf("%d", validator.Slot))
builder.WriteString(" committee ")
builder.WriteString(fmt.Sprintf("%d", validator.Committee))
builder.WriteString(" validator ")
builder.WriteString(fmt.Sprintf("%d", validator.Validator))
builder.WriteString(" failed to participate")
}
} else {
missedSyncCommittees := make([]string, 0, len(c.summary.SyncCommittee))
for _, syncCommittee := range c.summary.SyncCommittee {
missedPct := 100.0 * float64(syncCommittee.Missed) / float64(proposedBlocks)
missedSyncCommittees = append(missedSyncCommittees, fmt.Sprintf(" %d (%0.2f%%) by validator %d\n", syncCommittee.Missed, missedPct, syncCommittee.Index))
}
if c.summary.Epoch >= c.chainTime.AltairInitialEpoch() {
contributions := proposedBlocks * 512 // SYNC_COMMITTEE_SIZE
totalMissed := 0
for _, contribution := range c.summary.SyncCommittee {
totalMissed += contribution.Missed
}
if len(missedSyncCommittees) > 0 {
builder.WriteString(" Missed sync committees (excluding missed blocks):\n")
for _, missedSyncCommittee := range missedSyncCommittees {
builder.WriteString(missedSyncCommittee)
builder.WriteString(fmt.Sprintf("\n Sync committees: %d/%d (%0.2f%%)", contributions-totalMissed, contributions, 100.0*float64(contributions-totalMissed)/float64(contributions)))
if c.verbose {
for _, syncCommittee := range c.summary.SyncCommittee {
builder.WriteString("\n Validator ")
builder.WriteString(fmt.Sprintf("%d", syncCommittee.Index))
builder.WriteString(" included ")
builder.WriteString(fmt.Sprintf("%d/%d", proposedBlocks-syncCommittee.Missed, proposedBlocks))
builder.WriteString(fmt.Sprintf(" (%0.2f%%)", 100.0*float64(proposedBlocks-syncCommittee.Missed)/float64(proposedBlocks)))
}
}
}

View File

@@ -16,8 +16,10 @@ package epochsummary
import (
"context"
"fmt"
"sort"
eth2client "github.com/attestantio/go-eth2-client"
apiv1 "github.com/attestantio/go-eth2-client/api/v1"
"github.com/attestantio/go-eth2-client/spec"
"github.com/attestantio/go-eth2-client/spec/altair"
"github.com/attestantio/go-eth2-client/spec/phase0"
@@ -77,19 +79,191 @@ func (c *command) processProposerDuties(ctx context.Context) error {
return nil
}
func (c *command) activeValidators(ctx context.Context) (map[phase0.ValidatorIndex]*apiv1.Validator, error) {
validators, err := c.validatorsProvider.Validators(ctx, fmt.Sprintf("%d", c.chainTime.FirstSlotOfEpoch(c.summary.Epoch)), nil)
if err != nil {
return nil, errors.Wrap(err, "failed to obtain validators for epoch")
}
activeValidators := make(map[phase0.ValidatorIndex]*apiv1.Validator)
for _, validator := range validators {
if validator.Validator.ActivationEpoch <= c.summary.Epoch && validator.Validator.ExitEpoch > c.summary.Epoch {
activeValidators[validator.Index] = validator
}
}
return activeValidators, nil
}
func (c *command) processAttesterDuties(ctx context.Context) error {
// Obtain all active validators for the given epoch.
// Do in future.
activeValidators, err := c.activeValidators(ctx)
if err != nil {
return err
}
c.summary.ActiveValidators = len(activeValidators)
// Obtain number of validators that voted for blocks in the epoch.
// These votes can be included anywhere from the second slot of
// the epoch to the first slot of the next-but-one epoch.
firstSlot := c.chainTime.FirstSlotOfEpoch(c.summary.Epoch) + 1
lastSlot := c.chainTime.FirstSlotOfEpoch(c.summary.Epoch + 2)
if lastSlot > c.chainTime.CurrentSlot() {
lastSlot = c.chainTime.CurrentSlot()
}
var votes map[phase0.ValidatorIndex]struct{}
var participations map[phase0.ValidatorIndex]*nonParticipatingValidator
c.summary.ParticipatingValidators, c.summary.HeadCorrectValidators, c.summary.HeadTimelyValidators, c.summary.SourceTimelyValidators, c.summary.TargetCorrectValidators, c.summary.TargetTimelyValidators, votes, participations, err = c.processSlots(ctx, firstSlot, lastSlot)
if err != nil {
return err
}
c.summary.NonParticipatingValidators = make([]*nonParticipatingValidator, 0, len(activeValidators)-len(votes))
for activeValidatorIndex := range activeValidators {
if _, exists := votes[activeValidatorIndex]; !exists {
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 {
if c.summary.NonParticipatingValidators[i].Slot != c.summary.NonParticipatingValidators[j].Slot {
return c.summary.NonParticipatingValidators[i].Slot < c.summary.NonParticipatingValidators[j].Slot
}
if c.summary.NonParticipatingValidators[i].Committee != c.summary.NonParticipatingValidators[j].Committee {
return c.summary.NonParticipatingValidators[i].Committee < c.summary.NonParticipatingValidators[j].Committee
}
return c.summary.NonParticipatingValidators[i].Validator < c.summary.NonParticipatingValidators[j].Validator
})
return nil
}
func (c *command) processSlots(ctx context.Context,
firstSlot phase0.Slot,
lastSlot phase0.Slot,
) (
int,
int,
int,
int,
int,
int,
map[phase0.ValidatorIndex]struct{},
map[phase0.ValidatorIndex]*nonParticipatingValidator,
error,
) {
votes := make(map[phase0.ValidatorIndex]struct{})
headCorrects := make(map[phase0.ValidatorIndex]struct{})
headTimelys := make(map[phase0.ValidatorIndex]struct{})
sourceTimelys := make(map[phase0.ValidatorIndex]struct{})
targetCorrects := make(map[phase0.ValidatorIndex]struct{})
targetTimelys := make(map[phase0.ValidatorIndex]struct{})
allCommittees := make(map[phase0.Slot]map[phase0.CommitteeIndex][]phase0.ValidatorIndex)
participations := make(map[phase0.ValidatorIndex]*nonParticipatingValidator)
// Need a cache of beacon block headers to reduce lookup times.
headersCache := util.NewBeaconBlockHeaderCache(c.beaconBlockHeadersProvider)
for slot := firstSlot; slot <= lastSlot; slot++ {
block, err := c.blocksProvider.SignedBeaconBlock(ctx, fmt.Sprintf("%d", slot))
if err != nil {
return 0, 0, 0, 0, 0, 0, nil, nil, errors.Wrap(err, fmt.Sprintf("failed to obtain block for slot %d", slot))
}
if block == nil {
// No block at this slot; that's fine.
continue
}
slot, err := block.Slot()
if err != nil {
return 0, 0, 0, 0, 0, 0, nil, nil, err
}
attestations, err := block.Attestations()
if err != nil {
return 0, 0, 0, 0, 0, 0, nil, nil, err
}
for _, attestation := range attestations {
if attestation.Data.Slot < c.chainTime.FirstSlotOfEpoch(c.summary.Epoch) || attestation.Data.Slot >= c.chainTime.FirstSlotOfEpoch(c.summary.Epoch+1) {
// Outside of this epoch's range.
continue
}
slotCommittees, exists := allCommittees[attestation.Data.Slot]
if !exists {
beaconCommittees, err := c.beaconCommitteesProvider.BeaconCommittees(ctx, fmt.Sprintf("%d", attestation.Data.Slot))
if err != nil {
return 0, 0, 0, 0, 0, 0, nil, nil, errors.Wrap(err, fmt.Sprintf("failed to obtain committees for slot %d", attestation.Data.Slot))
}
for _, beaconCommittee := range beaconCommittees {
if _, exists := allCommittees[beaconCommittee.Slot]; !exists {
allCommittees[beaconCommittee.Slot] = make(map[phase0.CommitteeIndex][]phase0.ValidatorIndex)
}
allCommittees[beaconCommittee.Slot][beaconCommittee.Index] = beaconCommittee.Validators
for _, index := range beaconCommittee.Validators {
participations[index] = &nonParticipatingValidator{
Validator: index,
Slot: beaconCommittee.Slot,
Committee: beaconCommittee.Index,
}
}
}
slotCommittees = allCommittees[attestation.Data.Slot]
}
committee := slotCommittees[attestation.Data.Index]
inclusionDistance := slot - attestation.Data.Slot
headCorrect, err := util.AttestationHeadCorrect(ctx, headersCache, attestation)
if err != nil {
return 0, 0, 0, 0, 0, 0, nil, nil, err
}
targetCorrect, err := util.AttestationTargetCorrect(ctx, headersCache, c.chainTime, attestation)
if err != nil {
return 0, 0, 0, 0, 0, 0, nil, nil, err
}
for i := uint64(0); i < attestation.AggregationBits.Len(); i++ {
if attestation.AggregationBits.BitAt(i) {
votes[committee[int(i)]] = struct{}{}
if _, exists := headCorrects[committee[int(i)]]; !exists && headCorrect {
headCorrects[committee[int(i)]] = struct{}{}
}
if _, exists := headTimelys[committee[int(i)]]; !exists && headCorrect && inclusionDistance == 1 {
headTimelys[committee[int(i)]] = struct{}{}
}
if _, exists := sourceTimelys[committee[int(i)]]; !exists && inclusionDistance <= 5 {
sourceTimelys[committee[int(i)]] = struct{}{}
}
if _, exists := targetCorrects[committee[int(i)]]; !exists && targetCorrect {
targetCorrects[committee[int(i)]] = struct{}{}
}
if _, exists := targetTimelys[committee[int(i)]]; !exists && targetCorrect && inclusionDistance <= 32 {
targetTimelys[committee[int(i)]] = struct{}{}
}
}
}
}
}
return len(votes),
len(headCorrects),
len(headTimelys),
len(sourceTimelys),
len(targetCorrects),
len(targetTimelys),
votes,
participations,
nil
}
func (c *command) processSyncCommitteeDuties(ctx context.Context) error {
if c.summary.Epoch < c.chainTime.AltairInitialEpoch() {
// The epoch is pre-Altair. No info but no error.
return nil
}
committee, err := c.syncCommitteesProvider.SyncCommittee(ctx, fmt.Sprintf("%d", c.summary.FirstSlot))
if err != nil {
return errors.Wrap(err, "failed to obtain sync committee")
}
if len(committee.Validators) == 0 {
return errors.New("empty sync committee")
return errors.Wrap(err, "empty sync committee")
}
missed := make(map[phase0.ValidatorIndex]int)
@@ -135,6 +309,16 @@ func (c *command) processSyncCommitteeDuties(ctx context.Context) error {
}
}
sort.Slice(c.summary.SyncCommittee, func(i int, j int) bool {
missedDiff := c.summary.SyncCommittee[i].Missed - c.summary.SyncCommittee[j].Missed
if missedDiff != 0 {
// Actually want to order by missed descending, so invert the expected condition.
return missedDiff > 0
}
// Then order by validator index.
return c.summary.SyncCommittee[i].Index < c.summary.SyncCommittee[j].Index
})
return nil
}
@@ -169,6 +353,18 @@ func (c *command) setup(ctx context.Context) error {
if !isProvider {
return errors.New("connection does not provide sync committee duties")
}
c.validatorsProvider, isProvider = c.eth2Client.(eth2client.ValidatorsProvider)
if !isProvider {
return errors.New("connection does not provide validators")
}
c.beaconCommitteesProvider, isProvider = c.eth2Client.(eth2client.BeaconCommitteesProvider)
if !isProvider {
return errors.New("connection does not provide beacon committees")
}
c.beaconBlockHeadersProvider, isProvider = c.eth2Client.(eth2client.BeaconBlockHeadersProvider)
if !isProvider {
return errors.New("connection does not provide beacon block headers")
}
return nil
}

View File

@@ -66,7 +66,7 @@ func TestInput(t *testing.T) {
vars: map[string]interface{}{
"timeout": "5s",
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to any beacon node",
},
{
name: "ConnectionBad",
@@ -75,7 +75,7 @@ func TestInput(t *testing.T) {
"connection": "localhost:1",
"topics": []string{"one", "two"},
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to beacon node: failed to confirm node connection: failed to fetch genesis: failed to request genesis: failed to call GET endpoint: Get \"http://localhost:1/eth/v1/beacon/genesis\": dial tcp 127.0.0.1:1: connect: connection refused",
},
{
name: "TopicsNil",

View File

@@ -27,3 +27,6 @@ var proposerCmd = &cobra.Command{
func init() {
RootCmd.AddCommand(proposerCmd)
}
func proposerFlags(cmd *cobra.Command) {
}

View File

@@ -0,0 +1,77 @@
// 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 proposerduties
import (
"context"
"time"
eth2client "github.com/attestantio/go-eth2-client"
apiv1 "github.com/attestantio/go-eth2-client/api/v1"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/wealdtech/ethdo/services/chaintime"
)
type command struct {
quiet bool
verbose bool
debug bool
// Beacon node connection.
timeout time.Duration
connection string
allowInsecureConnections bool
// Operation.
epoch string
jsonOutput bool
// Data access.
eth2Client eth2client.Service
chainTime chaintime.Service
proposerDutiesProvider eth2client.ProposerDutiesProvider
// Results.
results *results
}
type results struct {
Epoch phase0.Epoch `json:"epoch"`
Duties []*apiv1.ProposerDuty `json:"duties"`
}
func newCommand(ctx context.Context) (*command, error) {
c := &command{
quiet: viper.GetBool("quiet"),
verbose: viper.GetBool("verbose"),
debug: viper.GetBool("debug"),
results: &results{},
}
// Timeout.
if viper.GetDuration("timeout") == 0 {
return nil, errors.New("timeout is required")
}
c.timeout = viper.GetDuration("timeout")
c.connection = viper.GetString("connection")
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")
c.epoch = viper.GetString("epoch")
c.jsonOutput = viper.GetBool("json")
return c, nil
}

View File

@@ -0,0 +1,72 @@
// 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 proposerduties
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: "Good",
vars: map[string]interface{}{
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
},
},
{
name: "GoodWithEpoch",
vars: map[string]interface{}{
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
"epoch": "-1",
},
},
}
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)
}
})
}
}

View File

@@ -0,0 +1,62 @@
// 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 proposerduties
import (
"context"
"encoding/json"
"fmt"
"strings"
)
func (c *command) output(ctx context.Context) (string, error) {
if c.quiet {
return "", nil
}
if c.jsonOutput {
return c.outputJSON(ctx)
}
return c.outputTxt(ctx)
}
func (c *command) outputJSON(_ context.Context) (string, error) {
data, err := json.Marshal(c.results)
if err != nil {
return "", err
}
return string(data), nil
}
func (c *command) outputTxt(_ context.Context) (string, error) {
builder := strings.Builder{}
builder.WriteString("Epoch ")
builder.WriteString(fmt.Sprintf("%d:\n", c.results.Epoch))
for _, duty := range c.results.Duties {
builder.WriteString(" Slot ")
builder.WriteString(fmt.Sprintf("%d: ", duty.Slot))
builder.WriteString("validator ")
builder.WriteString(fmt.Sprintf("%d", duty.ValidatorIndex))
if c.verbose {
builder.WriteString(" (pubkey ")
builder.WriteString(fmt.Sprintf("%#x)", duty.PubKey))
}
builder.WriteString("\n")
}
return strings.TrimSuffix(builder.String(), "\n"), nil
}

View File

@@ -0,0 +1,70 @@
// 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 proposerduties
import (
"context"
eth2client "github.com/attestantio/go-eth2-client"
"github.com/pkg/errors"
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.
err := c.setup(ctx)
if err != nil {
return err
}
c.results.Epoch, err = util.ParseEpoch(ctx, c.chainTime, c.epoch)
if err != nil {
return errors.Wrap(err, "failed to parse epoch")
}
c.results.Duties, err = c.proposerDutiesProvider.ProposerDuties(ctx, c.results.Epoch, nil)
if err != nil {
return errors.Wrap(err, "failed to obtain proposer duties")
}
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")
}
c.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")
}
var isProvider bool
c.proposerDutiesProvider, isProvider = c.eth2Client.(eth2client.ProposerDutiesProvider)
if !isProvider {
return errors.New("connection does not provide proposer duties")
}
return nil
}

View File

@@ -0,0 +1,62 @@
// 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 proposerduties
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",
"data": "[[",
"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)
}
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)
}
})
}
}

View File

@@ -0,0 +1,50 @@
// 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 proposerduties
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
}

61
cmd/proposerduties.go Normal file
View 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"
"github.com/spf13/cobra"
"github.com/spf13/viper"
proposerduties "github.com/wealdtech/ethdo/cmd/proposer/duties"
)
var proposerDutiesCmd = &cobra.Command{
Use: "duties",
Short: "Obtain information about duties of an proposer",
Long: `Obtain information about dutes of an proposer. For example:
ethdo proposer duties --epoch=12345
In quiet mode this will return 0 if duties can be obtained, otherwise 1.`,
RunE: func(cmd *cobra.Command, args []string) error {
res, err := proposerduties.Run(cmd)
if err != nil {
return err
}
if viper.GetBool("quiet") {
return nil
}
if res != "" {
fmt.Println(res)
}
return nil
},
}
func init() {
proposerCmd.AddCommand(proposerDutiesCmd)
proposerFlags(proposerDutiesCmd)
proposerDutiesCmd.Flags().String("epoch", "", "the epoch for which to fetch duties")
proposerDutiesCmd.Flags().Bool("json", false, "output data in JSON format")
}
func proposerDutiesBindings() {
if err := viper.BindPFlag("epoch", proposerDutiesCmd.Flags().Lookup("epoch")); err != nil {
panic(err)
}
if err := viper.BindPFlag("json", proposerDutiesCmd.Flags().Lookup("json")); err != nil {
panic(err)
}
}

View File

@@ -77,6 +77,7 @@ func persistentPreRunE(cmd *cobra.Command, args []string) error {
return util.SetupStore()
}
// nolint:gocyclo
func includeCommandBindings(cmd *cobra.Command) {
switch commandPath(cmd) {
case "account/create":
@@ -93,6 +94,12 @@ func includeCommandBindings(cmd *cobra.Command) {
blockAnalyzeBindings()
case "block/info":
blockInfoBindings()
case "chain/eth1votes":
chainEth1VotesBindings()
case "chain/info":
chainInfoBindings()
case "chain/queues":
chainQueuesBindings()
case "chain/time":
chainTimeBindings()
case "chain/verify/signedcontributionandproof":
@@ -103,12 +110,18 @@ func includeCommandBindings(cmd *cobra.Command) {
exitVerifyBindings()
case "node/events":
nodeEventsBindings()
case "proposer/duties":
proposerDutiesBindings()
case "slot/time":
slotTimeBindings()
case "synccommittee/inclusion":
synccommitteeInclusionBindings()
case "synccommittee/members":
synccommitteeMembersBindings()
case "validator/credentials/get":
validatorCredentialsGetBindings()
case "validator/credentials/set":
validatorCredentialsSetBindings()
case "validator/depositdata":
validatorDepositdataBindings()
case "validator/duties":
@@ -119,6 +132,10 @@ func includeCommandBindings(cmd *cobra.Command) {
validatorInfoBindings()
case "validator/keycheck":
validatorKeycheckBindings()
case "validator/summary":
validatorSummaryBindings()
case "validator/yield":
validatorYieldBindings()
case "validator/expectation":
validatorExpectationBindings()
case "wallet/create":
@@ -158,10 +175,26 @@ func init() {
if err := viper.BindPFlag("store", RootCmd.PersistentFlags().Lookup("store")); err != nil {
panic(err)
}
RootCmd.PersistentFlags().String("account", "", "Account name (in format \"wallet/account\")")
RootCmd.PersistentFlags().String("account", "", `Account name (in format "<wallet>/<account>")`)
if err := viper.BindPFlag("account", RootCmd.PersistentFlags().Lookup("account")); err != nil {
panic(err)
}
RootCmd.PersistentFlags().String("mnemonic", "", "Mnemonic to provide access to an account")
if err := viper.BindPFlag("mnemonic", RootCmd.PersistentFlags().Lookup("mnemonic")); err != nil {
panic(err)
}
RootCmd.PersistentFlags().String("path", "", "Hierarchical derivation path used with mnemonic to provide access to an account")
if err := viper.BindPFlag("path", RootCmd.PersistentFlags().Lookup("path")); err != nil {
panic(err)
}
RootCmd.PersistentFlags().String("private-key", "", "Private key to provide access to an account")
if err := viper.BindPFlag("private-key", RootCmd.PersistentFlags().Lookup("private-key")); err != nil {
panic(err)
}
RootCmd.PersistentFlags().String("public-key", "", "public key to provide access to an account")
if err := viper.BindPFlag("public-key", RootCmd.PersistentFlags().Lookup("public-key")); err != nil {
panic(err)
}
RootCmd.PersistentFlags().String("basedir", "", "Base directory for filesystem wallets")
if err := viper.BindPFlag("basedir", RootCmd.PersistentFlags().Lookup("basedir")); err != nil {
panic(err)
@@ -211,7 +244,7 @@ func init() {
if err := viper.BindPFlag("debug", RootCmd.PersistentFlags().Lookup("debug")); err != nil {
panic(err)
}
RootCmd.PersistentFlags().String("connection", "http://localhost:3500", "URL to an Ethereum 2 node's RET API endpoint")
RootCmd.PersistentFlags().String("connection", "", "URL to an Ethereum 2 node's RET API endpoint")
if err := viper.BindPFlag("connection", RootCmd.PersistentFlags().Lookup("connection")); err != nil {
panic(err)
}
@@ -364,24 +397,6 @@ func walletAndAccountFromPath(ctx context.Context, path string) (e2wtypes.Wallet
return wallet, account, nil
}
// bestPublicKey returns the best public key for operations.
// It prefers the composite public key if present, otherwise the public key.
func bestPublicKey(account e2wtypes.Account) (e2types.PublicKey, error) {
var pubKey e2types.PublicKey
publicKeyProvider, isCompositePublicKeyProvider := account.(e2wtypes.AccountCompositePublicKeyProvider)
if isCompositePublicKeyProvider {
pubKey = publicKeyProvider.CompositePublicKey()
} else {
publicKeyProvider, isPublicKeyProvider := account.(e2wtypes.AccountPublicKeyProvider)
if isPublicKeyProvider {
pubKey = publicKeyProvider.PublicKey()
} else {
return nil, errors.New("account does not provide a public key")
}
}
return pubKey, nil
}
// remotesToEndpoints generates endpoints from remote addresses.
func remotesToEndpoints(remotes []string) ([]*dirk.Endpoint, error) {
endpoints := make([]*dirk.Endpoint, 0)

View File

@@ -73,7 +73,7 @@ func TestInput(t *testing.T) {
"timeout": "5s",
"slot": "1",
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to any beacon node",
},
}

View File

@@ -37,7 +37,7 @@ func TestOutput(t *testing.T) {
dataOut: &dataOut{
startTime: time.Unix(1606824023, 0),
},
res: "2020-12-01 12:00:23 +0000 GMT",
res: "2020-12-01 12:00:23 +0000 UTC",
},
{
name: "Verbose",
@@ -46,7 +46,7 @@ func TestOutput(t *testing.T) {
endTime: time.Unix(1606824035, 0),
verbose: true,
},
res: "2020-12-01 12:00:23 +0000 GMT - 2020-12-01 12:00:35 +0000 GMT",
res: "2020-12-01 12:00:23 +0000 UTC - 2020-12-01 12:00:35 +0000 UTC",
},
}

View File

@@ -62,9 +62,7 @@ func newCommand(ctx context.Context) (*command, error) {
}
c.timeout = viper.GetDuration("timeout")
if viper.GetString("connection") == "" {
return nil, errors.New("connection is required")
}
// Connection.
c.connection = viper.GetString("connection")
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")

View File

@@ -37,14 +37,6 @@ func TestInput(t *testing.T) {
vars: map[string]interface{}{},
err: "timeout is required",
},
{
name: "ConnectionMissing",
vars: map[string]interface{}{
"validators": "1",
"timeout": "5s",
},
err: "connection is required",
},
{
name: "NoValidator",
vars: map[string]interface{}{

View File

@@ -32,6 +32,14 @@ func TestProcess(t *testing.T) {
vars map[string]interface{}
err string
}{
{
name: "MissingConnection",
vars: map[string]interface{}{
"timeout": "5s",
"index": "1",
},
err: "failed to connect to any beacon node",
},
{
name: "InvalidConnection",
vars: map[string]interface{}{
@@ -39,7 +47,7 @@ func TestProcess(t *testing.T) {
"index": "1",
"connection": "invalid",
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to beacon node: failed to confirm node connection: failed to fetch genesis: failed to request genesis: failed to call GET endpoint: Get \"http://invalid/eth/v1/beacon/genesis\": dial tcp: lookup invalid: no such host",
},
{
name: "Good",

View File

@@ -65,7 +65,15 @@ func TestInput(t *testing.T) {
vars: map[string]interface{}{
"timeout": "5s",
},
err: "failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to any beacon node",
},
{
name: "ConnectionInvalid",
vars: map[string]interface{}{
"timeout": "5s",
"connection": "localhost:1",
},
err: "failed to connect to beacon node: failed to confirm node connection: failed to fetch genesis: failed to request genesis: failed to call GET endpoint: Get \"http://localhost:1/eth/v1/beacon/genesis\": dial tcp 127.0.0.1:1: connect: connection refused",
},
}

View File

@@ -30,3 +30,6 @@ func init() {
func validatorFlags(cmd *cobra.Command) {
}
func validatorBindings() {
}

View File

@@ -0,0 +1,69 @@
// 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 validatorcredentialsget
import (
"context"
"time"
eth2client "github.com/attestantio/go-eth2-client"
apiv1 "github.com/attestantio/go-eth2-client/api/v1"
"github.com/pkg/errors"
"github.com/spf13/viper"
)
type command struct {
quiet bool
verbose bool
debug bool
// Input.
validator string
// Beacon node connection.
timeout time.Duration
connection string
allowInsecureConnections bool
// Data access.
consensusClient eth2client.Service
validatorsProvider eth2client.ValidatorsProvider
// Output.
validatorInfo *apiv1.Validator
}
func newCommand(ctx context.Context) (*command, error) {
c := &command{
quiet: viper.GetBool("quiet"),
verbose: viper.GetBool("verbose"),
debug: viper.GetBool("debug"),
}
// Timeout.
if viper.GetDuration("timeout") == 0 {
return nil, errors.New("timeout is required")
}
c.timeout = viper.GetDuration("timeout")
c.connection = viper.GetString("connection")
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")
if viper.GetString("validator") == "" {
return nil, errors.New("validator is required")
}
c.validator = viper.GetString("validator")
return c, nil
}

View File

@@ -0,0 +1,83 @@
// 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 validatorcredentialsget
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: "NoValidatorInfo",
vars: map[string]interface{}{
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
},
err: "one of account, index or pubkey required",
},
{
name: "MultipleValidatorInfo",
vars: map[string]interface{}{
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
"index": "1",
"pubkey": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
},
err: "only one of account, index and pubkey allowed",
},
{
name: "Good",
vars: map[string]interface{}{
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
"index": "1",
},
},
}
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)
}
})
}
}

View File

@@ -0,0 +1,65 @@
// 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 validatorcredentialsget
import (
"context"
"fmt"
"strings"
ethutil "github.com/wealdtech/go-eth2-util"
)
func (c *command) output(ctx context.Context) (string, error) {
if c.quiet {
return "", nil
}
builder := strings.Builder{}
switch c.validatorInfo.Validator.WithdrawalCredentials[0] {
case 0:
builder.WriteString("BLS credentials: ")
builder.WriteString(fmt.Sprintf("%#x", c.validatorInfo.Validator.WithdrawalCredentials))
case 1:
builder.WriteString("Ethereum execution address: ")
builder.WriteString(addressBytesToEIP55(c.validatorInfo.Validator.WithdrawalCredentials[12:]))
if c.verbose {
builder.WriteString("\n")
builder.WriteString("Withdrawal credentials: ")
builder.WriteString(fmt.Sprintf("%#x", c.validatorInfo.Validator.WithdrawalCredentials))
}
}
return builder.String(), nil
}
// addressBytesToEIP55 converts a byte array in to an EIP-55 string format.
func addressBytesToEIP55(address []byte) string {
bytes := []byte(fmt.Sprintf("%x", address))
hash := ethutil.Keccak256(bytes)
for i := 0; i < len(bytes); i++ {
hashByte := hash[i/2]
if i%2 == 0 {
hashByte >>= 4
} else {
hashByte &= 0xf
}
if bytes[i] > '9' && hashByte > 7 {
bytes[i] -= 32
}
}
return fmt.Sprintf("0x%s", string(bytes))
}

View File

@@ -0,0 +1,74 @@
// 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 validatorcredentialsget
import (
"context"
"encoding/json"
"fmt"
eth2client "github.com/attestantio/go-eth2-client"
"github.com/pkg/errors"
"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
}
// Work out which validator we are dealing with.
if err := c.fetchValidator(ctx); err != nil {
return err
}
if c.debug {
data, err := json.Marshal(c.validator)
if err == nil {
fmt.Println(string(data))
}
}
return nil
}
func (c *command) setup(ctx context.Context) error {
var err error
// Connect to the consensus node.
c.consensusClient, err = util.ConnectToBeaconNode(ctx, c.connection, c.timeout, c.allowInsecureConnections)
if err != nil {
return errors.Wrap(err, "failed to connect to consensus node")
}
// Obtain the validators provider.
var isProvider bool
c.validatorsProvider, isProvider = c.consensusClient.(eth2client.ValidatorsProvider)
if !isProvider {
return errors.New("consensu node does not provide validator information")
}
return nil
}
func (c *command) fetchValidator(ctx context.Context) error {
var err error
c.validatorInfo, err = util.ParseValidator(ctx, c.validatorsProvider, c.validator, "head")
if err != nil {
return errors.Wrap(err, "failed to obtain validator information")
}
return nil
}

View File

@@ -0,0 +1,50 @@
// 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 validatorcredentialsget
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
}

View File

@@ -0,0 +1,127 @@
// 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 validatorcredentialsset
import (
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
)
type chainInfo struct {
Version uint64
Validators []*validatorInfo
GenesisValidatorsRoot phase0.Root
Epoch phase0.Epoch
ForkVersion phase0.Version
Domain phase0.Domain
}
type chainInfoJSON struct {
Version string `json:"version"`
Validators []*validatorInfo `json:"validators"`
GenesisValidatorsRoot string `json:"genesis_validators_root"`
Epoch string `json:"epoch"`
ForkVersion string `json:"fork_version"`
Domain string `json:"domain"`
}
// MarshalJSON implements json.Marshaler.
func (v *chainInfo) MarshalJSON() ([]byte, error) {
return json.Marshal(&chainInfoJSON{
Version: fmt.Sprintf("%d", v.Version),
Validators: v.Validators,
GenesisValidatorsRoot: fmt.Sprintf("%#x", v.GenesisValidatorsRoot),
Epoch: fmt.Sprintf("%d", v.Epoch),
ForkVersion: fmt.Sprintf("%#x", v.ForkVersion),
Domain: fmt.Sprintf("%#x", v.Domain),
})
}
// UnmarshalJSON implements json.Unmarshaler.
func (v *chainInfo) UnmarshalJSON(input []byte) error {
var data chainInfoJSON
if err := json.Unmarshal(input, &data); err != nil {
return errors.Wrap(err, "invalid JSON")
}
if data.Version == "" {
// Default to 1.
v.Version = 1
} else {
version, err := strconv.ParseUint(data.Version, 10, 64)
if err != nil {
return errors.Wrap(err, "version invalid")
}
v.Version = version
}
if len(data.Validators) == 0 {
return errors.New("validators missing")
}
v.Validators = data.Validators
if data.GenesisValidatorsRoot == "" {
return errors.New("genesis validators root missing")
}
genesisValidatorsRootBytes, err := hex.DecodeString(strings.TrimPrefix(data.GenesisValidatorsRoot, "0x"))
if err != nil {
return errors.Wrap(err, "genesis validators root invalid")
}
if len(genesisValidatorsRootBytes) != phase0.RootLength {
return errors.New("genesis validators root incorrect length")
}
copy(v.GenesisValidatorsRoot[:], genesisValidatorsRootBytes)
if data.Epoch == "" {
return errors.New("epoch missing")
}
epoch, err := strconv.ParseUint(data.Epoch, 10, 64)
if err != nil {
return errors.Wrap(err, "epoch invalid")
}
v.Epoch = phase0.Epoch(epoch)
if data.ForkVersion == "" {
return errors.New("fork version missing")
}
forkVersionBytes, err := hex.DecodeString(strings.TrimPrefix(data.ForkVersion, "0x"))
if err != nil {
return errors.Wrap(err, "fork version invalid")
}
if len(forkVersionBytes) != phase0.ForkVersionLength {
return errors.New("fork version incorrect length")
}
copy(v.ForkVersion[:], forkVersionBytes)
if data.Domain == "" {
return errors.New("domain missing")
}
domainBytes, err := hex.DecodeString(strings.TrimPrefix(data.Domain, "0x"))
if err != nil {
return errors.Wrap(err, "domain invalid")
}
if len(domainBytes) != phase0.DomainLength {
return errors.New("domain incorrect length")
}
copy(v.Domain[:], domainBytes)
return nil
}

View File

@@ -0,0 +1,104 @@
// 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 validatorcredentialsset
import (
"context"
"time"
consensusclient "github.com/attestantio/go-eth2-client"
"github.com/attestantio/go-eth2-client/spec/bellatrix"
capella "github.com/attestantio/go-eth2-client/spec/capella"
"github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/wealdtech/ethdo/services/chaintime"
"github.com/wealdtech/ethdo/util"
)
type command struct {
quiet bool
verbose bool
debug bool
offline bool
json bool
// Input.
account string
passphrases []string
mnemonic string
path string
privateKey string
validator string
withdrawalAddressStr string
forkVersion string
genesisValidatorsRoot string
prepareOffline bool
// Beacon node connection.
timeout time.Duration
connection string
allowInsecureConnections bool
// Information required to generate the operations.
withdrawalAddress bellatrix.ExecutionAddress
chainInfo *chainInfo
// Processing.
consensusClient consensusclient.Service
chainTime chaintime.Service
// Output.
signedOperations []*capella.SignedBLSToExecutionChange
}
func newCommand(ctx context.Context) (*command, error) {
c := &command{
quiet: viper.GetBool("quiet"),
verbose: viper.GetBool("verbose"),
debug: viper.GetBool("debug"),
offline: viper.GetBool("offline"),
json: viper.GetBool("json"),
timeout: viper.GetDuration("timeout"),
connection: viper.GetString("connection"),
allowInsecureConnections: viper.GetBool("allow-insecure-connections"),
prepareOffline: viper.GetBool("prepare-offline"),
account: viper.GetString("account"),
passphrases: util.GetPassphrases(),
mnemonic: viper.GetString("mnemonic"),
path: viper.GetString("path"),
privateKey: viper.GetString("private-key"),
validator: viper.GetString("validator"),
withdrawalAddressStr: viper.GetString("withdrawal-address"),
forkVersion: viper.GetString("fork-version"),
genesisValidatorsRoot: viper.GetString("genesis-validators-root"),
}
// Timeout is required.
if c.timeout == 0 {
return nil, errors.New("timeout is required")
}
// We are generating information for offline use, we don't need any information
// related to the accounts or signing.
if c.prepareOffline {
return c, nil
}
if c.account != "" && len(c.passphrases) == 0 {
return nil, errors.New("passphrase required with account")
}
return c, nil
}

View File

@@ -0,0 +1,83 @@
// 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 validatorcredentialsset
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: "NoValidatorInfo",
vars: map[string]interface{}{
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
},
err: "one of account, index or pubkey required",
},
{
name: "MultipleValidatorInfo",
vars: map[string]interface{}{
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
"index": "1",
"pubkey": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
},
err: "only one of account, index and pubkey allowed",
},
{
name: "Good",
vars: map[string]interface{}{
"timeout": "5s",
"connection": os.Getenv("ETHDO_TEST_CONNECTION"),
"index": "1",
},
},
}
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)
}
})
}
}

View File

@@ -0,0 +1,41 @@
// 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 validatorcredentialsset
import (
"context"
"encoding/json"
"os"
"github.com/pkg/errors"
)
func (c *command) output(ctx context.Context) (string, error) {
if c.quiet {
return "", nil
}
if c.json || c.offline {
data, err := json.Marshal(c.signedOperations)
if err != nil {
return "", errors.Wrap(err, "failed to marshal signed operations")
}
if err := os.WriteFile("credentials-operations.json", data, 0600); err != nil {
return "", errors.Wrap(err, "failed to write credentials-operations.json")
}
return "", nil
}
return "", nil
}

View File

@@ -0,0 +1,626 @@
// 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 validatorcredentialsset
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"regexp"
"strconv"
"strings"
consensusclient "github.com/attestantio/go-eth2-client"
"github.com/attestantio/go-eth2-client/spec/bellatrix"
capella "github.com/attestantio/go-eth2-client/spec/capella"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
standardchaintime "github.com/wealdtech/ethdo/services/chaintime/standard"
"github.com/wealdtech/ethdo/signing"
"github.com/wealdtech/ethdo/util"
ethutil "github.com/wealdtech/go-eth2-util"
e2wtypes "github.com/wealdtech/go-eth2-wallet-types/v2"
)
// validatorPath is the regular expression that matches a validator path.
var validatorPath = regexp.MustCompile("^m/12381/3600/[0-9]+/0/0$")
var offlinePreparationFilename = "offline-preparation.json"
var changeOperationsFilename = "change-operations.json"
func (c *command) process(ctx context.Context) error {
if err := c.setup(ctx); err != nil {
return err
}
if err := c.obtainRequiredInformation(ctx); err != nil {
return err
}
if c.prepareOffline {
return c.dumpRequiredInformation(ctx)
}
if err := c.generateOperations(ctx); err != nil {
return err
}
if validated, reason := c.validateOperations(ctx); !validated {
return fmt.Errorf("operation failed validation: %s", reason)
}
if c.json || c.offline {
// Want JSON output, or cannot broadcast.
return nil
}
return c.broadcastOperations(ctx)
}
// obtainRequiredInformation obtains the information required to create a
// withdrawal credentials change operation.
func (c *command) obtainRequiredInformation(ctx context.Context) error {
c.chainInfo = &chainInfo{
Validators: make([]*validatorInfo, 0),
}
// Use the offline preparation file if present (and we haven't been asked to recreate it).
if !c.prepareOffline {
err := c.loadChainInfo(ctx)
if err == nil {
return nil
}
}
if c.offline {
return fmt.Errorf("could not find the %s file; this is required to have been previously generated using --offline-preparation on an online mcahine and be readable in the directory in which this command is being run", offlinePreparationFilename)
}
if err := c.populateChainInfo(ctx); err != nil {
return err
}
return nil
}
// populateChainInfo populates chain info structure from a beacon node.
func (c *command) populateChainInfo(ctx context.Context) error {
// Obtain validators.
validators, err := c.consensusClient.(consensusclient.ValidatorsProvider).Validators(ctx, "head", nil)
if err != nil {
return errors.Wrap(err, "failed to obtain validators")
}
for _, validator := range validators {
c.chainInfo.Validators = append(c.chainInfo.Validators, &validatorInfo{
Index: validator.Index,
Pubkey: validator.Validator.PublicKey,
WithdrawalCredentials: validator.Validator.WithdrawalCredentials,
})
}
// Obtain genesis validators root.
genesis, err := c.consensusClient.(consensusclient.GenesisProvider).Genesis(ctx)
if err != nil {
return errors.Wrap(err, "failed to obtain genesis information")
}
c.chainInfo.GenesisValidatorsRoot = genesis.GenesisValidatorsRoot
// Obtain epoch.
c.chainInfo.Epoch = c.chainTime.CurrentEpoch()
// Obtain fork version.
forkSchedule, err := c.consensusClient.(consensusclient.ForkScheduleProvider).ForkSchedule(ctx)
if err != nil {
return errors.Wrap(err, "failed to obtain fork schedule")
}
for i := range forkSchedule {
if forkSchedule[i].Epoch <= c.chainInfo.Epoch {
c.chainInfo.ForkVersion = forkSchedule[i].CurrentVersion
}
}
// Calculate domain.
spec, err := c.consensusClient.(consensusclient.SpecProvider).Spec(ctx)
if err != nil {
return errors.Wrap(err, "failed to obtain spec")
}
domainType, exists := spec["DOMAIN_BLS_TO_EXECUTION_CHANGE"].(phase0.DomainType)
if !exists {
return errors.New("failed to obtain DOMAIN_BLS_TO_EXECUTION_CHANGE")
}
domainProvider, isProvider := c.consensusClient.(consensusclient.DomainProvider)
if !isProvider {
return errors.New("consensus node does not provide domain information")
}
c.chainInfo.Domain, err = domainProvider.Domain(ctx, domainType, c.chainInfo.Epoch)
if err != nil {
return errors.Wrap(err, "failed to obtain domain")
}
return nil
}
// dumpRequiredInformation prepares for an offline run of this command by dumping
// the chain information to a file.
func (c *command) dumpRequiredInformation(ctx context.Context) error {
data, err := json.Marshal(c.chainInfo)
if err != nil {
return err
}
if err := os.WriteFile(offlinePreparationFilename, data, 0600); err != nil {
return err
}
return nil
}
func (c *command) generateOperations(ctx context.Context) error {
// Ensure that we are beyond the capella hard fork epoch.
if c.chainTime.CurrentEpoch() < c.chainTime.CapellaInitialEpoch() {
return errors.New("chain not yet activated capella hard fork")
}
if c.account == "" && c.mnemonic == "" && c.privateKey == "" {
// No input information; fetch the operations from a file.
if err := c.loadOperations(ctx); err == nil {
return nil
}
return fmt.Errorf("no account, mnemonic or private key specified and no %s file found; cannot proceed", changeOperationsFilename)
}
if c.mnemonic != "" && c.path == "" {
// Have a mnemonic and no path; scan mnemonic.
return c.generateOperationsFromMnemonic(ctx)
}
if c.mnemonic != "" && c.path != "" {
// Have a mnemonic and path.
return c.generateOperationsFromMnemonicAndPath(ctx)
}
// Have a validator index or public key ; fetch the validator info.
validatorInfo, err := c.fetchValidatorInfo(ctx)
if err != nil {
return err
}
// Fetch the individual account.
withdrawalAccount, err := c.fetchAccount(ctx)
if err != nil {
return err
}
// Generate the operation.
if err := c.generateOperationFromAccount(ctx, validatorInfo, withdrawalAccount); err != nil {
return err
}
return nil
}
func (c *command) loadChainInfo(ctx context.Context) error {
_, err := os.Stat(offlinePreparationFilename)
if err != nil {
if c.debug {
fmt.Printf("Failed to read offline preparation file: %v\n", err)
}
}
if c.debug {
fmt.Printf("%s found; loading chain state\n", offlinePreparationFilename)
}
data, err := os.ReadFile(offlinePreparationFilename)
if err != nil {
return errors.Wrap(err, "failed to read offline preparation file")
}
if err := json.Unmarshal(data, c.chainInfo); err != nil {
return errors.Wrap(err, "failed to parse offline preparation file")
}
return nil
}
func (c *command) loadOperations(ctx context.Context) error {
_, err := os.Stat(changeOperationsFilename)
if err != nil {
if c.debug {
fmt.Printf("Failed to read change operations file: %v\n", err)
}
return err
}
if c.debug {
fmt.Printf("%s found; loading operations\n", changeOperationsFilename)
}
data, err := os.ReadFile(changeOperationsFilename)
if err != nil {
return errors.Wrap(err, "failed to read change operations file")
}
if err := json.Unmarshal(data, &c.signedOperations); err != nil {
return errors.Wrap(err, "failed to parse change operations file")
}
return nil
}
func (c *command) generateOperationsFromMnemonic(ctx context.Context) error {
seed, err := util.SeedFromMnemonic(c.mnemonic)
if err != nil {
return err
}
// Turn the validators in to a map for easy lookup.
validators := make(map[string]*validatorInfo, 0)
for _, validator := range c.chainInfo.Validators {
validators[fmt.Sprintf("%#x", validator.Pubkey)] = validator
}
maxDistance := 1024
// Start scanning the validator keys.
lastFoundIndex := 0
for i := 0; ; i++ {
if i-lastFoundIndex > maxDistance {
if c.debug {
fmt.Printf("Gone %d indices without finding a validator, not scanning any further\n", maxDistance)
}
break
}
validatorKeyPath := fmt.Sprintf("m/12381/3600/%d/0/0", i)
found, err := c.generateOperationFromSeedAndPath(ctx, validators, seed, validatorKeyPath)
if err != nil {
return errors.Wrap(err, "failed to generate operation from seed and path")
}
if found {
lastFoundIndex = i
}
}
return nil
}
func (c *command) generateOperationFromSeedAndPath(ctx context.Context,
validators map[string]*validatorInfo,
seed []byte,
path string,
) (
bool,
error,
) {
validatorPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, path)
if err != nil {
return false, errors.Wrap(err, "failed to generate validator private key")
}
validatorPubkey := fmt.Sprintf("%#x", validatorPrivkey.PublicKey().Marshal())
validator, exists := validators[validatorPubkey]
if !exists {
if c.debug {
fmt.Printf("No validator found with public key %s at path %s\n", validatorPubkey, path)
}
return false, nil
}
if c.verbose {
fmt.Printf("Validator %d found with public key %s at path %s\n", validator.Index, validatorPubkey, path)
}
if validator.WithdrawalCredentials[0] != byte(0) {
if c.debug {
fmt.Printf("Validator %s has non-BLS withdrawal credentials %#x\n", validatorPubkey, validator.WithdrawalCredentials)
}
return false, nil
}
// Recreate the withdrawal credentials to ensure a match.
withdrawalKeyPath := strings.TrimSuffix(path, "/0")
withdrawalPrivkey, err := ethutil.PrivateKeyFromSeedAndPath(seed, withdrawalKeyPath)
if err != nil {
return false, errors.Wrap(err, "failed to generate withdrawal private key")
}
withdrawalPubkey := withdrawalPrivkey.PublicKey()
withdrawalCredentials := ethutil.SHA256(withdrawalPubkey.Marshal())
withdrawalCredentials[0] = byte(0) // BLS_WITHDRAWAL_PREFIX
if !bytes.Equal(withdrawalCredentials, validator.WithdrawalCredentials) {
if c.verbose {
fmt.Printf("Validator %s withdrawal credentials %#x do not match expected credentials, cannot update\n", validatorPubkey, validator.WithdrawalCredentials)
}
return false, nil
}
if c.debug {
fmt.Printf("Validator %s eligible for setting credentials\n", validatorPubkey)
}
withdrawalAccount, err := util.ParseAccount(ctx, c.mnemonic, []string{withdrawalKeyPath}, true)
if err != nil {
return false, errors.Wrap(err, "failed to create withdrawal account")
}
err = c.generateOperationFromAccount(ctx, validator, withdrawalAccount)
if err != nil {
return false, err
}
return true, nil
}
func (c *command) generateOperationFromAccount(ctx context.Context,
validator *validatorInfo,
withdrawalAccount e2wtypes.Account,
) error {
signedOperation, err := c.createSignedOperation(ctx, validator, withdrawalAccount)
if err != nil {
return err
}
c.signedOperations = append(c.signedOperations, signedOperation)
return nil
}
func (c *command) createSignedOperation(ctx context.Context,
validator *validatorInfo,
withdrawalAccount e2wtypes.Account,
) (
*capella.SignedBLSToExecutionChange,
error,
) {
pubkey, err := util.BestPublicKey(withdrawalAccount)
if err != nil {
return nil, err
}
blsPubkey := phase0.BLSPubKey{}
copy(blsPubkey[:], pubkey.Marshal())
if err := c.parseWithdrawalAddress(ctx); err != nil {
return nil, errors.Wrap(err, "invalid withdrawal address")
}
operation := &capella.BLSToExecutionChange{
ValidatorIndex: validator.Index,
FromBLSPubkey: blsPubkey,
ToExecutionAddress: c.withdrawalAddress,
}
root, err := operation.HashTreeRoot()
if err != nil {
return nil, errors.Wrap(err, "failed to generate root for credentials change operation")
}
// Sign the operation.
signature, err := signing.SignRoot(ctx, withdrawalAccount, nil, root, c.chainInfo.Domain)
if err != nil {
return nil, errors.Wrap(err, "failed to sign credentials change operation")
}
return &capella.SignedBLSToExecutionChange{
Message: operation,
Signature: signature,
}, nil
}
func (c *command) parseWithdrawalAddress(ctx context.Context) error {
withdrawalAddressBytes, err := hex.DecodeString(strings.TrimPrefix(c.withdrawalAddressStr, "0x"))
if err != nil {
return errors.Wrap(err, "failed to obtain execution address")
}
if len(withdrawalAddressBytes) != bellatrix.ExecutionAddressLength {
return errors.New("withdrawal address must be exactly 20 bytes in length")
}
// Ensure the address is properly checksummed.
checksummedAddress := addressBytesToEIP55(withdrawalAddressBytes)
if checksummedAddress != c.withdrawalAddressStr {
return fmt.Errorf("withdrawal address checksum does not match (expected %s)", checksummedAddress)
}
copy(c.withdrawalAddress[:], withdrawalAddressBytes)
return nil
}
func (c *command) validateOperations(ctx context.Context) (bool, string) {
// Turn the validators in to a map for easy lookup.
validators := make(map[phase0.ValidatorIndex]*validatorInfo, 0)
for _, validator := range c.chainInfo.Validators {
validators[validator.Index] = validator
}
for _, signedOperation := range c.signedOperations {
if validated, reason := c.validateOperation(ctx, validators, signedOperation); !validated {
return validated, reason
}
}
return true, ""
}
func (c *command) validateOperation(ctx context.Context,
validators map[phase0.ValidatorIndex]*validatorInfo,
signedOperation *capella.SignedBLSToExecutionChange,
) (
bool,
string,
) {
validator, exists := validators[signedOperation.Message.ValidatorIndex]
if !exists {
return false, "validator not known on chain"
}
if c.debug {
fmt.Printf("Credentials change operation: %v", signedOperation)
fmt.Printf("On-chain validator info: %v\n", validator)
}
if validator.WithdrawalCredentials[0] != byte(0) {
return false, "validator is not using BLS withdrawal credentials"
}
withdrawalCredentials := ethutil.SHA256(signedOperation.Message.FromBLSPubkey[:])
withdrawalCredentials[0] = byte(0) // BLS_WITHDRAWAL_PREFIX
if !bytes.Equal(withdrawalCredentials, validator.WithdrawalCredentials) {
if c.debug {
fmt.Printf("validator withdrawal credentials %#x do not match calculated operation withdrawal credentials %#x\n", validator.WithdrawalCredentials, withdrawalCredentials)
}
return false, "validator withdrawal credentials do not match those in the operation"
}
return true, ""
}
func (c *command) broadcastOperations(ctx context.Context) error {
// Broadcast the operations.
for _, signedOperation := range c.signedOperations {
if err := c.consensusClient.(consensusclient.BLSToExecutionChangeSubmitter).SubmitBLSToExecutionChange(ctx, signedOperation); err != nil {
return err
}
}
return nil
}
func (c *command) setup(ctx context.Context) error {
if c.offline {
return nil
}
// Connect to the consensus node.
var err error
c.consensusClient, err = util.ConnectToBeaconNode(ctx, c.connection, c.timeout, c.allowInsecureConnections)
if err != nil {
return errors.Wrap(err, "failed to connect to consensus node")
}
// Set up chaintime.
c.chainTime, err = standardchaintime.New(ctx,
standardchaintime.WithGenesisTimeProvider(c.consensusClient.(consensusclient.GenesisTimeProvider)),
standardchaintime.WithForkScheduleProvider(c.consensusClient.(consensusclient.ForkScheduleProvider)),
standardchaintime.WithSpecProvider(c.consensusClient.(consensusclient.SpecProvider)),
)
if err != nil {
return errors.Wrap(err, "failed to create chaintime service")
}
return nil
}
func (c *command) fetchValidatorInfo(ctx context.Context) (*validatorInfo, error) {
var validatorInfo *validatorInfo
switch {
case c.validator == "":
return nil, errors.New("no validator specified")
case strings.HasPrefix(c.validator, "0x"):
// A public key
for _, validator := range c.chainInfo.Validators {
if strings.EqualFold(c.validator, fmt.Sprintf("%#x", validator.Pubkey)) {
validatorInfo = validator
break
}
}
case strings.Contains(c.validator, "/"):
// An account.
_, account, err := util.WalletAndAccountFromPath(ctx, c.validator)
if err != nil {
return nil, errors.Wrap(err, "unable to obtain account")
}
accPubKey, err := util.BestPublicKey(account)
if err != nil {
return nil, errors.Wrap(err, "unable to obtain public key for account")
}
pubkey := fmt.Sprintf("%#x", accPubKey.Marshal())
for _, validator := range c.chainInfo.Validators {
if strings.EqualFold(pubkey, fmt.Sprintf("%#x", validator.Pubkey)) {
validatorInfo = validator
break
}
}
default:
// An index.
index, err := strconv.ParseUint(c.validator, 10, 64)
if err != nil {
return nil, errors.Wrap(err, "failed to parse validator index")
}
validatorIndex := phase0.ValidatorIndex(index)
for _, validator := range c.chainInfo.Validators {
if validator.Index == validatorIndex {
validatorInfo = validator
break
}
}
}
if validatorInfo == nil {
return nil, errors.New("unknown validator")
}
return validatorInfo, nil
}
func (c *command) fetchAccount(ctx context.Context) (e2wtypes.Account, error) {
var account e2wtypes.Account
var err error
switch {
case c.account != "":
account, err = util.ParseAccount(ctx, c.account, c.passphrases, true)
case c.mnemonic != "":
account, err = util.ParseAccount(ctx, c.mnemonic, []string{c.path}, true)
case c.privateKey != "":
account, err = util.ParseAccount(ctx, c.privateKey, nil, true)
default:
err = errors.New("account, mnemonic or private key must be supplied")
}
return account, err
}
// addressBytesToEIP55 converts a byte array in to an EIP-55 string format.
func addressBytesToEIP55(address []byte) string {
bytes := []byte(fmt.Sprintf("%x", address))
hash := ethutil.Keccak256(bytes)
for i := 0; i < len(bytes); i++ {
hashByte := hash[i/2]
if i%2 == 0 {
hashByte >>= 4
} else {
hashByte &= 0xf
}
if bytes[i] > '9' && hashByte > 7 {
bytes[i] -= 32
}
}
return fmt.Sprintf("0x%s", string(bytes))
}
func (c *command) generateOperationsFromMnemonicAndPath(ctx context.Context) error {
seed, err := util.SeedFromMnemonic(c.mnemonic)
if err != nil {
return err
}
// Turn the validators in to a map for easy lookup.
validators := make(map[string]*validatorInfo, 0)
for _, validator := range c.chainInfo.Validators {
validators[fmt.Sprintf("%#x", validator.Pubkey)] = validator
}
validatorKeyPath := c.path
match := validatorPath.Match([]byte(c.path))
if !match {
return fmt.Errorf("path %s does not match EIP-2334 format", c.path)
}
if _, err := c.generateOperationFromSeedAndPath(ctx, validators, seed, validatorKeyPath); err != nil {
return errors.Wrap(err, "failed to generate operation from seed and path")
}
return nil
}

View File

@@ -0,0 +1,50 @@
// 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 validatorcredentialsset
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
}

View File

@@ -0,0 +1,97 @@
// 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 validatorcredentialsset
import (
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
)
type validatorInfo struct {
Index phase0.ValidatorIndex
Pubkey phase0.BLSPubKey
WithdrawalCredentials []byte
}
type validatorInfoJSON struct {
Index string `json:"index"`
Pubkey string `json:"pubkey"`
WithdrawalCredentials string `json:"withdrawal_credentials"`
}
// MarshalJSON implements json.Marshaler.
func (v *validatorInfo) MarshalJSON() ([]byte, error) {
return json.Marshal(&validatorInfoJSON{
Index: fmt.Sprintf("%d", v.Index),
Pubkey: fmt.Sprintf("%#x", v.Pubkey),
WithdrawalCredentials: fmt.Sprintf("%#x", v.WithdrawalCredentials),
})
}
// UnmarshalJSON implements json.Unmarshaler.
func (v *validatorInfo) UnmarshalJSON(input []byte) error {
var data validatorInfoJSON
if err := json.Unmarshal(input, &data); err != nil {
return errors.Wrap(err, "invalid JSON")
}
if data.Index == "" {
return errors.New("index missing")
}
index, err := strconv.ParseUint(data.Index, 10, 64)
if err != nil {
return errors.Wrap(err, "invalid value for index")
}
v.Index = phase0.ValidatorIndex(index)
if data.Pubkey == "" {
return errors.New("public key missing")
}
pubkey, err := hex.DecodeString(strings.TrimPrefix(data.Pubkey, "0x"))
if err != nil {
return errors.Wrap(err, "invalid value for public key")
}
if len(pubkey) != phase0.PublicKeyLength {
return fmt.Errorf("incorrect length %d for public key", len(pubkey))
}
copy(v.Pubkey[:], pubkey)
if data.WithdrawalCredentials == "" {
return errors.New("withdrawal credentials missing")
}
v.WithdrawalCredentials, err = hex.DecodeString(strings.TrimPrefix(data.WithdrawalCredentials, "0x"))
if err != nil {
return errors.Wrap(err, "invalid value for withdrawal credentials")
}
if len(v.WithdrawalCredentials) != phase0.HashLength {
return fmt.Errorf("incorrect length %d for withdrawal credentials", len(v.WithdrawalCredentials))
}
return nil
}
// String implements the Stringer interface.
func (v *validatorInfo) String() string {
data, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("Err: %v\n", err)
}
return string(data)
}

View File

@@ -1,4 +1,4 @@
// Copyright © 2019, 2020 Weald Technology Trading
// Copyright © 2019 - 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
@@ -110,6 +110,8 @@ func validatorDepositDataOutputLaunchpad(datum *dataOut) (string, error) {
[4]byte{0x00, 0x00, 0x00, 0x00}: "mainnet",
[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 {

View File

@@ -49,9 +49,6 @@ func input(ctx context.Context) (*dataIn, error) {
// Ethereum 2 connection.
data.eth2Client = viper.GetString("connection")
if data.eth2Client == "" {
return nil, errors.New("connection is required")
}
data.allowInsecure = viper.GetBool("allow-insecure-connections")
// Account.

View File

@@ -71,14 +71,6 @@ func TestInput(t *testing.T) {
},
err: "account, pubkey or index required",
},
{
name: "ConnectionMissing",
vars: map[string]interface{}{
"timeout": "5s",
"pubkey": "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c",
},
err: "connection is required",
},
}
for _, test := range tests {

View File

@@ -158,7 +158,7 @@ func TestInput(t *testing.T) {
"timeout": "5s",
"key": "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866",
},
err: "failed to connect to Ethereum 2 beacon node: failed to connect to beacon node: failed to connect to Ethereum 2 client with any known method",
err: "failed to connect to Ethereum 2 beacon node: failed to connect to beacon node: failed to confirm node connection: failed to fetch genesis: failed to request genesis: failed to call GET endpoint: Get \"http://localhost:1/eth/v1/beacon/genesis\": dial tcp 127.0.0.1:1: connect: connection refused",
},
{
name: "EpochProvided",

View File

@@ -58,9 +58,6 @@ func newCommand(ctx context.Context) (*command, error) {
}
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")

View File

@@ -37,14 +37,6 @@ func TestInput(t *testing.T) {
vars: map[string]interface{}{},
err: "timeout is required",
},
{
name: "ConnectionMissing",
vars: map[string]interface{}{
"validators": "1",
"timeout": "5s",
},
err: "connection is required",
},
{
name: "ValidatorsZero",
vars: map[string]interface{}{

View File

@@ -0,0 +1,140 @@
// 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 validatorsummary
import (
"context"
"time"
eth2client "github.com/attestantio/go-eth2-client"
apiv1 "github.com/attestantio/go-eth2-client/api/v1"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
"github.com/spf13/viper"
"github.com/wealdtech/ethdo/services/chaintime"
)
type command struct {
quiet bool
verbose bool
debug bool
// Beacon node connection.
timeout time.Duration
connection string
allowInsecureConnections bool
// Operation.
epoch string
validators []string
jsonOutput bool
// Data access.
eth2Client eth2client.Service
chainTime chaintime.Service
proposerDutiesProvider eth2client.ProposerDutiesProvider
attesterDutiesProvider eth2client.AttesterDutiesProvider
blocksProvider eth2client.SignedBeaconBlockProvider
syncCommitteesProvider eth2client.SyncCommitteesProvider
validatorsProvider eth2client.ValidatorsProvider
beaconCommitteesProvider eth2client.BeaconCommitteesProvider
beaconBlockHeadersProvider eth2client.BeaconBlockHeadersProvider
// Processing.
validatorsByIndex map[phase0.ValidatorIndex]*apiv1.Validator
// Results.
summary *validatorSummary
}
type validatorSummary struct {
Epoch phase0.Epoch `json:"epoch"`
Validators []*apiv1.Validator `json:"validators"`
FirstSlot phase0.Slot `json:"first_slot"`
LastSlot phase0.Slot `json:"last_slot"`
ActiveValidators int `json:"active_validators"`
ParticipatingValidators int `json:"participating_validators"`
NonParticipatingValidators []*nonParticipatingValidator `json:"non_participating_validators"`
IncorrectHeadValidators []*validatorFault `json:"incorrect_head_validators"`
UntimelyHeadValidators []*validatorFault `json:"untimely_head_validators"`
UntimelySourceValidators []*validatorFault `json:"untimely_source_validators"`
IncorrectTargetValidators []*validatorFault `json:"incorrect_target_validators"`
UntimelyTargetValidators []*validatorFault `json:"untimely_target_validators"`
Slots []*slot `json:"slots"`
Proposals []*epochProposal `json:"-"`
SyncCommittee []*epochSyncCommittee `json:"-"`
}
type slot struct {
Slot phase0.Slot `json:"slot"`
Attestations *slotAttestations `json:"attestations"`
}
type slotAttestations struct {
Expected int `json:"expected"`
Included int `json:"included"`
CorrectHead int `json:"correct_head"`
TimelyHead int `json:"timely_head"`
CorrectTarget int `json:"correct_target"`
TimelyTarget int `json:"timely_target"`
TimelySource int `json:"timely_source"`
}
type epochProposal struct {
Slot phase0.Slot `json:"slot"`
Proposer phase0.ValidatorIndex `json:"proposer"`
Block bool `json:"block"`
}
type epochSyncCommittee struct {
Index phase0.ValidatorIndex `json:"index"`
Missed int `json:"missed"`
}
type validatorFault struct {
Validator phase0.ValidatorIndex `json:"validator_index"`
AttestationData *phase0.AttestationData `json:"attestation_data,omitempty"`
InclusionDistance int `json:"inclusion_delay"`
}
type nonParticipatingValidator struct {
Validator phase0.ValidatorIndex `json:"validator_index"`
Slot phase0.Slot `json:"slot"`
Committee phase0.CommitteeIndex `json:"committee_index"`
}
func newCommand(ctx context.Context) (*command, error) {
c := &command{
quiet: viper.GetBool("quiet"),
verbose: viper.GetBool("verbose"),
debug: viper.GetBool("debug"),
validatorsByIndex: make(map[phase0.ValidatorIndex]*apiv1.Validator),
summary: &validatorSummary{},
}
// Timeout.
if viper.GetDuration("timeout") == 0 {
return nil, errors.New("timeout is required")
}
c.timeout = viper.GetDuration("timeout")
c.connection = viper.GetString("connection")
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")
c.epoch = viper.GetString("epoch")
c.validators = viper.GetStringSlice("validators")
c.jsonOutput = viper.GetBool("json")
return c, nil
}

View File

@@ -0,0 +1,64 @@
// 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 validatorsummary
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: "Good",
vars: map[string]interface{}{
"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)
}
})
}
}

View File

@@ -0,0 +1,86 @@
// 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 validatorsummary
import (
"context"
"encoding/json"
"fmt"
"strings"
)
func (c *command) output(ctx context.Context) (string, error) {
if c.quiet {
return "", nil
}
if c.jsonOutput {
return c.outputJSON(ctx)
}
return c.outputTxt(ctx)
}
func (c *command) outputJSON(_ context.Context) (string, error) {
data, err := json.Marshal(c.summary)
if err != nil {
return "", err
}
return string(data), nil
}
func (c *command) outputTxt(_ context.Context) (string, error) {
builder := strings.Builder{}
builder.WriteString("Epoch ")
builder.WriteString(fmt.Sprintf("%d:\n", c.summary.Epoch))
if len(c.summary.NonParticipatingValidators) > 0 {
builder.WriteString(" Non-participating validators:\n")
for _, validator := range c.summary.NonParticipatingValidators {
builder.WriteString(fmt.Sprintf(" %d (slot %d, committee %d)\n", validator.Validator, validator.Slot, validator.Committee))
}
}
if len(c.summary.IncorrectHeadValidators) > 0 {
builder.WriteString(" Incorrect head validators:\n")
for _, validator := range c.summary.IncorrectHeadValidators {
builder.WriteString(fmt.Sprintf(" %d (slot %d, committee %d)\n", validator.Validator, validator.AttestationData.Slot, validator.AttestationData.Index))
}
}
if len(c.summary.UntimelyHeadValidators) > 0 {
builder.WriteString(" Untimely head validators:\n")
for _, validator := range c.summary.UntimelyHeadValidators {
builder.WriteString(fmt.Sprintf(" %d (slot %d, committee %d, inclusion distance %d)\n", validator.Validator, validator.AttestationData.Slot, validator.AttestationData.Index, validator.InclusionDistance))
}
}
if len(c.summary.UntimelySourceValidators) > 0 {
builder.WriteString(" Untimely source validators:\n")
for _, validator := range c.summary.UntimelySourceValidators {
builder.WriteString(fmt.Sprintf(" %d (slot %d, committee %d, inclusion distance %d)\n", validator.Validator, validator.AttestationData.Slot, validator.AttestationData.Index, validator.InclusionDistance))
}
}
if len(c.summary.IncorrectTargetValidators) > 0 {
builder.WriteString(" Incorrect target validators:\n")
for _, validator := range c.summary.IncorrectTargetValidators {
builder.WriteString(fmt.Sprintf(" %d (slot %d, committee %d)\n", validator.Validator, validator.AttestationData.Slot, validator.AttestationData.Index))
}
}
if len(c.summary.UntimelyTargetValidators) > 0 {
builder.WriteString(" Untimely target validators:\n")
for _, validator := range c.summary.UntimelyTargetValidators {
builder.WriteString(fmt.Sprintf(" %d (slot %d, committee %d, inclusion distance %d)\n", validator.Validator, validator.AttestationData.Slot, validator.AttestationData.Index, validator.InclusionDistance))
}
}
return builder.String(), nil
}

View File

@@ -0,0 +1,425 @@
// 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 validatorsummary
import (
"context"
"fmt"
"sort"
eth2client "github.com/attestantio/go-eth2-client"
apiv1 "github.com/attestantio/go-eth2-client/api/v1"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/pkg/errors"
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.
err := c.setup(ctx)
if err != nil {
return err
}
c.summary.Epoch, err = util.ParseEpoch(ctx, c.chainTime, c.epoch)
if err != nil {
return errors.Wrap(err, "failed to parse epoch")
}
c.summary.FirstSlot = c.chainTime.FirstSlotOfEpoch(c.summary.Epoch)
c.summary.LastSlot = c.chainTime.FirstSlotOfEpoch(c.summary.Epoch+1) - 1
c.summary.Slots = make([]*slot, 1+int(c.summary.LastSlot)-int(c.summary.FirstSlot))
for i := range c.summary.Slots {
c.summary.Slots[i] = &slot{
Slot: c.summary.FirstSlot + phase0.Slot(i),
}
}
c.summary.Validators, err = util.ParseValidators(ctx, c.validatorsProvider, c.validators, fmt.Sprintf("%d", c.summary.FirstSlot))
if err != nil {
return errors.Wrap(err, "failed to parse validators")
}
// Reorder validators by index.
sort.Slice(c.summary.Validators, func(i int, j int) bool {
return c.summary.Validators[i].Index < c.summary.Validators[j].Index
})
// Create a map for validator indices for easy lookup.
c.validatorsByIndex = make(map[phase0.ValidatorIndex]*apiv1.Validator)
for _, validator := range c.summary.Validators {
c.validatorsByIndex[validator.Index] = validator
}
if err := c.processProposerDuties(ctx); err != nil {
return err
}
if err := c.processAttesterDuties(ctx); err != nil {
return err
}
// if err := c.processSyncCommitteeDuties(ctx); err != nil {
// return err
// }
return nil
}
func (c *command) processProposerDuties(ctx context.Context) error {
duties, err := c.proposerDutiesProvider.ProposerDuties(ctx, c.summary.Epoch, nil)
if err != nil {
return errors.Wrap(err, "failed to obtain proposer duties")
}
if duties == nil {
return errors.New("empty proposer duties")
}
for _, duty := range duties {
if _, exists := c.validatorsByIndex[duty.ValidatorIndex]; !exists {
continue
}
block, err := c.blocksProvider.SignedBeaconBlock(ctx, fmt.Sprintf("%d", duty.Slot))
if err != nil {
return errors.Wrap(err, fmt.Sprintf("failed to obtain block for slot %d", duty.Slot))
}
present := block != nil
c.summary.Proposals = append(c.summary.Proposals, &epochProposal{
Slot: duty.Slot,
Proposer: duty.ValidatorIndex,
Block: present,
})
}
return nil
}
func (c *command) activeValidators() (map[phase0.ValidatorIndex]*apiv1.Validator, []phase0.ValidatorIndex) {
activeValidators := make(map[phase0.ValidatorIndex]*apiv1.Validator)
activeValidatorIndices := make([]phase0.ValidatorIndex, 0, len(c.validatorsByIndex))
for _, validator := range c.summary.Validators {
if validator.Validator.ActivationEpoch <= c.summary.Epoch && validator.Validator.ExitEpoch > c.summary.Epoch {
activeValidators[validator.Index] = validator
activeValidatorIndices = append(activeValidatorIndices, validator.Index)
}
}
return activeValidators, activeValidatorIndices
}
func (c *command) processAttesterDuties(ctx context.Context) error {
activeValidators, activeValidatorIndices := c.activeValidators()
// Obtain number of validators that voted for blocks in the epoch.
// These votes can be included anywhere from the second slot of
// the epoch to the first slot of the next-but-one epoch.
firstSlot := c.chainTime.FirstSlotOfEpoch(c.summary.Epoch) + 1
lastSlot := c.chainTime.FirstSlotOfEpoch(c.summary.Epoch + 2)
if lastSlot > c.chainTime.CurrentSlot() {
lastSlot = c.chainTime.CurrentSlot()
}
// Obtain the duties for the validators to know where they should be attesting.
duties, err := c.attesterDutiesProvider.AttesterDuties(ctx, c.summary.Epoch, activeValidatorIndices)
if err != nil {
return errors.Wrap(err, "failed to obtain attester duties")
}
for slot := c.chainTime.FirstSlotOfEpoch(c.summary.Epoch); slot < c.chainTime.FirstSlotOfEpoch(c.summary.Epoch+1); slot++ {
index := int(slot - c.chainTime.FirstSlotOfEpoch(c.summary.Epoch))
c.summary.Slots[index].Attestations = &slotAttestations{}
}
// Need a cache of beacon block headers to reduce lookup times.
headersCache := util.NewBeaconBlockHeaderCache(c.beaconBlockHeadersProvider)
// Need a map of duties to easily find the attestations we care about.
dutiesBySlot := make(map[phase0.Slot]map[phase0.CommitteeIndex][]*apiv1.AttesterDuty)
dutiesByValidatorIndex := make(map[phase0.ValidatorIndex]*apiv1.AttesterDuty)
for _, duty := range duties {
index := int(duty.Slot - c.chainTime.FirstSlotOfEpoch(c.summary.Epoch))
dutiesByValidatorIndex[duty.ValidatorIndex] = duty
c.summary.Slots[index].Attestations.Expected++
if _, exists := dutiesBySlot[duty.Slot]; !exists {
dutiesBySlot[duty.Slot] = make(map[phase0.CommitteeIndex][]*apiv1.AttesterDuty)
}
if _, exists := dutiesBySlot[duty.Slot][duty.CommitteeIndex]; !exists {
dutiesBySlot[duty.Slot][duty.CommitteeIndex] = make([]*apiv1.AttesterDuty, 0)
}
dutiesBySlot[duty.Slot][duty.CommitteeIndex] = append(dutiesBySlot[duty.Slot][duty.CommitteeIndex], duty)
}
c.summary.IncorrectHeadValidators = make([]*validatorFault, 0)
c.summary.UntimelyHeadValidators = make([]*validatorFault, 0)
c.summary.UntimelySourceValidators = make([]*validatorFault, 0)
c.summary.IncorrectTargetValidators = make([]*validatorFault, 0)
c.summary.UntimelyTargetValidators = make([]*validatorFault, 0)
// Hunt through the blocks looking for attestations from the validators.
votes := make(map[phase0.ValidatorIndex]struct{})
for slot := firstSlot; slot <= lastSlot; slot++ {
if err := c.processAttesterDutiesSlot(ctx, slot, dutiesBySlot, votes, headersCache, activeValidatorIndices); err != nil {
return err
}
}
// Use dutiesMap and votes to work out which validators didn't participate.
c.summary.NonParticipatingValidators = make([]*nonParticipatingValidator, 0)
for _, index := range activeValidatorIndices {
if _, exists := votes[index]; !exists {
// Didn't vote.
duty := dutiesByValidatorIndex[index]
c.summary.NonParticipatingValidators = append(c.summary.NonParticipatingValidators, &nonParticipatingValidator{
Validator: index,
Slot: duty.Slot,
Committee: duty.CommitteeIndex,
})
}
}
// Sort the non-participating validators list.
sort.Slice(c.summary.NonParticipatingValidators, func(i int, j int) bool {
if c.summary.NonParticipatingValidators[i].Slot != c.summary.NonParticipatingValidators[j].Slot {
return c.summary.NonParticipatingValidators[i].Slot < c.summary.NonParticipatingValidators[j].Slot
}
if c.summary.NonParticipatingValidators[i].Committee != c.summary.NonParticipatingValidators[j].Committee {
return c.summary.NonParticipatingValidators[i].Committee < c.summary.NonParticipatingValidators[j].Committee
}
return c.summary.NonParticipatingValidators[i].Validator < c.summary.NonParticipatingValidators[j].Validator
})
c.summary.ActiveValidators = len(activeValidators)
c.summary.ParticipatingValidators = len(votes)
return nil
}
func (c *command) processAttesterDutiesSlot(ctx context.Context,
slot phase0.Slot,
dutiesBySlot map[phase0.Slot]map[phase0.CommitteeIndex][]*apiv1.AttesterDuty,
votes map[phase0.ValidatorIndex]struct{},
headersCache *util.BeaconBlockHeaderCache,
activeValidatorIndices []phase0.ValidatorIndex,
) error {
block, err := c.blocksProvider.SignedBeaconBlock(ctx, fmt.Sprintf("%d", slot))
if err != nil {
return errors.Wrap(err, fmt.Sprintf("failed to obtain block for slot %d", slot))
}
if block == nil {
// No block at this slot; that's fine.
return nil
}
attestations, err := block.Attestations()
if err != nil {
return err
}
for _, attestation := range attestations {
if _, exists := dutiesBySlot[attestation.Data.Slot]; !exists {
// We do not have any attestations for this slot.
continue
}
if _, exists := dutiesBySlot[attestation.Data.Slot][attestation.Data.Index]; !exists {
// We do not have any attestations for this committee.
continue
}
for _, duty := range dutiesBySlot[attestation.Data.Slot][attestation.Data.Index] {
if attestation.AggregationBits.BitAt(duty.ValidatorCommitteeIndex) {
// Found it.
if _, exists := votes[duty.ValidatorIndex]; exists {
// Duplicate; ignore.
continue
}
votes[duty.ValidatorIndex] = struct{}{}
// Update the metrics for the attestation.
index := int(attestation.Data.Slot - c.chainTime.FirstSlotOfEpoch(c.summary.Epoch))
c.summary.Slots[index].Attestations.Included++
inclusionDelay := slot - duty.Slot
fault := &validatorFault{
Validator: duty.ValidatorIndex,
AttestationData: attestation.Data,
InclusionDistance: int(inclusionDelay),
}
headCorrect, err := util.AttestationHeadCorrect(ctx, headersCache, attestation)
if err != nil {
return errors.Wrap(err, "failed to calculate if attestation had correct head vote")
}
if headCorrect {
c.summary.Slots[index].Attestations.CorrectHead++
if inclusionDelay == 1 {
c.summary.Slots[index].Attestations.TimelyHead++
} else {
c.summary.UntimelyHeadValidators = append(c.summary.UntimelyHeadValidators, fault)
}
} else {
c.summary.IncorrectHeadValidators = append(c.summary.IncorrectHeadValidators, fault)
if inclusionDelay > 1 {
c.summary.UntimelyHeadValidators = append(c.summary.UntimelyHeadValidators, fault)
}
}
if inclusionDelay <= 5 {
c.summary.Slots[index].Attestations.TimelySource++
} else {
c.summary.UntimelySourceValidators = append(c.summary.UntimelySourceValidators, fault)
}
targetCorrect, err := util.AttestationTargetCorrect(ctx, headersCache, c.chainTime, attestation)
if err != nil {
return errors.Wrap(err, "failed to calculate if attestation had correct target vote")
}
if targetCorrect {
c.summary.Slots[index].Attestations.CorrectTarget++
if inclusionDelay <= 32 {
c.summary.Slots[index].Attestations.TimelyTarget++
} else {
c.summary.UntimelyTargetValidators = append(c.summary.UntimelyTargetValidators, fault)
}
} else {
c.summary.IncorrectTargetValidators = append(c.summary.IncorrectTargetValidators, fault)
if inclusionDelay > 32 {
c.summary.UntimelyTargetValidators = append(c.summary.UntimelyTargetValidators, fault)
}
}
}
}
if len(votes) == len(activeValidatorIndices) {
// Found them all.
break
}
}
return nil
}
// func (c *command) processSyncCommitteeDuties(ctx context.Context) error {
// if c.summary.Epoch < c.chainTime.AltairInitialEpoch() {
// // The epoch is pre-Altair. No info but no error.
// return nil
// }
//
// committee, err := c.syncCommitteesProvider.SyncCommittee(ctx, fmt.Sprintf("%d", c.summary.FirstSlot))
// if err != nil {
// return errors.Wrap(err, "failed to obtain sync committee")
// }
// if len(committee.Validators) == 0 {
// return errors.Wrap(err, "empty sync committee")
// }
//
// missed := make(map[phase0.ValidatorIndex]int)
// for _, index := range committee.Validators {
// missed[index] = 0
// }
//
// for slot := c.summary.FirstSlot; slot <= c.summary.LastSlot; slot++ {
// block, err := c.blocksProvider.SignedBeaconBlock(ctx, fmt.Sprintf("%d", slot))
// if err != nil {
// return errors.Wrap(err, fmt.Sprintf("failed to obtain block for slot %d", slot))
// }
// if block == nil {
// // If the block is missed we don't count the sync aggregate miss.
// continue
// }
// var aggregate *altair.SyncAggregate
// switch block.Version {
// case spec.DataVersionPhase0:
// // No sync committees in this fork.
// return nil
// case spec.DataVersionAltair:
// aggregate = block.Altair.Message.Body.SyncAggregate
// case spec.DataVersionBellatrix:
// aggregate = block.Bellatrix.Message.Body.SyncAggregate
// default:
// return fmt.Errorf("unhandled block version %v", block.Version)
// }
// for i := uint64(0); i < aggregate.SyncCommitteeBits.Len(); i++ {
// if !aggregate.SyncCommitteeBits.BitAt(i) {
// missed[committee.Validators[int(i)]]++
// }
// }
// }
//
// c.summary.SyncCommittee = make([]*epochSyncCommittee, 0, len(missed))
// for index, count := range missed {
// if count > 0 {
// c.summary.SyncCommittee = append(c.summary.SyncCommittee, &epochSyncCommittee{
// Index: index,
// Missed: count,
// })
// }
// }
//
// sort.Slice(c.summary.SyncCommittee, func(i int, j int) bool {
// missedDiff := c.summary.SyncCommittee[i].Missed - c.summary.SyncCommittee[j].Missed
// if missedDiff != 0 {
// // Actually want to order by missed descending, so invert the expected condition.
// return missedDiff > 0
// }
// // Then order by validator index.
// return c.summary.SyncCommittee[i].Index < c.summary.SyncCommittee[j].Index
// })
//
// 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")
}
c.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")
}
var isProvider bool
c.proposerDutiesProvider, isProvider = c.eth2Client.(eth2client.ProposerDutiesProvider)
if !isProvider {
return errors.New("connection does not provide proposer duties")
}
c.attesterDutiesProvider, isProvider = c.eth2Client.(eth2client.AttesterDutiesProvider)
if !isProvider {
return errors.New("connection does not provide attester duties")
}
c.blocksProvider, isProvider = c.eth2Client.(eth2client.SignedBeaconBlockProvider)
if !isProvider {
return errors.New("connection does not provide signed beacon blocks")
}
c.syncCommitteesProvider, isProvider = c.eth2Client.(eth2client.SyncCommitteesProvider)
if !isProvider {
return errors.New("connection does not provide sync committee duties")
}
c.validatorsProvider, isProvider = c.eth2Client.(eth2client.ValidatorsProvider)
if !isProvider {
return errors.New("connection does not provide validators")
}
c.beaconCommitteesProvider, isProvider = c.eth2Client.(eth2client.BeaconCommitteesProvider)
if !isProvider {
return errors.New("connection does not provide beacon committees")
}
c.beaconBlockHeadersProvider, isProvider = c.eth2Client.(eth2client.BeaconBlockHeadersProvider)
if !isProvider {
return errors.New("connection does not provide beacon block headers")
}
return nil
}

View File

@@ -0,0 +1,62 @@
// 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 validatorsummary
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",
"data": "[[",
"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)
}
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)
}
})
}
}

View File

@@ -0,0 +1,50 @@
// 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 validatorsummary
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
}

View File

@@ -0,0 +1,81 @@
// 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")
c.connection = viper.GetString("connection")
c.allowInsecureConnections = viper.GetBool("allow-insecure-connections")
c.validators = viper.GetString("validators")
return c, nil
}

View File

@@ -0,0 +1,65 @@
// 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: "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)
}
})
}
}

View 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
}

View 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
}

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

View 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
}

View File

@@ -0,0 +1,32 @@
// 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 (
"github.com/spf13/cobra"
)
// validatorCredentialsCmd represents the validator credentials command
var validatorCredentialsCmd = &cobra.Command{
Use: "credentials",
Short: "Manage Ethereum consensus validator credentials",
Long: `Manage Ethereum consensus validator credentials.`,
}
func init() {
validatorCmd.AddCommand(validatorCredentialsCmd)
}
func validatorCredentialsFlags(cmd *cobra.Command) {
}

View File

@@ -0,0 +1,57 @@
// 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"
"github.com/spf13/cobra"
"github.com/spf13/viper"
validatorcredentialsget "github.com/wealdtech/ethdo/cmd/validator/credentials/get"
)
var validatorCredentialsGetCmd = &cobra.Command{
Use: "get",
Short: "Obtain withdrawal credentials for an Ethereum consensus validator",
Long: `Obtain withdrawal credentials for an Ethereum consensus validator. For example:
ethdo validator credentials get --validator=primary/validator
In quiet mode this will return 0 if the validator exists, otherwise 1.`,
RunE: func(cmd *cobra.Command, args []string) error {
res, err := validatorcredentialsget.Run(cmd)
if err != nil {
return err
}
if viper.GetBool("quiet") {
return nil
}
if res != "" {
fmt.Println(res)
}
return nil
},
}
func init() {
validatorCredentialsCmd.AddCommand(validatorCredentialsGetCmd)
validatorCredentialsFlags(validatorCredentialsGetCmd)
validatorCredentialsGetCmd.Flags().String("validator", "", "Validator for which to get validator credentials")
}
func validatorCredentialsGetBindings() {
if err := viper.BindPFlag("validator", validatorCredentialsGetCmd.Flags().Lookup("validator")); err != nil {
panic(err)
}
}

Some files were not shown because too many files have changed in this diff Show More