solana_exporter/pkg/rpc/client.go

265 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/asymmetric-research/solana_exporter/pkg/slog"
"go.uber.org/zap"
"io"
"net/http"
"slices"
"time"
)
type (
Client struct {
HttpClient http.Client
RpcUrl string
HttpTimeout time.Duration
logger *zap.SugaredLogger
}
Request struct {
Jsonrpc string `json:"jsonrpc"`
Id int `json:"id"`
Method string `json:"method"`
Params []any `json:"params"`
}
Commitment string
)
func (c Commitment) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]string{"commitment": string(c)})
}
const (
// LamportsInSol is the number of lamports in 1 SOL (a billion)
LamportsInSol = 1_000_000_000
// CommitmentFinalized level offers the highest level of certainty for a transaction on the Solana blockchain.
// A transaction is considered “Finalized” when it is included in a block that has been confirmed by a
// supermajority of the stake, and at least 31 additional confirmed blocks have been built on top of it.
CommitmentFinalized Commitment = "finalized"
// CommitmentConfirmed level is reached when a transaction is included in a block that has been voted on
// by a supermajority (66%+) of the networks stake.
CommitmentConfirmed Commitment = "confirmed"
// CommitmentProcessed level represents a transaction that has been received by the network and included in a block.
CommitmentProcessed Commitment = "processed"
)
func NewRPCClient(rpcAddr string, httpTimeout time.Duration) *Client {
return &Client{HttpClient: http.Client{}, RpcUrl: rpcAddr, HttpTimeout: httpTimeout, logger: slog.Get()}
}
func getResponse[T any](
ctx context.Context, client *Client, method string, params []any, rpcResponse *Response[T],
) error {
logger := slog.Get()
// format request:
request := &Request{Jsonrpc: "2.0", Id: 1, Method: method, Params: params}
buffer, err := json.Marshal(request)
if err != nil {
logger.Fatalf("failed to marshal request: %v", err)
}
logger.Debugf("jsonrpc request: %s", string(buffer))
// make request:
ctx, cancel := context.WithTimeout(ctx, client.HttpTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", client.RpcUrl, bytes.NewBuffer(buffer))
if err != nil {
logger.Fatalf("failed to create request: %v", err)
}
req.Header.Set("content-type", "application/json")
resp, err := client.HttpClient.Do(req)
if err != nil {
return fmt.Errorf("%s rpc call failed: %w", method, err)
}
//goland:noinspection GoUnhandledErrorResult
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error processing %s rpc call: %w", method, err)
}
// log response:
logger.Debugf("%s response: %v", method, string(body))
// unmarshal the response into the predicted format
if err = json.Unmarshal(body, rpcResponse); err != nil {
return fmt.Errorf("failed to decode %s response body: %w", method, err)
}
// check for an actual rpc error
if rpcResponse.Error.Code != 0 {
rpcResponse.Error.Method = method
return &rpcResponse.Error
}
return nil
}
// GetEpochInfo returns information about the current epoch.
// See API docs: https://solana.com/docs/rpc/http/getepochinfo
func (c *Client) GetEpochInfo(ctx context.Context, commitment Commitment) (*EpochInfo, error) {
var resp Response[EpochInfo]
if err := getResponse(ctx, c, "getEpochInfo", []any{commitment}, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// GetVoteAccounts returns the account info and associated stake for all the voting accounts in the current bank.
// See API docs: https://solana.com/docs/rpc/http/getvoteaccounts
func (c *Client) GetVoteAccounts(ctx context.Context, commitment Commitment) (*VoteAccounts, error) {
// format params:
config := map[string]string{"commitment": string(commitment)}
var resp Response[VoteAccounts]
if err := getResponse(ctx, c, "getVoteAccounts", []any{config}, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// GetVersion returns the current Solana version running on the node.
// See API docs: https://solana.com/docs/rpc/http/getversion
func (c *Client) GetVersion(ctx context.Context) (string, error) {
var resp Response[struct {
Version string `json:"solana-core"`
}]
if err := getResponse(ctx, c, "getVersion", []any{}, &resp); err != nil {
return "", err
}
return resp.Result.Version, nil
}
// GetSlot returns the slot that has reached the given or default commitment level.
// See API docs: https://solana.com/docs/rpc/http/getslot
func (c *Client) GetSlot(ctx context.Context, commitment Commitment) (int64, error) {
config := map[string]string{"commitment": string(commitment)}
var resp Response[int64]
if err := getResponse(ctx, c, "getSlot", []any{config}, &resp); err != nil {
return 0, err
}
return resp.Result, nil
}
// GetBlockProduction returns recent block production information from the current or previous epoch.
// See API docs: https://solana.com/docs/rpc/http/getblockproduction
func (c *Client) GetBlockProduction(
ctx context.Context, commitment Commitment, firstSlot int64, lastSlot int64,
) (*BlockProduction, error) {
// format params:
config := map[string]any{
"commitment": string(commitment),
"range": map[string]int64{"firstSlot": firstSlot, "lastSlot": lastSlot},
}
// make request:
var resp Response[contextualResult[BlockProduction]]
if err := getResponse(ctx, c, "getBlockProduction", []any{config}, &resp); err != nil {
return nil, err
}
return &resp.Result.Value, nil
}
// GetBalance returns the lamport balance of the account of provided pubkey.
// See API docs:https://solana.com/docs/rpc/http/getbalance
func (c *Client) GetBalance(ctx context.Context, commitment Commitment, address string) (float64, error) {
config := map[string]string{"commitment": string(commitment)}
var resp Response[contextualResult[int64]]
if err := getResponse(ctx, c, "getBalance", []any{address, config}, &resp); err != nil {
return 0, err
}
return float64(resp.Result.Value) / float64(LamportsInSol), nil
}
// GetInflationReward returns the inflation / staking reward for a list of addresses for an epoch.
// See API docs: https://solana.com/docs/rpc/http/getinflationreward
func (c *Client) GetInflationReward(
ctx context.Context, commitment Commitment, addresses []string, epoch int64,
) ([]InflationReward, error) {
// format params:
config := map[string]any{"commitment": string(commitment), "epoch": epoch}
var resp Response[[]InflationReward]
if err := getResponse(ctx, c, "getInflationReward", []any{addresses, config}, &resp); err != nil {
return nil, err
}
return resp.Result, nil
}
// GetLeaderSchedule returns the leader schedule for an epoch.
// See API docs: https://solana.com/docs/rpc/http/getleaderschedule
func (c *Client) GetLeaderSchedule(ctx context.Context, commitment Commitment, slot int64) (map[string][]int64, error) {
config := map[string]any{"commitment": string(commitment)}
var resp Response[map[string][]int64]
if err := getResponse(ctx, c, "getLeaderSchedule", []any{slot, config}, &resp); err != nil {
return nil, err
}
return resp.Result, nil
}
// GetBlock returns identity and transaction information about a confirmed block in the ledger.
// See API docs: https://solana.com/docs/rpc/http/getblock
func (c *Client) GetBlock(
ctx context.Context, commitment Commitment, slot int64, transactionDetails string,
) (*Block, error) {
detailsOptions := []string{"full", "none"}
if !slices.Contains(detailsOptions, transactionDetails) {
c.logger.Fatalf(
"%s is not a valid transaction-details option, must be one of %v", transactionDetails, detailsOptions,
)
}
if commitment == CommitmentProcessed {
// as per https://solana.com/docs/rpc/http/getblock
c.logger.Fatalf("commitment '%v' is not supported for GetBlock", CommitmentProcessed)
}
config := map[string]any{
"commitment": commitment,
"encoding": "json", // this is default, but no harm in specifying it
"transactionDetails": transactionDetails,
"rewards": true, // what we here for!
"maxSupportedTransactionVersion": 0,
}
var resp Response[Block]
if err := getResponse(ctx, c, "getBlock", []any{slot, config}, &resp); err != nil {
return nil, err
}
return &resp.Result, nil
}
// GetHealth returns the current health of the node. A healthy node is one that is within a blockchain-configured slots
// of the latest cluster confirmed slot.
// See API docs: https://solana.com/docs/rpc/http/gethealth
func (c *Client) GetHealth(ctx context.Context) (string, error) {
var resp Response[string]
if err := getResponse(ctx, c, "getHealth", []any{}, &resp); err != nil {
return "", err
}
return resp.Result, nil
}
// GetMinimumLedgerSlot returns the lowest slot that the node has information about in its ledger.
// See API docs: https://solana.com/docs/rpc/http/minimumledgerslot
func (c *Client) GetMinimumLedgerSlot(ctx context.Context) (int64, error) {
var resp Response[int64]
if err := getResponse(ctx, c, "minimumLedgerSlot", []any{}, &resp); err != nil {
return 0, err
}
return resp.Result, nil
}
// GetFirstAvailableBlock returns the slot of the lowest confirmed block that has not been purged from the ledger
// See API docs: https://solana.com/docs/rpc/http/getfirstavailableblock
func (c *Client) GetFirstAvailableBlock(ctx context.Context) (int64, error) {
var resp Response[int64]
if err := getResponse(ctx, c, "getFirstAvailableBlock", []any{}, &resp); err != nil {
return 0, err
}
return resp.Result, nil
}