diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f42e118 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# for prometheus configs: +.prometheus + +# builds: +.builds +/solana_exporter \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..a02c9a2 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.22.3 diff --git a/Dockerfile b/Dockerfile index 8ce2836..8dafaae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.15 as builder +FROM golang:1.22 as builder COPY . /opt WORKDIR /opt diff --git a/cmd/solana_exporter/exporter.go b/cmd/solana_exporter/exporter.go index d3f9965..62a425f 100644 --- a/cmd/solana_exporter/exporter.go +++ b/cmd/solana_exporter/exporter.go @@ -11,14 +11,13 @@ import ( "k8s.io/klog/v2" ) -const ( - httpTimeout = 5 * time.Second -) var ( - rpcAddr = flag.String("rpcURI", "", "Solana RPC URI (including protocol and path)") - addr = flag.String("addr", ":8080", "Listen address") - votePubkey = flag.String("votepubkey", "", "Validator vote address (will only return results of this address)") + httpTimeout = 60 * time.Second + rpcAddr = flag.String("rpcURI", "", "Solana RPC URI (including protocol and path)") + addr = flag.String("addr", ":8080", "Listen address") + votePubkey = flag.String("votepubkey", "", "Validator vote address (will only return results of this address)") + httpTimeoutSecs = flag.Int("http_timeout", 60, "HTTP timeout in seconds") ) func init() { @@ -26,7 +25,8 @@ func init() { } type solanaCollector struct { - rpcClient *rpc.RPCClient + rpcClient rpc.Provider + slotPace time.Duration totalValidatorsDesc *prometheus.Desc validatorActivatedStake *prometheus.Desc @@ -36,9 +36,10 @@ type solanaCollector struct { solanaVersion *prometheus.Desc } -func NewSolanaCollector(rpcAddr string) *solanaCollector { +func createSolanaCollector(provider rpc.Provider, slotPace time.Duration) *solanaCollector { return &solanaCollector{ - rpcClient: rpc.NewRPCClient(rpcAddr), + rpcClient: provider, + slotPace: slotPace, totalValidatorsDesc: prometheus.NewDesc( "solana_active_validators", "Total number of active validators by state", @@ -66,18 +67,26 @@ func NewSolanaCollector(rpcAddr string) *solanaCollector { } } +func NewSolanaCollector(rpcAddr string) *solanaCollector { + return createSolanaCollector(rpc.NewRPCClient(rpcAddr), slotPacerSchedule) +} + func (c *solanaCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.totalValidatorsDesc ch <- c.solanaVersion + ch <- c.validatorActivatedStake + ch <- c.validatorLastVote + ch <- c.validatorRootSlot + ch <- c.validatorDelinquent } -func (c *solanaCollector) mustEmitMetrics(ch chan<- prometheus.Metric, response *rpc.GetVoteAccountsResponse) { +func (c *solanaCollector) mustEmitMetrics(ch chan<- prometheus.Metric, response *rpc.VoteAccounts) { ch <- prometheus.MustNewConstMetric(c.totalValidatorsDesc, prometheus.GaugeValue, - float64(len(response.Result.Delinquent)), "delinquent") + float64(len(response.Delinquent)), "delinquent") ch <- prometheus.MustNewConstMetric(c.totalValidatorsDesc, prometheus.GaugeValue, - float64(len(response.Result.Current)), "current") + float64(len(response.Current)), "current") - for _, account := range append(response.Result.Current, response.Result.Delinquent...) { + for _, account := range append(response.Current, response.Delinquent...) { ch <- prometheus.MustNewConstMetric(c.validatorActivatedStake, prometheus.GaugeValue, float64(account.ActivatedStake), account.VotePubkey, account.NodePubkey) ch <- prometheus.MustNewConstMetric(c.validatorLastVote, prometheus.GaugeValue, @@ -85,11 +94,11 @@ func (c *solanaCollector) mustEmitMetrics(ch chan<- prometheus.Metric, response ch <- prometheus.MustNewConstMetric(c.validatorRootSlot, prometheus.GaugeValue, float64(account.RootSlot), account.VotePubkey, account.NodePubkey) } - for _, account := range response.Result.Current { + for _, account := range response.Current { ch <- prometheus.MustNewConstMetric(c.validatorDelinquent, prometheus.GaugeValue, 0, account.VotePubkey, account.NodePubkey) } - for _, account := range response.Result.Delinquent { + for _, account := range response.Delinquent { ch <- prometheus.MustNewConstMetric(c.validatorDelinquent, prometheus.GaugeValue, 1, account.VotePubkey, account.NodePubkey) } @@ -120,7 +129,7 @@ func (c *solanaCollector) Collect(ch chan<- prometheus.Metric) { if err != nil { ch <- prometheus.NewInvalidMetric(c.solanaVersion, err) } else { - ch <- prometheus.MustNewConstMetric(c.solanaVersion, prometheus.GaugeValue, 1, *version) + ch <- prometheus.MustNewConstMetric(c.solanaVersion, prometheus.GaugeValue, 1, version) } } @@ -131,9 +140,11 @@ func main() { klog.Fatal("Please specify -rpcURI") } + httpTimeout = time.Duration(*httpTimeoutSecs) * time.Second + collector := NewSolanaCollector(*rpcAddr) - go collector.WatchSlots() + go collector.WatchSlots(context.Background()) prometheus.MustRegister(collector) http.Handle("/metrics", promhttp.Handler()) diff --git a/cmd/solana_exporter/exporter_test.go b/cmd/solana_exporter/exporter_test.go new file mode 100644 index 0000000..d638c62 --- /dev/null +++ b/cmd/solana_exporter/exporter_test.go @@ -0,0 +1,544 @@ +package main + +import ( + "bytes" + "context" + "github.com/certusone/solana_exporter/pkg/rpc" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "math/rand" + "regexp" + "testing" + "time" +) + +type ( + staticRPCClient struct{} + dynamicRPCClient struct { + Slot int + BlockHeight int + Epoch int + EpochSize int + SlotTime time.Duration + TransactionCount int + Version string + SlotInfos map[int]slotInfo + LeaderIndex int + ValidatorInfos map[string]validatorInfo + } + slotInfo struct { + leader string + blockProduced bool + } + validatorInfo struct { + Stake int + LastVote int + Commission int + Delinquent bool + } +) + +var ( + identities = []string{"aaa", "bbb", "ccc"} + identityVotes = map[string]string{"aaa": "AAA", "bbb": "BBB", "ccc": "CCC"} + nv = len(identities) + staticEpochInfo = rpc.EpochInfo{ + AbsoluteSlot: 166598, + BlockHeight: 166500, + Epoch: 27, + SlotIndex: 2790, + SlotsInEpoch: 8192, + TransactionCount: 22661093, + } + staticBlockProduction = rpc.BlockProduction{ + FirstSlot: 100000000, + LastSlot: 200000000, + Hosts: map[string]rpc.BlockProductionPerHost{ + "bbb": {LeaderSlots: 40000000, BlocksProduced: 36000000}, + "ccc": {LeaderSlots: 30000000, BlocksProduced: 29600000}, + "aaa": {LeaderSlots: 30000000, BlocksProduced: 10000000}, + }, + } + staticVoteAccounts = rpc.VoteAccounts{ + Current: []rpc.VoteAccount{ + { + ActivatedStake: 42, + Commission: 0, + EpochCredits: [][]int{ + {1, 64, 0}, + {2, 192, 64}, + }, + EpochVoteAccount: true, + LastVote: 147, + NodePubkey: "bbb", + RootSlot: 18, + VotePubkey: "BBB", + }, + { + ActivatedStake: 43, + Commission: 1, + EpochCredits: [][]int{ + {2, 65, 1}, + {3, 193, 65}, + }, + EpochVoteAccount: true, + LastVote: 148, + NodePubkey: "ccc", + RootSlot: 19, + VotePubkey: "CCC", + }, + }, + Delinquent: []rpc.VoteAccount{ + { + ActivatedStake: 49, + Commission: 2, + EpochCredits: [][]int{ + {10, 594, 6}, + {9, 98, 4}, + }, + EpochVoteAccount: true, + LastVote: 92, + NodePubkey: "aaa", + RootSlot: 3, + VotePubkey: "AAA", + }, + }, + } +) + +/* +===== STATIC CLIENT =====: +*/ + +//goland:noinspection GoUnusedParameter +func (c *staticRPCClient) GetEpochInfo(ctx context.Context, commitment rpc.Commitment) (*rpc.EpochInfo, error) { + return &staticEpochInfo, nil +} + +//goland:noinspection GoUnusedParameter +func (c *staticRPCClient) GetSlot(ctx context.Context) (int64, error) { + return staticEpochInfo.AbsoluteSlot, nil +} + +//goland:noinspection GoUnusedParameter +func (c *staticRPCClient) GetVersion(ctx context.Context) (string, error) { + version := "1.16.7" + return version, nil +} + +//goland:noinspection GoUnusedParameter +func (c *staticRPCClient) GetVoteAccounts( + ctx context.Context, + params []interface{}, +) (*rpc.VoteAccounts, error) { + return &staticVoteAccounts, nil +} + +//goland:noinspection GoUnusedParameter +func (c *staticRPCClient) GetBlockProduction( + ctx context.Context, + firstSlot *int64, + lastSlot *int64, +) (*rpc.BlockProduction, error) { + return &staticBlockProduction, nil +} + +/* +===== DYNAMIC CLIENT =====: +*/ + +func newDynamicRPCClient() *dynamicRPCClient { + validatorInfos := make(map[string]validatorInfo) + for identity := range identityVotes { + validatorInfos[identity] = validatorInfo{ + Stake: 1_000_000, + LastVote: 0, + Commission: 5, + Delinquent: false, + } + } + return &dynamicRPCClient{ + Slot: 0, + BlockHeight: 0, + Epoch: 0, + EpochSize: 20, + SlotTime: 100 * time.Millisecond, + TransactionCount: 0, + Version: "v1.0.0", + SlotInfos: map[int]slotInfo{}, + LeaderIndex: 0, + ValidatorInfos: validatorInfos, + } +} + +func (c *dynamicRPCClient) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + + default: + c.newSlot() + // add 5% noise to the slot time: + noiseRange := float64(c.SlotTime) * 0.05 + noise := (rand.Float64()*2 - 1) * noiseRange + time.Sleep(c.SlotTime + time.Duration(noise)) + } + } +} + +func (c *dynamicRPCClient) newSlot() { + c.Slot++ + + // leader changes every 4 slots + if c.Slot%4 == 0 { + c.LeaderIndex = (c.LeaderIndex + 1) % nv + } + + if c.Slot%c.EpochSize == 0 { + c.Epoch++ + } + + // assume 90% chance of block produced: + blockProduced := rand.Intn(100) <= 90 + // add slot info: + c.SlotInfos[c.Slot] = slotInfo{ + leader: identities[c.LeaderIndex], + blockProduced: blockProduced, + } + + if blockProduced { + c.BlockHeight++ + // only add some transactions if a block was produced + c.TransactionCount += rand.Intn(10) + // assume both other validators voted + for i := 1; i < 3; i++ { + otherValidatorIndex := (c.LeaderIndex + i) % nv + identity := identities[otherValidatorIndex] + info := c.ValidatorInfos[identity] + info.LastVote = c.Slot + c.ValidatorInfos[identity] = info + } + } +} + +func (c *dynamicRPCClient) UpdateVersion(version string) { + c.Version = version +} + +func (c *dynamicRPCClient) UpdateStake(validator string, amount int) { + info := c.ValidatorInfos[validator] + info.Stake = amount + c.ValidatorInfos[validator] = info +} + +func (c *dynamicRPCClient) UpdateCommission(validator string, newCommission int) { + info := c.ValidatorInfos[validator] + info.Commission = newCommission + c.ValidatorInfos[validator] = info +} + +func (c *dynamicRPCClient) UpdateDelinquency(validator string, newDelinquent bool) { + info := c.ValidatorInfos[validator] + info.Delinquent = newDelinquent + c.ValidatorInfos[validator] = info +} + +//goland:noinspection GoUnusedParameter +func (c *dynamicRPCClient) GetEpochInfo(ctx context.Context, commitment rpc.Commitment) (*rpc.EpochInfo, error) { + return &rpc.EpochInfo{ + AbsoluteSlot: int64(c.Slot), + BlockHeight: int64(c.BlockHeight), + Epoch: int64(c.Epoch), + SlotIndex: int64(c.Slot % c.EpochSize), + SlotsInEpoch: int64(c.EpochSize), + TransactionCount: int64(c.TransactionCount), + }, nil +} + +//goland:noinspection GoUnusedParameter +func (c *dynamicRPCClient) GetSlot(ctx context.Context) (int64, error) { + return int64(c.Slot), nil +} + +//goland:noinspection GoUnusedParameter +func (c *dynamicRPCClient) GetVersion(ctx context.Context) (string, error) { + return c.Version, nil +} + +//goland:noinspection GoUnusedParameter +func (c *dynamicRPCClient) GetVoteAccounts( + ctx context.Context, + params []interface{}, +) (*rpc.VoteAccounts, error) { + var currentVoteAccounts, delinquentVoteAccounts []rpc.VoteAccount + for identity, vote := range identityVotes { + info := c.ValidatorInfos[identity] + voteAccount := rpc.VoteAccount{ + ActivatedStake: int64(info.Stake), + Commission: info.Commission, + EpochCredits: [][]int{}, + EpochVoteAccount: true, + LastVote: info.LastVote, + NodePubkey: identity, + RootSlot: 0, + VotePubkey: vote, + } + if info.Delinquent { + delinquentVoteAccounts = append(delinquentVoteAccounts, voteAccount) + } else { + currentVoteAccounts = append(currentVoteAccounts, voteAccount) + } + } + return &rpc.VoteAccounts{Current: currentVoteAccounts, Delinquent: delinquentVoteAccounts}, nil +} + +//goland:noinspection GoUnusedParameter +func (c *dynamicRPCClient) GetBlockProduction( + ctx context.Context, + firstSlot *int64, + lastSlot *int64, +) (*rpc.BlockProduction, error) { + hostProduction := make(map[string]rpc.BlockProductionPerHost) + for _, identity := range identities { + hostProduction[identity] = rpc.BlockProductionPerHost{LeaderSlots: 0, BlocksProduced: 0} + } + for i := *firstSlot; i <= *lastSlot; i++ { + info := c.SlotInfos[int(i)] + hp := hostProduction[info.leader] + hp.LeaderSlots++ + if info.blockProduced { + hp.BlocksProduced++ + } + hostProduction[info.leader] = hp + } + production := rpc.BlockProduction{ + FirstSlot: *firstSlot, + LastSlot: *lastSlot, + Hosts: hostProduction, + } + return &production, nil +} + +/* +===== OTHER TEST UTILITIES =====: +*/ + +// extractName takes a Prometheus descriptor and returns its name +func extractName(desc *prometheus.Desc) string { + // Get the string representation of the descriptor + descString := desc.String() + // Use regex to extract the metric name and help message from the descriptor string + reName := regexp.MustCompile(`fqName: "([^"]+)"`) + nameMatch := reName.FindStringSubmatch(descString) + + var name string + if len(nameMatch) > 1 { + name = nameMatch[1] + } + + return name +} + +type collectionTest struct { + Name string + ExpectedResponse string +} + +func runCollectionTests(t *testing.T, collector prometheus.Collector, testCases []collectionTest) { + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + err := testutil.CollectAndCompare(collector, bytes.NewBufferString(test.ExpectedResponse), test.Name) + assert.Nilf(t, err, "unexpected collecting result for %s: \n%s", test.Name, err) + }) + } +} + +func TestSolanaCollector_Collect_Static(t *testing.T) { + collector := createSolanaCollector( + &staticRPCClient{}, + slotPacerSchedule, + ) + prometheus.NewPedanticRegistry().MustRegister(collector) + + testCases := []collectionTest{ + { + Name: "solana_active_validators", + ExpectedResponse: ` +# HELP solana_active_validators Total number of active validators by state +# TYPE solana_active_validators gauge +solana_active_validators{state="current"} 2 +solana_active_validators{state="delinquent"} 1 +`, + }, + { + Name: "solana_validator_activated_stake", + ExpectedResponse: ` +# HELP solana_validator_activated_stake Activated stake per validator +# TYPE solana_validator_activated_stake gauge +solana_validator_activated_stake{nodekey="aaa",pubkey="AAA"} 49 +solana_validator_activated_stake{nodekey="bbb",pubkey="BBB"} 42 +solana_validator_activated_stake{nodekey="ccc",pubkey="CCC"} 43 +`, + }, + { + Name: "solana_validator_last_vote", + ExpectedResponse: ` +# HELP solana_validator_last_vote Last voted slot per validator +# TYPE solana_validator_last_vote gauge +solana_validator_last_vote{nodekey="aaa",pubkey="AAA"} 92 +solana_validator_last_vote{nodekey="bbb",pubkey="BBB"} 147 +solana_validator_last_vote{nodekey="ccc",pubkey="CCC"} 148 +`, + }, + { + Name: "solana_validator_root_slot", + ExpectedResponse: ` +# HELP solana_validator_root_slot Root slot per validator +# TYPE solana_validator_root_slot gauge +solana_validator_root_slot{nodekey="aaa",pubkey="AAA"} 3 +solana_validator_root_slot{nodekey="bbb",pubkey="BBB"} 18 +solana_validator_root_slot{nodekey="ccc",pubkey="CCC"} 19 +`, + }, + { + Name: "solana_validator_delinquent", + ExpectedResponse: ` +# HELP solana_validator_delinquent Whether a validator is delinquent +# TYPE solana_validator_delinquent gauge +solana_validator_delinquent{nodekey="aaa",pubkey="AAA"} 1 +solana_validator_delinquent{nodekey="bbb",pubkey="BBB"} 0 +solana_validator_delinquent{nodekey="ccc",pubkey="CCC"} 0 +`, + }, + { + Name: "solana_node_version", + ExpectedResponse: ` +# HELP solana_node_version Node version of solana +# TYPE solana_node_version gauge +solana_node_version{version="1.16.7"} 1 +`, + }, + } + + runCollectionTests(t, collector, testCases) +} + +func TestSolanaCollector_Collect_Dynamic(t *testing.T) { + client := newDynamicRPCClient() + collector := createSolanaCollector(client, slotPacerSchedule) + prometheus.NewPedanticRegistry().MustRegister(collector) + + // start off by testing initial state: + testCases := []collectionTest{ + { + Name: "solana_active_validators", + ExpectedResponse: ` +# HELP solana_active_validators Total number of active validators by state +# TYPE solana_active_validators gauge +solana_active_validators{state="current"} 3 +solana_active_validators{state="delinquent"} 0 +`, + }, + { + Name: "solana_validator_activated_stake", + ExpectedResponse: ` +# HELP solana_validator_activated_stake Activated stake per validator +# TYPE solana_validator_activated_stake gauge +solana_validator_activated_stake{nodekey="aaa",pubkey="AAA"} 1000000 +solana_validator_activated_stake{nodekey="bbb",pubkey="BBB"} 1000000 +solana_validator_activated_stake{nodekey="ccc",pubkey="CCC"} 1000000 +`, + }, + { + Name: "solana_validator_root_slot", + ExpectedResponse: ` +# HELP solana_validator_root_slot Root slot per validator +# TYPE solana_validator_root_slot gauge +solana_validator_root_slot{nodekey="aaa",pubkey="AAA"} 0 +solana_validator_root_slot{nodekey="bbb",pubkey="BBB"} 0 +solana_validator_root_slot{nodekey="ccc",pubkey="CCC"} 0 +`, + }, + { + Name: "solana_validator_delinquent", + ExpectedResponse: ` +# HELP solana_validator_delinquent Whether a validator is delinquent +# TYPE solana_validator_delinquent gauge +solana_validator_delinquent{nodekey="aaa",pubkey="AAA"} 0 +solana_validator_delinquent{nodekey="bbb",pubkey="BBB"} 0 +solana_validator_delinquent{nodekey="ccc",pubkey="CCC"} 0 +`, + }, + { + Name: "solana_node_version", + ExpectedResponse: ` +# HELP solana_node_version Node version of solana +# TYPE solana_node_version gauge +solana_node_version{version="v1.0.0"} 1 +`, + }, + } + + runCollectionTests(t, collector, testCases) + + // now make some changes: + client.UpdateStake("aaa", 2_000_000) + client.UpdateStake("bbb", 500_000) + client.UpdateDelinquency("ccc", true) + client.UpdateVersion("v1.2.3") + + // now test the final state + testCases = []collectionTest{ + { + Name: "solana_active_validators", + ExpectedResponse: ` +# HELP solana_active_validators Total number of active validators by state +# TYPE solana_active_validators gauge +solana_active_validators{state="current"} 2 +solana_active_validators{state="delinquent"} 1 +`, + }, + { + Name: "solana_validator_activated_stake", + ExpectedResponse: ` +# HELP solana_validator_activated_stake Activated stake per validator +# TYPE solana_validator_activated_stake gauge +solana_validator_activated_stake{nodekey="aaa",pubkey="AAA"} 2000000 +solana_validator_activated_stake{nodekey="bbb",pubkey="BBB"} 500000 +solana_validator_activated_stake{nodekey="ccc",pubkey="CCC"} 1000000 +`, + }, + { + Name: "solana_validator_root_slot", + ExpectedResponse: ` +# HELP solana_validator_root_slot Root slot per validator +# TYPE solana_validator_root_slot gauge +solana_validator_root_slot{nodekey="aaa",pubkey="AAA"} 0 +solana_validator_root_slot{nodekey="bbb",pubkey="BBB"} 0 +solana_validator_root_slot{nodekey="ccc",pubkey="CCC"} 0 +`, + }, + { + Name: "solana_validator_delinquent", + ExpectedResponse: ` +# HELP solana_validator_delinquent Whether a validator is delinquent +# TYPE solana_validator_delinquent gauge +solana_validator_delinquent{nodekey="aaa",pubkey="AAA"} 0 +solana_validator_delinquent{nodekey="bbb",pubkey="BBB"} 0 +solana_validator_delinquent{nodekey="ccc",pubkey="CCC"} 1 +`, + }, + { + Name: "solana_node_version", + ExpectedResponse: ` +# HELP solana_node_version Node version of solana +# TYPE solana_node_version gauge +solana_node_version{version="v1.2.3"} 1 +`, + }, + } + + runCollectionTests(t, collector, testCases) +} diff --git a/cmd/solana_exporter/slots.go b/cmd/solana_exporter/slots.go index 4647066..fdf4d58 100644 --- a/cmd/solana_exporter/slots.go +++ b/cmd/solana_exporter/slots.go @@ -3,10 +3,11 @@ package main import ( "context" "fmt" + "time" + "github.com/certusone/solana_exporter/pkg/rpc" "github.com/prometheus/client_golang/prometheus" "k8s.io/klog/v2" - "time" ) const ( @@ -42,9 +43,16 @@ var ( leaderSlotsTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "solana_leader_slots_total", - Help: "Number of leader slots per leader, grouped by skip status (max confirmation)", + Help: "(DEPRECATED) Number of leader slots per leader, grouped by skip status", }, []string{"status", "nodekey"}) + + leaderSlotsByEpoch = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "solana_leader_slots_by_epoch", + Help: "Number of leader slots per leader, grouped by skip status and epoch", + }, + []string{"status", "nodekey", "epoch"}) ) func init() { @@ -54,133 +62,183 @@ func init() { prometheus.MustRegister(epochFirstSlot) prometheus.MustRegister(epochLastSlot) prometheus.MustRegister(leaderSlotsTotal) + prometheus.MustRegister(leaderSlotsByEpoch) } -func (c *solanaCollector) WatchSlots() { - var ( - // Current mapping of relative slot numbers to leader public keys. - epochSlots map[int64]string - // Current epoch number corresponding to epochSlots. - epochNumber int64 - // Last slot number we generated ticks for. - watermark int64 - ) +func (c *solanaCollector) WatchSlots(ctx context.Context) { + // Get current slot height and epoch info + ctx_, cancel := context.WithTimeout(context.Background(), httpTimeout) + info, err := c.rpcClient.GetEpochInfo(ctx_, rpc.CommitmentMax) + if err != nil { + klog.Fatalf("failed to fetch epoch info, bailing out: %v", err) + } + cancel() + + totalTransactionsTotal.Set(float64(info.TransactionCount)) + confirmedSlotHeight.Set(float64(info.AbsoluteSlot)) - ticker := time.NewTicker(slotPacerSchedule) + // watermark is the last slot number we generated ticks for. Set it to the current offset on startup (we do not backfill slots we missed at startup) + watermark := info.AbsoluteSlot + currentEpoch, firstSlot, lastSlot := getEpochBounds(info) + currentEpochNumber.Set(float64(currentEpoch)) + epochFirstSlot.Set(float64(firstSlot)) + epochLastSlot.Set(float64(lastSlot)) + + klog.Infof("Starting at slot %d in epoch %d (%d-%d)", firstSlot, currentEpoch, firstSlot, lastSlot) + _, err = updateCounters(c.rpcClient, currentEpoch, watermark, &lastSlot) + if err != nil { + klog.Error(err) + } + ticker := time.NewTicker(c.slotPace) for { - <-ticker.C + select { + case <-ctx.Done(): + klog.Infof("Stopping WatchSlots() at slot %v", watermark) + return - // Get current slot height and epoch info - ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) - info, err := c.rpcClient.GetEpochInfo(ctx, rpc.CommitmentMax) - if err != nil { - klog.Infof("failed to fetch info info, retrying: %v", err) + default: + <-ticker.C + + // Get current slot height and epoch info + ctx_, cancel := context.WithTimeout(context.Background(), httpTimeout) + info, err := c.rpcClient.GetEpochInfo(ctx_, rpc.CommitmentMax) + if err != nil { + klog.Warningf("failed to fetch epoch info, retrying: %v", err) + cancel() + continue + } cancel() - continue - } - cancel() - // Calculate first and last slot in epoch. - firstSlot := info.AbsoluteSlot - info.SlotIndex - lastSlot := firstSlot + info.SlotsInEpoch + if watermark == info.AbsoluteSlot { + klog.V(2).Infof("slot has not advanced at %d, skipping", info.AbsoluteSlot) + continue + } - totalTransactionsTotal.Set(float64(info.TransactionCount)) - confirmedSlotHeight.Set(float64(info.AbsoluteSlot)) - currentEpochNumber.Set(float64(info.Epoch)) - epochFirstSlot.Set(float64(firstSlot)) - epochLastSlot.Set(float64(lastSlot)) + if currentEpoch != info.Epoch { + klog.Infof( + "changing epoch from %d to %d. Watermark: %d, lastSlot: %d", + currentEpoch, + info.Epoch, + watermark, + lastSlot, + ) + + last, err := updateCounters(c.rpcClient, currentEpoch, watermark, &lastSlot) + if err != nil { + klog.Error(err) + continue + } - // Check whether we need to fetch a new leader schedule - if epochNumber != info.Epoch { - klog.Infof("new epoch at slot %d: %d (previous: %d)", firstSlot, info.Epoch, epochNumber) + klog.Infof( + "counters updated to slot %d (+%d), epoch %d (slots %d-%d, %d remaining)", + last, + last-watermark, + currentEpoch, + firstSlot, + lastSlot, + lastSlot-last, + ) + + watermark = last + currentEpoch, firstSlot, lastSlot = getEpochBounds(info) + + currentEpochNumber.Set(float64(currentEpoch)) + epochFirstSlot.Set(float64(firstSlot)) + epochLastSlot.Set(float64(lastSlot)) + } - epochSlots, err = c.fetchLeaderSlots(firstSlot) + totalTransactionsTotal.Set(float64(info.TransactionCount)) + confirmedSlotHeight.Set(float64(info.AbsoluteSlot)) + + last, err := updateCounters(c.rpcClient, currentEpoch, watermark, nil) if err != nil { - klog.Errorf("failed to request leader schedule, retrying: %v", err) + klog.Info(err) continue } - klog.V(1).Infof("%d leader slots in epoch %d", len(epochSlots), info.Epoch) - - epochNumber = info.Epoch - klog.V(1).Infof("we're still in epoch %d, not fetching leader schedule", info.Epoch) - - // Reset watermark to current offset on new epoch (we do not backfill slots we missed at startup) - watermark = info.SlotIndex - } else if watermark == info.SlotIndex { - klog.Infof("slot has not advanced at %d, skipping", info.AbsoluteSlot) - continue + klog.Infof( + "counters updated to slot %d (offset %d, +%d), epoch %d (slots %d-%d, %d remaining)", + last, + info.SlotIndex, + last-watermark, + currentEpoch, + firstSlot, + lastSlot, + lastSlot-last, + ) + + watermark = last } + } +} - klog.Infof("confirmed slot %d (offset %d, +%d), epoch %d (from slot %d to %d, %d remaining)", - info.AbsoluteSlot, info.SlotIndex, info.SlotIndex-watermark, info.Epoch, firstSlot, lastSlot, lastSlot-info.AbsoluteSlot) +// getEpochBounds returns the epoch, first slot and last slot given an EpochInfo struct +func getEpochBounds(info *rpc.EpochInfo) (int64, int64, int64) { + firstSlot := info.AbsoluteSlot - info.SlotIndex + lastSlot := firstSlot + info.SlotsInEpoch - // Get list of confirmed blocks since the last request. This is totally undocumented, but the result won't - // contain missed blocks, allowing us to figure out block production success rate. - rangeStart := firstSlot + watermark - rangeEnd := firstSlot + info.SlotIndex - 1 + return info.Epoch, firstSlot, lastSlot +} - ctx, cancel = context.WithTimeout(context.Background(), httpTimeout) - cfm, err := c.rpcClient.GetConfirmedBlocks(ctx, rangeStart, rangeEnd) - if err != nil { - klog.Errorf("failed to request confirmed blocks at %d, retrying: %v", watermark, err) - cancel() - continue - } - cancel() - - klog.V(1).Infof("confirmed blocks: %d -> %d: %v", rangeStart, rangeEnd, cfm) - - // Figure out leaders for each block in range - for i := watermark; i < info.SlotIndex; i++ { - leader, ok := epochSlots[i] - abs := firstSlot + i - if !ok { - // This cannot happen with a well-behaved node and is a programming error in either Solana or the exporter. - klog.Fatalf("slot %d (offset %d) missing from epoch %d leader schedule", - abs, i, info.Epoch) - } +func updateCounters(c rpc.Provider, epoch, firstSlot int64, lastSlotOpt *int64) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() - // Check if block was included in getConfirmedBlocks output, otherwise, it was skipped. - var present bool - for _, s := range cfm { - if abs == s { - present = true - } - } + var lastSlot int64 + var err error - var skipped string - var label string - if present { - skipped = "(valid)" - label = "valid" - } else { - skipped = "(SKIPPED)" - label = "skipped" - } + if lastSlotOpt == nil { + lastSlot, err = c.GetSlot(ctx) - leaderSlotsTotal.With(prometheus.Labels{"status": label, "nodekey": leader}).Add(1) - klog.V(1).Infof("slot %d (offset %d) with leader %s %s", abs, i, leader, skipped) + if err != nil { + return 0, fmt.Errorf("Error while getting the last slot: %v", err) } + klog.V(2).Infof("Setting lastSlot to %d", lastSlot) + } else { + lastSlot = *lastSlotOpt + klog.Infof("Got lastSlot: %d", lastSlot) + } - watermark = info.SlotIndex + if firstSlot > lastSlot { + return 0, fmt.Errorf( + "In epoch %d, firstSlot (%d) > lastSlot (%d). This should not happen. Not updating.", + epoch, + firstSlot, + lastSlot, + ) } -} -func (c *solanaCollector) fetchLeaderSlots(epochSlot int64) (map[int64]string, error) { - sch, err := c.rpcClient.GetLeaderSchedule(context.Background(), epochSlot) + ctx, cancel = context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + blockProduction, err := c.GetBlockProduction(ctx, &firstSlot, &lastSlot) if err != nil { - return nil, fmt.Errorf("failed to get leader schedule: %w", err) + return 0, fmt.Errorf("failed to fetch block production, retrying: %v", err) } - slots := make(map[int64]string) + for host, prod := range blockProduction.Hosts { + valid := float64(prod.BlocksProduced) + skipped := float64(prod.LeaderSlots - prod.BlocksProduced) - for pk, sch := range sch { - for _, i := range sch { - slots[int64(i)] = pk - } + epochStr := fmt.Sprintf("%d", epoch) + + leaderSlotsTotal.WithLabelValues("valid", host).Add(valid) + leaderSlotsTotal.WithLabelValues("skipped", host).Add(skipped) + + leaderSlotsByEpoch.WithLabelValues("valid", host, epochStr).Add(valid) + leaderSlotsByEpoch.WithLabelValues("skipped", host, epochStr).Add(skipped) + + klog.V(1).Infof( + "Epoch %s, slots %d-%d, node %s: Added %d valid and %d skipped slots", + epochStr, + firstSlot, + lastSlot, + host, + prod.BlocksProduced, + prod.LeaderSlots-prod.BlocksProduced, + ) } - return slots, err + return lastSlot, nil } diff --git a/cmd/solana_exporter/slots_test.go b/cmd/solana_exporter/slots_test.go new file mode 100644 index 0000000..db0cf5c --- /dev/null +++ b/cmd/solana_exporter/slots_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "context" + "fmt" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +type slotMetricValues struct { + SlotHeight float64 + TotalTransactions float64 + EpochNumber float64 + EpochFirstSlot float64 + EpochLastSlot float64 +} + +func getSlotMetricValues() slotMetricValues { + return slotMetricValues{ + SlotHeight: testutil.ToFloat64(confirmedSlotHeight), + TotalTransactions: testutil.ToFloat64(totalTransactionsTotal), + EpochNumber: testutil.ToFloat64(currentEpochNumber), + EpochFirstSlot: testutil.ToFloat64(epochFirstSlot), + EpochLastSlot: testutil.ToFloat64(epochLastSlot), + } +} + +func testBlockProductionMetric( + t *testing.T, + metric *prometheus.CounterVec, + host string, + status string, +) { + hostInfo := staticBlockProduction.Hosts[host] + // get expected value depending on status: + var expectedValue float64 + switch status { + case "valid": + expectedValue = float64(hostInfo.BlocksProduced) + case "skipped": + expectedValue = float64(hostInfo.LeaderSlots - hostInfo.BlocksProduced) + } + // get labels (leaderSlotsByEpoch requires an extra one) + labels := []string{status, host} + if metric == leaderSlotsByEpoch { + labels = append(labels, fmt.Sprintf("%d", staticEpochInfo.Epoch)) + } + // now we can do the assertion: + assert.Equalf( + t, + expectedValue, + testutil.ToFloat64(metric.WithLabelValues(labels...)), + "wrong value for block-production metric with labels: %s", + labels, + ) +} + +func assertSlotMetricsChangeCorrectly(t *testing.T, initial slotMetricValues, final slotMetricValues) { + // make sure that things have increased + assert.Greaterf( + t, + final.SlotHeight, + initial.SlotHeight, + "Slot has not increased! (%v -> %v)", + initial.SlotHeight, + final.SlotHeight, + ) + assert.Greaterf( + t, + final.TotalTransactions, + initial.TotalTransactions, + "Total transactions have not increased! (%v -> %v)", + initial.TotalTransactions, + final.TotalTransactions, + ) + assert.GreaterOrEqualf( + t, + final.EpochNumber, + initial.EpochNumber, + "Epoch number has decreased! (%v -> %v)", + initial.EpochNumber, + final.EpochNumber, + ) +} + +func TestSolanaCollector_WatchSlots_Static(t *testing.T) { + // reset metrics before running tests: + leaderSlotsTotal.Reset() + leaderSlotsByEpoch.Reset() + + collector := createSolanaCollector( + &staticRPCClient{}, + 100*time.Millisecond, + ) + prometheus.NewPedanticRegistry().MustRegister(collector) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go collector.WatchSlots(ctx) + time.Sleep(1 * time.Second) + + firstSlot := staticEpochInfo.AbsoluteSlot - staticEpochInfo.SlotIndex + lastSlot := firstSlot + staticEpochInfo.SlotsInEpoch + tests := []struct { + expectedValue float64 + metric prometheus.Gauge + }{ + {expectedValue: float64(staticEpochInfo.AbsoluteSlot), metric: confirmedSlotHeight}, + {expectedValue: float64(staticEpochInfo.TransactionCount), metric: totalTransactionsTotal}, + {expectedValue: float64(staticEpochInfo.Epoch), metric: currentEpochNumber}, + {expectedValue: float64(firstSlot), metric: epochFirstSlot}, + {expectedValue: float64(lastSlot), metric: epochLastSlot}, + } + + for _, testCase := range tests { + name := extractName(testCase.metric.Desc()) + t.Run(name, func(t *testing.T) { + assert.Equal(t, testCase.expectedValue, testutil.ToFloat64(testCase.metric)) + }) + } + + metrics := map[string]*prometheus.CounterVec{ + "solana_leader_slots_total": leaderSlotsTotal, + "solana_leader_slots_by_epoch": leaderSlotsByEpoch, + } + statuses := []string{"valid", "skipped"} + for name, metric := range metrics { + // subtest for each metric: + t.Run(name, func(t *testing.T) { + for _, status := range statuses { + // sub subtest for each status (as each one requires a different calc) + t.Run(status, func(t *testing.T) { + for _, identity := range identities { + testBlockProductionMetric(t, metric, identity, status) + } + }) + } + }) + } +} + +func TestSolanaCollector_WatchSlots_Dynamic(t *testing.T) { + // reset metrics before running tests: + leaderSlotsTotal.Reset() + leaderSlotsByEpoch.Reset() + + // create clients: + client := newDynamicRPCClient() + collector := createSolanaCollector(client, 300*time.Millisecond) + prometheus.NewPedanticRegistry().MustRegister(collector) + + // start client/collector and wait a bit: + runCtx, runCancel := context.WithCancel(context.Background()) + defer runCancel() + go client.Run(runCtx) + time.Sleep(time.Second) + + slotsCtx, slotsCancel := context.WithCancel(context.Background()) + defer slotsCancel() + go collector.WatchSlots(slotsCtx) + time.Sleep(time.Second) + + initial := getSlotMetricValues() + + // wait a bit: + var epochChanged bool + for i := 0; i < 5; i++ { + // wait a bit then get new metrics + time.Sleep(time.Second) + final := getSlotMetricValues() + + // make sure things are changing correctly: + assertSlotMetricsChangeCorrectly(t, initial, final) + + // sense check to make sure the exporter is not "ahead" of the client (due to double counting or whatever) + assert.LessOrEqualf( + t, + int(final.SlotHeight), + client.Slot, + "Exporter slot (%v) ahead of client slot (%v)!", + int(final.SlotHeight), + client.Slot, + ) + assert.LessOrEqualf( + t, + int(final.TotalTransactions), + client.TransactionCount, + "Exporter transaction count (%v) ahead of client transaction count (%v)!", + int(final.TotalTransactions), + client.TransactionCount, + ) + assert.LessOrEqualf( + t, + int(final.EpochNumber), + client.Epoch, + "Exporter epoch (%v) ahead of client epoch (%v)!", + int(final.EpochNumber), + client.Epoch, + ) + + // check if epoch changed + if final.EpochNumber > initial.EpochNumber { + epochChanged = true + } + + // make current final the new initial (for next iteration) + initial = final + } + + // epoch should have changed somewhere + assert.Truef(t, epochChanged, "Epoch has not changed!") +} diff --git a/go.mod b/go.mod index 2cf8bae..64bb422 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,24 @@ module github.com/certusone/solana_exporter -go 1.13 +go 1.22 require ( - github.com/prometheus/client_golang v1.4.0 - k8s.io/klog/v2 v2.4.0 + github.com/prometheus/client_golang v1.19.1 + github.com/stretchr/testify v1.9.0 + k8s.io/klog/v2 v2.120.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + golang.org/x/sys v0.17.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 525bb83..07ec9eb 100644 --- a/go.sum +++ b/go.sum @@ -1,93 +1,40 @@ -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logr/logr v0.2.0 h1:QvGt2nLcHH0WK9orKa+ppBPAxREcH364nPUedEpK0TY= -github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.4.0 h1:YVIb/fVcOTMSqtqZWSKnHpSLBxu8DKgxq8z6RuBZwqI= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.9.1 h1:KOMtN28tlbam3/7ZKEYKHhKoJZYYj3gMH4uc62x7X7U= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 h1:ywK/j/KkyTHcdyYSZNXGjMwgmDSfjglYZ3vStQ/gSCU= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -k8s.io/klog/v2 v2.4.0 h1:7+X0fUguPyrKEC4WjH8iGDg3laWgMo5tMnRTIGTTxGQ= -k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= diff --git a/hack/rpc.http b/hack/rpc.http deleted file mode 100644 index 6e4e79b..0000000 --- a/hack/rpc.http +++ /dev/null @@ -1,109 +0,0 @@ -// https://docs.solana.com/developing/clients/jsonrpc-api - -### - -POST https://tds-rpc.certus.one -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getVoteAccounts" -} - -### - -POST https://tds-rpc.certus.one -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getLeaderSchedule", - "params": [56252256] -} - -### - -POST https://tds-rpc.certus.one -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getSlotLeader" -} - -### - -POST https://tds-rpc.certus.one -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getEpochInfo", - "params": ["max"] -} - -### - -POST https://tds-rpc.certus.one -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getConfirmedBlocks", - "params": [56610214] -} - -### - -POST https://testnet.solana.com -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getConfirmedBlock", - "params": [56600576, "jsonParsed"] -} - -### - -POST https://testnet.solana.com -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getBlockTime", - "params": [56611148] -} - -### - -POST https://tds-rpc.certus.one -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getSlot" -} - -### - -POST https://tds-rpc.certus.one -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "getConfirmedBlocks", - "params": [ - 5, - 10 - ] -} diff --git a/pkg/rpc/blocktime.go b/pkg/rpc/blocktime.go deleted file mode 100644 index 0515c67..0000000 --- a/pkg/rpc/blocktime.go +++ /dev/null @@ -1,36 +0,0 @@ -package rpc - -import ( - "context" - "encoding/json" - "fmt" - "k8s.io/klog/v2" -) - -type ( - GetBlockTimeResponse struct { - Result int64 `json:"result"` - Error rpcError `json:"error"` - } -) - -// https://docs.solana.com/developing/clients/jsonrpc-api#getblocktime -func (c *RPCClient) GetBlockTime(ctx context.Context, slot int64) (int64, error) { - body, err := c.rpcRequest(ctx, formatRPCRequest("getBlockTime", []interface{}{slot})) - if err != nil { - return 0, fmt.Errorf("RPC call failed: %w", err) - } - - klog.V(2).Infof("getBlockTime response: %v", string(body)) - - var resp GetBlockTimeResponse - if err = json.Unmarshal(body, &resp); err != nil { - return 0, fmt.Errorf("failed to decode response body: %w", err) - } - - if resp.Error.Code != 0 { - return 0, fmt.Errorf("RPC error: %d %v", resp.Error.Code, resp.Error.Message) - } - - return resp.Result, nil -} diff --git a/pkg/rpc/client.go b/pkg/rpc/client.go index 7f98ff6..0994823 100644 --- a/pkg/rpc/client.go +++ b/pkg/rpc/client.go @@ -4,21 +4,21 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" - "io/ioutil" "k8s.io/klog/v2" "net/http" ) type ( - RPCClient struct { + Client struct { httpClient http.Client rpcAddr string } rpcError struct { Message string `json:"message"` - Code int64 `json:"id"` + Code int64 `json:"code"` } rpcRequest struct { @@ -31,6 +31,37 @@ type ( Commitment string ) +// Provider is an interface that defines the methods required to interact with the Solana blockchain. +// It provides methods to retrieve block production information, epoch info, slot info, vote accounts, and node version. +type Provider interface { + + // GetBlockProduction retrieves the block production information for the specified slot range. + // The method takes a context for cancellation, and pointers to the first and last slots of the range. + // It returns a BlockProduction struct containing the block production details, or an error if the operation fails. + GetBlockProduction(ctx context.Context, firstSlot *int64, lastSlot *int64) (*BlockProduction, error) + + // GetEpochInfo retrieves the information regarding the current epoch. + // The method takes a context for cancellation and a commitment level to specify the desired state. + // It returns a pointer to an EpochInfo struct containing the epoch details, or an error if the operation fails. + GetEpochInfo(ctx context.Context, commitment Commitment) (*EpochInfo, error) + + // GetSlot retrieves the current slot number. + // The method takes a context for cancellation. + // It returns the current slot number as an int64, or an error if the operation fails. + GetSlot(ctx context.Context) (int64, error) + + // GetVoteAccounts retrieves the vote accounts information. + // The method takes a context for cancellation and a slice of parameters to filter the vote accounts. + // It returns a pointer to a VoteAccounts struct containing the vote accounts details, + // or an error if the operation fails. + GetVoteAccounts(ctx context.Context, params []interface{}) (*VoteAccounts, error) + + // GetVersion retrieves the version of the Solana node. + // The method takes a context for cancellation. + // It returns a string containing the version information, or an error if the operation fails. + GetVersion(ctx context.Context) (string, error) +} + func (c Commitment) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]string{"commitment": string(c)}) } @@ -38,7 +69,7 @@ func (c Commitment) MarshalJSON() ([]byte, error) { const ( // Most recent block confirmed by supermajority of the cluster as having reached maximum lockout. CommitmentMax Commitment = "max" - // Most recent block having reached maximum lockout on this node. + // CommitmentRoot Most recent block having reached maximum lockout on this node. CommitmentRoot Commitment = "root" // Most recent block that has been voted on by supermajority of the cluster (optimistic confirmation). CommitmentSingleGossip Commitment = "singleGossip" @@ -46,33 +77,33 @@ const ( CommitmentRecent Commitment = "recent" ) -func NewRPCClient(rpcAddr string) *RPCClient { - c := &RPCClient{ +func NewRPCClient(rpcAddr string) *Client { + client := &Client{ httpClient: http.Client{}, rpcAddr: rpcAddr, } - return c + return client } func formatRPCRequest(method string, params []interface{}) io.Reader { - r := &rpcRequest{ + request := &rpcRequest{ Version: "2.0", ID: 1, Method: method, Params: params, } - b, err := json.Marshal(r) + buffer, err := json.Marshal(request) if err != nil { panic(err) } - klog.V(2).Infof("jsonrpc request: %s", string(b)) - return bytes.NewBuffer(b) + klog.V(2).Infof("jsonrpc request: %s", string(buffer)) + return bytes.NewBuffer(buffer) } -func (c *RPCClient) rpcRequest(ctx context.Context, data io.Reader) ([]byte, error) { +func (c *Client) rpcRequest(ctx context.Context, data io.Reader) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "POST", c.rpcAddr, data) if err != nil { panic(err) @@ -83,12 +114,102 @@ func (c *RPCClient) rpcRequest(ctx context.Context, data io.Reader) ([]byte, err if err != nil { return nil, err } + //goland:noinspection GoUnhandledErrorResult defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return body, nil } + +func (c *Client) getResponse(ctx context.Context, method string, params []interface{}, result HasRPCError) error { + body, err := c.rpcRequest(ctx, formatRPCRequest(method, params)) + // check if there was an error making the request: + if err != nil { + return fmt.Errorf("%s RPC call failed: %w", method, err) + } + // log response: + klog.V(2).Infof("%s response: %v", method, string(body)) + + // unmarshal the response into the predicted format + if err = json.Unmarshal(body, result); err != nil { + return fmt.Errorf("failed to decode %s response body: %w", method, err) + } + + if result.getError().Code != 0 { + return fmt.Errorf("RPC error: %d %v", result.getError().Code, result.getError().Message) + } + + return nil +} + +func (c *Client) GetEpochInfo(ctx context.Context, commitment Commitment) (*EpochInfo, error) { + var resp response[EpochInfo] + if err := c.getResponse(ctx, "getEpochInfo", []interface{}{commitment}, &resp); err != nil { + return nil, err + } + return &resp.Result, nil +} + +func (c *Client) GetVoteAccounts(ctx context.Context, params []interface{}) (*VoteAccounts, error) { + var resp response[VoteAccounts] + if err := c.getResponse(ctx, "getVoteAccounts", params, &resp); err != nil { + return nil, err + } + return &resp.Result, nil +} + +func (c *Client) GetVersion(ctx context.Context) (string, error) { + var resp response[struct { + Version string `json:"solana-core"` + }] + if err := c.getResponse(ctx, "getVersion", []interface{}{}, &resp); err != nil { + return "", err + } + return resp.Result.Version, nil +} + +func (c *Client) GetSlot(ctx context.Context) (int64, error) { + var resp response[int64] + if err := c.getResponse(ctx, "getSlot", []interface{}{}, &resp); err != nil { + return 0, err + } + return resp.Result, nil +} + +func (c *Client) GetBlockProduction(ctx context.Context, firstSlot *int64, lastSlot *int64) (*BlockProduction, error) { + // format params: + params := make([]interface{}, 1) + if firstSlot != nil { + params[0] = map[string]interface{}{ + "range": blockProductionRange{ + FirstSlot: *firstSlot, + LastSlot: lastSlot, + }, + } + } + + // make request: + var resp response[blockProductionResult] + if err := c.getResponse(ctx, "getBlockProduction", params, &resp); err != nil { + return nil, err + } + + // convert to BlockProduction format: + hosts := make(map[string]BlockProductionPerHost) + for id, arr := range resp.Result.Value.ByIdentity { + hosts[id] = BlockProductionPerHost{ + LeaderSlots: arr[0], + BlocksProduced: arr[1], + } + } + production := BlockProduction{ + FirstSlot: resp.Result.Value.Range.FirstSlot, + LastSlot: *resp.Result.Value.Range.LastSlot, + Hosts: hosts, + } + return &production, nil +} diff --git a/pkg/rpc/confirmedblocks.go b/pkg/rpc/confirmedblocks.go deleted file mode 100644 index 0f84521..0000000 --- a/pkg/rpc/confirmedblocks.go +++ /dev/null @@ -1,36 +0,0 @@ -package rpc - -import ( - "context" - "encoding/json" - "fmt" - "k8s.io/klog/v2" -) - -type ( - GetConfirmedBlocksResponse struct { - Result []int64 `json:"result"` - Error rpcError `json:"error"` - } -) - -// https://docs.solana.com/developing/clients/jsonrpc-api#getconfirmedblocks -func (c *RPCClient) GetConfirmedBlocks(ctx context.Context, startSlot, endSlot int64) ([]int64, error) { - body, err := c.rpcRequest(ctx, formatRPCRequest("getConfirmedBlocks", []interface{}{startSlot, endSlot})) - if err != nil { - return nil, fmt.Errorf("RPC call failed: %w", err) - } - - klog.V(2).Infof("getBlockTime response: %v", string(body)) - - var resp GetConfirmedBlocksResponse - if err = json.Unmarshal(body, &resp); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - - if resp.Error.Code != 0 { - return nil, fmt.Errorf("RPC error: %d %v", resp.Error.Code, resp.Error.Message) - } - - return resp.Result, nil -} diff --git a/pkg/rpc/epochinfo.go b/pkg/rpc/epochinfo.go deleted file mode 100644 index 82e75a6..0000000 --- a/pkg/rpc/epochinfo.go +++ /dev/null @@ -1,51 +0,0 @@ -package rpc - -import ( - "context" - "encoding/json" - "fmt" - "k8s.io/klog/v2" -) - -type ( - EpochInfo struct { - // Current absolute slot in epoch - AbsoluteSlot int64 `json:"absoluteSlot"` - // Current block height - BlockHeight int64 `json:"blockHeight"` - // Current epoch number - Epoch int64 `json:"epoch"` - // Current slot relative to the start of the current epoch - SlotIndex int64 `json:"slotIndex"` - // Number of slots in this epoch - SlotsInEpoch int64 `json:"slotsInEpoch"` - // Total number of transactions ever (?) - TransactionCount int64 `json:"transactionCount"` - } - - GetEpochInfoResponse struct { - Result EpochInfo `json:"result"` - Error rpcError `json:"error"` - } -) - -// https://docs.solana.com/developing/clients/jsonrpc-api#getepochinfo -func (c *RPCClient) GetEpochInfo(ctx context.Context, commitment Commitment) (*EpochInfo, error) { - body, err := c.rpcRequest(ctx, formatRPCRequest("getEpochInfo", []interface{}{commitment})) - if err != nil { - return nil, fmt.Errorf("RPC call failed: %w", err) - } - - klog.V(2).Infof("epoch info response: %v", string(body)) - - var resp GetEpochInfoResponse - if err = json.Unmarshal(body, &resp); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - - if resp.Error.Code != 0 { - return nil, fmt.Errorf("RPC error: %d %v", resp.Error.Code, resp.Error.Message) - } - - return &resp.Result, nil -} diff --git a/pkg/rpc/leaderschedule.go b/pkg/rpc/leaderschedule.go deleted file mode 100644 index e3073bf..0000000 --- a/pkg/rpc/leaderschedule.go +++ /dev/null @@ -1,38 +0,0 @@ -package rpc - -import ( - "context" - "encoding/json" - "fmt" - "k8s.io/klog/v2" -) - -type ( - LeaderSchedule map[string][]int64 - - GetLeaderScheduleResponse struct { - Result LeaderSchedule `json:"result"` - Error rpcError `json:"error"` - } -) - -// https://docs.solana.com/developing/clients/jsonrpc-api#getleaderschedule -func (c *RPCClient) GetLeaderSchedule(ctx context.Context, epochSlot int64) (LeaderSchedule, error) { - body, err := c.rpcRequest(ctx, formatRPCRequest("getLeaderSchedule", []interface{}{epochSlot})) - if err != nil { - return nil, fmt.Errorf("RPC call failed: %w", err) - } - - klog.V(3).Infof("getLeaderSchedule response: %v", string(body)) - - var resp GetLeaderScheduleResponse - if err = json.Unmarshal(body, &resp); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - - if resp.Error.Code != 0 { - return nil, fmt.Errorf("RPC error: %d %v", resp.Error.Code, resp.Error.Message) - } - - return resp.Result, nil -} diff --git a/pkg/rpc/responses.go b/pkg/rpc/responses.go new file mode 100644 index 0000000..926f1d3 --- /dev/null +++ b/pkg/rpc/responses.go @@ -0,0 +1,70 @@ +package rpc + +type ( + response[T any] struct { + Result T `json:"result"` + Error rpcError `json:"error"` + } + + EpochInfo struct { + // Current absolute slot in epoch + AbsoluteSlot int64 `json:"absoluteSlot"` + // Current block height + BlockHeight int64 `json:"blockHeight"` + // Current epoch number + Epoch int64 `json:"epoch"` + // Current slot relative to the start of the current epoch + SlotIndex int64 `json:"slotIndex"` + // Number of slots in this epoch + SlotsInEpoch int64 `json:"slotsInEpoch"` + // Total number of transactions + TransactionCount int64 `json:"transactionCount"` + } + + VoteAccount struct { + ActivatedStake int64 `json:"activatedStake"` + Commission int `json:"commission"` + EpochCredits [][]int `json:"epochCredits"` + EpochVoteAccount bool `json:"epochVoteAccount"` + LastVote int `json:"lastVote"` + NodePubkey string `json:"nodePubkey"` + RootSlot int `json:"rootSlot"` + VotePubkey string `json:"votePubkey"` + } + + VoteAccounts struct { + Current []VoteAccount `json:"current"` + Delinquent []VoteAccount `json:"delinquent"` + } + + blockProductionRange struct { + FirstSlot int64 `json:"firstSlot"` + LastSlot *int64 `json:"lastSlot,omitempty"` + } + + blockProductionResult struct { + Value struct { + ByIdentity map[string][]int64 `json:"byIdentity"` + Range blockProductionRange `json:"range"` + } `json:"value"` + } + + BlockProductionPerHost struct { + LeaderSlots int64 + BlocksProduced int64 + } + + BlockProduction struct { + FirstSlot int64 + LastSlot int64 + Hosts map[string]BlockProductionPerHost + } +) + +func (r response[T]) getError() rpcError { + return r.Error +} + +type HasRPCError interface { + getError() rpcError +} diff --git a/pkg/rpc/validators.go b/pkg/rpc/validators.go deleted file mode 100644 index 8b2d39f..0000000 --- a/pkg/rpc/validators.go +++ /dev/null @@ -1,50 +0,0 @@ -package rpc - -import ( - "context" - "encoding/json" - "fmt" - "k8s.io/klog/v2" -) - -type ( - VoteAccount struct { - ActivatedStake int64 `json:"activatedStake"` - Commission int `json:"commission"` - EpochCredits [][]int `json:"epochCredits"` - EpochVoteAccount bool `json:"epochVoteAccount"` - LastVote int `json:"lastVote"` - NodePubkey string `json:"nodePubkey"` - RootSlot int `json:"rootSlot"` - VotePubkey string `json:"votePubkey"` - } - - GetVoteAccountsResponse struct { - Result struct { - Current []VoteAccount `json:"current"` - Delinquent []VoteAccount `json:"delinquent"` - } `json:"result"` - Error rpcError `json:"error"` - } -) - -// https://docs.solana.com/developing/clients/jsonrpc-api#getvoteaccounts -func (c *RPCClient) GetVoteAccounts(ctx context.Context, params []interface{}) (*GetVoteAccountsResponse, error) { - body, err := c.rpcRequest(ctx, formatRPCRequest("getVoteAccounts", params)) - if err != nil { - return nil, fmt.Errorf("RPC call failed: %w", err) - } - - klog.V(3).Infof("getVoteAccounts response: %v", string(body)) - - var resp GetVoteAccountsResponse - if err = json.Unmarshal(body, &resp); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - - if resp.Error.Code != 0 { - return nil, fmt.Errorf("RPC error: %d %v", resp.Error.Code, resp.Error.Message) - } - - return &resp, nil -} diff --git a/pkg/rpc/version.go b/pkg/rpc/version.go deleted file mode 100644 index 39da07a..0000000 --- a/pkg/rpc/version.go +++ /dev/null @@ -1,43 +0,0 @@ -package rpc - -import ( - "context" - "encoding/json" - "fmt" - - "k8s.io/klog/v2" -) - -type ( - GetVersionResponse struct { - Result struct { - Version string `json:"solana-core"` - } `json:"result"` - Error rpcError `json:"error"` - } -) - -func (c *RPCClient) GetVersion(ctx context.Context) (*string, error) { - body, err := c.rpcRequest(ctx, formatRPCRequest("getVersion", []interface{}{})) - - if body == nil { - return nil, fmt.Errorf("RPC call failed: Body empty") - } - - if err != nil { - return nil, fmt.Errorf("RPC call failed: %w", err) - } - - klog.V(2).Infof("version response: %v", string(body)) - - var resp GetVersionResponse - if err = json.Unmarshal(body, &resp); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - - if resp.Error.Code != 0 { - return nil, fmt.Errorf("RPC error: %d %v", resp.Error.Code, resp.Error.Message) - } - - return &resp.Result.Version, nil -}