diff --git a/api/v1/forkchoice.go b/api/v1/forkchoice.go new file mode 100644 index 00000000..f170a493 --- /dev/null +++ b/api/v1/forkchoice.go @@ -0,0 +1,272 @@ +// Copyright © 2020, 2021 Attestant Limited. +// 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 v1 + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// ForkChoice is the data regarding the node's current fork choice context. +type ForkChoice struct { + // JustifiedCheckpoint is the current justified checkpoint. + JustifiedCheckpoint phase0.Checkpoint + // FInalizedCheckpoint is the current finalized checkpoint. + FinalizedCheckpoint phase0.Checkpoint + // ForkChoiceNodes contains the fork choice nodes. + ForkChoiceNodes []*ForkChoiceNode +} + +// MarshalJSON implements json.Marshaler. +func (f *ForkChoice) MarshalJSON() ([]byte, error) { + return json.Marshal(&forkChoiceJSON{ + JustifiedCheckpoint: &f.JustifiedCheckpoint, + FinalizedCheckpoint: &f.FinalizedCheckpoint, + ForkChoiceNodes: f.ForkChoiceNodes, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (f *ForkChoice) UnmarshalJSON(input []byte) error { + var err error + + var forkChoiceJSON forkChoiceJSON + if err = json.Unmarshal(input, &forkChoiceJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if forkChoiceJSON.JustifiedCheckpoint == nil { + return errors.New("justified checkpoint missing") + } + f.JustifiedCheckpoint = *forkChoiceJSON.JustifiedCheckpoint + + if forkChoiceJSON.FinalizedCheckpoint == nil { + return errors.New("finalized checkpoint missing") + } + f.FinalizedCheckpoint = *forkChoiceJSON.FinalizedCheckpoint + + if forkChoiceJSON.ForkChoiceNodes == nil { + return errors.New("fork choice nodes missing") + } + f.ForkChoiceNodes = forkChoiceJSON.ForkChoiceNodes + + return nil +} + +// String returns a string version of the structure. +func (f *ForkChoice) String() string { + data, err := json.Marshal(f) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + return string(data) +} + +// forkChoiceJSON is the json representation of the struct. +type forkChoiceJSON struct { + JustifiedCheckpoint *phase0.Checkpoint `json:"justified_checkpoint"` + FinalizedCheckpoint *phase0.Checkpoint `json:"finalized_checkpoint"` + ForkChoiceNodes []*ForkChoiceNode `json:"fork_choice_nodes"` +} + +// ForkChoiceNodeValidity represents the validity of a fork choice node. +type ForkChoiceNodeValidity uint64 + +const ( + // ForkChoiceNodeValidityUnknown is an unknown fork choice node. + ForkChoiceNodeValidityUnknown ForkChoiceNodeValidity = iota + // ForkChoiceNodeValidityInvalid is an invalid fork choice node. + ForkChoiceNodeValidityInvalid + // ForkChoiceNodeValidityValid is a valid fork choice node. + ForkChoiceNodeValidityValid + // ForkChoiceNodeValidityOptimistic is an optimistic fork choice node. + ForkChoiceNodeValidityOptimistic +) + +var ForkChoiceNodeValidityStrings = [...]string{ + "unknown", + "invalid", + "valid", + "optimistic", +} + +func ForkChoiceNodeValidityFromString(input string) (ForkChoiceNodeValidity, error) { + switch strings.ToLower(string(input)) { + case "invalid": + return ForkChoiceNodeValidityInvalid, nil + case "valid": + return ForkChoiceNodeValidityValid, nil + case "optimistic": + return ForkChoiceNodeValidityOptimistic, nil + default: + return ForkChoiceNodeValidityUnknown, fmt.Errorf("unrecognised fork choice validity: %s", string(input)) + } +} + +// MarshalJSON implements json.Marshaler. +func (d *ForkChoiceNodeValidity) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("%q", ForkChoiceNodeValidityStrings[*d])), nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (d *ForkChoiceNodeValidity) UnmarshalJSON(input []byte) error { + var err error + + inputString := strings.Trim(string(input), "\"") + if *d, err = ForkChoiceNodeValidityFromString(inputString); err != nil { + return err + } + + return nil +} + +// String returns a string representation of the ForkChoiceNodeValidity. +func (d ForkChoiceNodeValidity) String() string { + if int(d) >= len(ForkChoiceNodeValidityStrings) { + return "unknown" + } + return ForkChoiceNodeValidityStrings[d] +} + +type ForkChoiceNode struct { + // Slot is the slot of the node. + Slot phase0.Slot + // BlockRoot is the block root of the node. + BlockRoot phase0.Root + // ParentRoot is the parent root of the node. + ParentRoot phase0.Root + // JustifiedEpcih is the justified epoch of the node. + JustifiedEpoch phase0.Epoch + // FinalizedEpoch is the finalized epoch of the node. + FinalizedEpoch phase0.Epoch + // Weight is the weight of the node. + Weight uint64 + // Validity is the validity of the node. + Validity ForkChoiceNodeValidity + // ExecutiionBlockHash is the execution block hash of the node. + ExecutionBlockHash phase0.Root + // ExtraData is the extra data of the node. + ExtraData map[string]interface{} +} + +// forkChoiceNodeJSON is the json representation of the struct. +type forkChoiceNodeJSON struct { + Slot string `json:"slot"` + BlockRoot string `json:"block_root"` + ParentRoot string `json:"parent_root"` + JustifiedEpoch string `json:"justified_epoch"` + FinalizedEpoch string `json:"finalized_epoch"` + Weight string `json:"weight"` + Validity string `json:"validity"` + ExecutionBlockHash string `json:"execution_block_hash"` + ExtraData map[string]interface{} `json:"extra_data,omitempty"` +} + +// MarshalJSON implements json.Marshaler. +func (f *ForkChoiceNode) MarshalJSON() ([]byte, error) { + return json.Marshal(&forkChoiceNodeJSON{ + Slot: fmt.Sprintf("%d", f.Slot), + BlockRoot: fmt.Sprintf("%#x", f.BlockRoot), + ParentRoot: fmt.Sprintf("%#x", f.ParentRoot), + JustifiedEpoch: fmt.Sprintf("%d", f.JustifiedEpoch), + FinalizedEpoch: fmt.Sprintf("%d", f.FinalizedEpoch), + Weight: fmt.Sprintf("%d", f.Weight), + Validity: f.Validity.String(), + ExecutionBlockHash: fmt.Sprintf("%#x", f.ExecutionBlockHash), + ExtraData: f.ExtraData, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (f *ForkChoiceNode) UnmarshalJSON(input []byte) error { + var err error + + var forkChoiceNodeJSON forkChoiceNodeJSON + if err = json.Unmarshal(input, &forkChoiceNodeJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + slot, err := strconv.ParseUint(forkChoiceNodeJSON.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for slot: %s", forkChoiceNodeJSON.Slot)) + } + f.Slot = phase0.Slot(slot) + + blockRoot, err := hex.DecodeString(strings.TrimPrefix(forkChoiceNodeJSON.BlockRoot, "0x")) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for block root: %s", forkChoiceNodeJSON.BlockRoot)) + } + if len(blockRoot) != rootLength { + return fmt.Errorf("incorrect length %d for block root", len(blockRoot)) + } + copy(f.BlockRoot[:], blockRoot) + + parentRoot, err := hex.DecodeString(strings.TrimPrefix(forkChoiceNodeJSON.ParentRoot, "0x")) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for parent root: %s", forkChoiceNodeJSON.ParentRoot)) + } + copy(f.ParentRoot[:], parentRoot) + + justifiedEpoch, err := strconv.ParseUint(forkChoiceNodeJSON.JustifiedEpoch, 10, 64) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for justified epoch: %s", forkChoiceNodeJSON.JustifiedEpoch)) + } + f.JustifiedEpoch = phase0.Epoch(justifiedEpoch) + + finalizedEpoch, err := strconv.ParseUint(forkChoiceNodeJSON.FinalizedEpoch, 10, 64) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for finalized epoch: %s", forkChoiceNodeJSON.FinalizedEpoch)) + } + f.FinalizedEpoch = phase0.Epoch(finalizedEpoch) + + weight, err := strconv.ParseUint(forkChoiceNodeJSON.Weight, 10, 64) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for weight: %s", forkChoiceNodeJSON.Weight)) + } + f.Weight = weight + + validity, err := ForkChoiceNodeValidityFromString(forkChoiceNodeJSON.Validity) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for validity: %s", forkChoiceNodeJSON.Validity)) + } + f.Validity = validity + + executionBlockHash, err := hex.DecodeString(strings.TrimPrefix(forkChoiceNodeJSON.ExecutionBlockHash, "0x")) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid value for execution block hash: %s", forkChoiceNodeJSON.ExecutionBlockHash)) + } + if len(executionBlockHash) != rootLength { + return fmt.Errorf("incorrect length %d for execution block hash", len(executionBlockHash)) + } + copy(f.ExecutionBlockHash[:], executionBlockHash) + + f.ExtraData = forkChoiceNodeJSON.ExtraData + + return nil +} + +// String returns a string version of the structure. +func (f *ForkChoiceNode) String() string { + data, err := json.Marshal(f) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + return string(data) +} diff --git a/api/v1/forkchoice_test.go b/api/v1/forkchoice_test.go new file mode 100644 index 00000000..bf26c245 --- /dev/null +++ b/api/v1/forkchoice_test.go @@ -0,0 +1,159 @@ +package v1_test + +import ( + "encoding/json" + "testing" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/stretchr/testify/require" + "gotest.tools/assert" +) + +func TestForkChoiceJSON(t *testing.T) { + tests := []struct { + name string + input []byte + expected string + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.forkChoiceJSON", + }, + { + name: "Good", + input: []byte(`{"justified_checkpoint":{"epoch":"1","root":"0x0000000000000000000000000000000000000000000000000000000000000000"},"finalized_checkpoint":{"epoch":"2","root":"0x0100000000000000000000000000000000000000000000000000000000000000"},"fork_choice_nodes":[{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}]}`), + expected: `{"justified_checkpoint":{"epoch":"1","root":"0x0000000000000000000000000000000000000000000000000000000000000000"},"finalized_checkpoint":{"epoch":"2","root":"0x0100000000000000000000000000000000000000000000000000000000000000"},"fork_choice_nodes":[{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}]}`, + err: "", + }, + { + name: "JustifiedCheckpointMissing", + input: []byte(`{"finalized_checkpoint":{"epoch":"2","root":"0x0100000000000000000000000000000000000000000000000000000000000000"},"fork_choice_nodes":[]}`), + err: "justified checkpoint missing", + }, + { + name: "JustifiedCheckpointInvalid", + input: []byte(`{"finalized_checkpoint":{"epoch":"1","root":"0x0000000000000000000000000000000000000000000000000000000000000000"},"justified_checkpoint":-1,"fork_choice_nodes":[]}`), + err: "invalid JSON: invalid JSON: json: cannot unmarshal number into Go value of type phase0.checkpointJSON", + }, + { + name: "FinalizedCheckpointMissing", + input: []byte(`{"justified_checkpoint":{"epoch":"2","root":"0x0100000000000000000000000000000000000000000000000000000000000000"},"fork_choice_nodes":[]}`), + err: "finalized checkpoint missing", + }, + { + name: "FinalizedCheckpointInvalid", + input: []byte(`{"justified_checkpoint":{"epoch":"1","root":"0x0000000000000000000000000000000000000000000000000000000000000000"},"finalized_checkpoint":-1,"fork_choice_nodes":[]}`), + err: "invalid JSON: invalid JSON: json: cannot unmarshal number into Go value of type phase0.checkpointJSON", + }, + { + name: "ForkChoiceNodesInvalid", + input: []byte(`{"justified_checkpoint":{"epoch":"1","root":"0x0000000000000000000000000000000000000000000000000000000000000000"},"finalized_checkpoint":{"epoch":"2","root":"0x0100000000000000000000000000000000000000000000000000000000000000"},"fork_choice_nodes":-1}`), + expected: `{"justified_checkpoint":{"epoch":"1","root":"0x0000000000000000000000000000000000000000000000000000000000000000"},"finalized_checkpoint":{"epoch":"2","root":"0x0100000000000000000000000000000000000000000000000000000000000000"},"fork_choice_nodes":[]}`, + err: "invalid JSON: json: cannot unmarshal number into Go struct field forkChoiceJSON.fork_choice_nodes of type []*v1.ForkChoiceNode", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var fc api.ForkChoice + err := json.Unmarshal(test.input, &fc) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&fc) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), fc.String()) + } + }) + } +} + +func TestForkChoiceNodeJSON(t *testing.T) { + tests := []struct { + name string + input []byte + expected string + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.forkChoiceNodeJSON", + }, + { + name: "Good", + input: []byte(`{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`), + expected: `{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`, + err: "", + }, + { + name: "SlotInvalid", + input: []byte(`{"slot":1,"block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`), + err: "invalid JSON: json: cannot unmarshal number into Go struct field forkChoiceNodeJSON.slot of type string", + }, + { + name: "BlockRootInvalid", + input: []byte(`{"slot":"1962336","block_root":"","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`), + err: "incorrect length 0 for block root", + }, + { + name: "JustifiedEpochInvalid", + input: []byte(`{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":-1,"finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`), + err: "invalid JSON: json: cannot unmarshal number into Go struct field forkChoiceNodeJSON.justified_epoch of type string", + }, + { + name: "FinalizedEpochInvalid", + input: []byte(`{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":-1,"weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`), + err: "invalid JSON: json: cannot unmarshal number into Go struct field forkChoiceNodeJSON.finalized_epoch of type string", + }, + { + name: "WeightInvalid", + input: []byte(`{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":400,"validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`), + err: "invalid JSON: json: cannot unmarshal number into Go struct field forkChoiceNodeJSON.weight of type string", + }, + { + name: "ValidityInvalid", + input: []byte(`{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"NOT_A_VALID_VALIDITY","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`), + err: "invalid value for validity: NOT_A_VALID_VALIDITY: unrecognised fork choice validity: NOT_A_VALID_VALIDITY", + }, + { + name: "ExecutionBlockHashInvalid", + input: []byte(`{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"abc","extra_data":{"justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7","state_root":"0x4bcecf56081291ab95df1dba25b0f83343d38217e9cec198510b61f6f35afdb3","unrealised_finalized_epoch":"61321","unrealised_justified_epoch":"61322","unrealized_finalized_root":"0x57a41f26678190d3e319c19fe9f4ea3830c4b21710a1e1ae41adcc23d0f030a2","unrealized_justified_root":"0xdee6c83ee7dc6c0916a8d43c4e7cda93655857da0487f193a62852699e5c39f7"}}`), + err: "invalid value for execution block hash: abc: encoding/hex: odd length hex string", + }, + { + name: "ExtraDataMissing", + input: []byte(`{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9"}`), + expected: `{"slot":"1962336","block_root":"0x0f61e82f7b51f41fcd552cbdc64547bd9e1ba54b8404482732927645c2e13ec6","parent_root":"0xe399a2ee74cf0570b4f980772983bdc5cfbfde1f87f3ab395f2bee96103978c7","justified_epoch":"61322","finalized_epoch":"61321","weight":"57481550000000","validity":"valid","execution_block_hash":"0x06a0277e02eae44c332bcec82d6715c3113dddce427982014cf5f43432f479e9"}`, + err: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var fc api.ForkChoiceNode + err := json.Unmarshal(test.input, &fc) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&fc) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), fc.String()) + } + }) + } +} diff --git a/http/forkchoice.go b/http/forkchoice.go new file mode 100644 index 00000000..e3408953 --- /dev/null +++ b/http/forkchoice.go @@ -0,0 +1,40 @@ +// Copyright © 2020, 2021 Attestant Limited. +// 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 http + +import ( + "context" + "encoding/json" + + api "github.com/attestantio/go-eth2-client/api/v1" + "github.com/pkg/errors" +) + +// ForkChoice fetches all current fork choice context. +func (s *Service) ForkChoice(ctx context.Context) (*api.ForkChoice, error) { + respBodyReader, err := s.get(ctx, "/eth/v1/debug/fork_choice") + if err != nil { + return nil, errors.Wrap(err, "failed to request fork choice") + } + if respBodyReader == nil { + return nil, errors.New("failed to obtain fork choice") + } + + var forkChoice *api.ForkChoice + if err := json.NewDecoder(respBodyReader).Decode(&forkChoice); err != nil { + return nil, errors.Wrap(err, "failed to parse fork choice") + } + + return forkChoice, nil +} diff --git a/http/forkchoice_test.go b/http/forkchoice_test.go new file mode 100644 index 00000000..e29a3f3e --- /dev/null +++ b/http/forkchoice_test.go @@ -0,0 +1,54 @@ +// Copyright © 2020, 2021 Attestant Limited. +// 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 http_test + +import ( + "context" + "os" + "testing" + + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/http" + "github.com/stretchr/testify/require" +) + +func TestForkChoice(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tests := []struct { + name string + }{ + { + name: "Good", + }, + } + + service, err := http.New(ctx, + http.WithTimeout(timeout), + http.WithAddress(os.Getenv("HTTP_ADDRESS")), + ) + require.NoError(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fc, err := service.(client.ForkChoiceProvider).ForkChoice(ctx) + require.NoError(t, err) + require.NotNil(t, fc) + require.NotNil(t, fc.FinalizedCheckpoint) + require.NotNil(t, fc.JustifiedCheckpoint) + require.NotNil(t, fc.ForkChoiceNodes) + }) + } +} diff --git a/service.go b/service.go index a3d5ac74..afb0eb6e 100644 --- a/service.go +++ b/service.go @@ -297,6 +297,12 @@ type FinalityProvider interface { Finality(ctx context.Context, stateID string) (*apiv1.Finality, error) } +// ForkChoiceProvider is the interface for providing fork choice information. +type ForkChoiceProvider interface { + // Fork fetches all current fork choice context. + ForkChoice(ctx context.Context) (*apiv1.ForkChoice, error) +} + // ForkProvider is the interface for providing fork information. type ForkProvider interface { // Fork fetches fork information for the given state. diff --git a/testclients/erroring.go b/testclients/erroring.go index d3e321a2..915c9d8f 100644 --- a/testclients/erroring.go +++ b/testclients/erroring.go @@ -701,3 +701,15 @@ func (s *Erroring) BeaconStateRoot(ctx context.Context, stateID string) (*phase0 } return next.BeaconStateRoot(ctx, stateID) } + +// Fork fetches the node's current fork choice context. +func (s *Erroring) ForkChoice(ctx context.Context) (*apiv1.ForkChoice, error) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + next, isNext := s.next.(consensusclient.ForkChoiceProvider) + if !isNext { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + return next.ForkChoice(ctx) +} diff --git a/testclients/sleepy.go b/testclients/sleepy.go index 77a96e97..82050a65 100644 --- a/testclients/sleepy.go +++ b/testclients/sleepy.go @@ -457,6 +457,16 @@ func (s *Sleepy) GenesisTime(ctx context.Context) (time.Time, error) { return next.GenesisTime(ctx) } +// ForkChoice fetches the node's current fork choice context. +func (s *Sleepy) ForkChoice(ctx context.Context) (*apiv1.ForkChoice, error) { + s.sleep(ctx) + next, isNext := s.next.(consensusclient.ForkChoiceProvider) + if !isNext { + return nil, errors.New("next does not support this call") + } + return next.ForkChoice(ctx) +} + // BeaconBlockBlobs fetches the blobs given a block ID. func (s *Sleepy) BeaconBlockBlobs(ctx context.Context, blockID string) ([]*deneb.BlobSidecar, error) { s.sleep(ctx)