package rpc import ( "bytes" "context" "fmt" "git.gammaspectra.live/P2Pool/consensus/v3/utils" "io" "net/http" "net/url" ) const ( // endpointJSONRPC is the common endpoint used for all the RPC calls // that make use of epee's JSONRPC invocation format for requests and // responses. // endpointJSONRPC = "/json_rpc" // versionJSONRPC is the version of the JSONRPC format. // versionJSONRPC = "2.0" ) // Client is a wrapper over a plain HTTP client providing methods that // correspond to all RPC invocations to a `monerod` daemon, including // restricted and non-restricted ones. type Client struct { // http is the underlying http client that takes care of sending // requests and receiving the responses. // // To provide your own, make use of `WithHTTPClient` when instantiating // the client via the `NewClient` constructor. // http *http.Client // address is the address of the monerod instance serving the RPC // endpoints. // address *url.URL } // clientOptions is a set of options that can be overridden to tweak the // client's behavior. type clientOptions struct { HTTPClient *http.Client } // ClientOption defines a functional option for overriding optional client // configuration parameters. type ClientOption func(o *clientOptions) // WithHTTPClient is a functional option for providing a custom HTTP client to // be used for the HTTP requests made to a monero daemon. func WithHTTPClient(v *http.Client) func(o *clientOptions) { return func(o *clientOptions) { o.HTTPClient = v } } // NewClient instantiates a new Client that is able to communicate with // monerod's RPC endpoints. // // The `address` might be either restricted (typically :18089) or not // (typically :18081). func NewClient(address string, opts ...ClientOption) (*Client, error) { options := &clientOptions{} for _, opt := range opts { opt(options) } if options.HTTPClient == nil { options.HTTPClient = http.DefaultClient } parsedAddress, err := url.Parse(address) if err != nil { return nil, fmt.Errorf("url parse: %w", err) } return &Client{ address: parsedAddress, http: options.HTTPClient, }, nil } // ResponseEnvelope wraps all responses from the RPC server. type ResponseEnvelope struct { ID string `json:"id"` JSONRPC string `json:"jsonrpc"` Result interface{} `json:"result,omitempty"` Error struct { Code int `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` } // RequestEnvelope wraps all requests made to the RPC server. type RequestEnvelope struct { ID string `json:"id"` JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params interface{} `json:"params,omitempty"` } // RawBinaryRequest makes requests to any endpoints, not assuming any particular format. func (c *Client) RawBinaryRequest(ctx context.Context, endpoint string, body io.Reader) (io.ReadCloser, error) { address := *c.address address.Path = endpoint req, err := http.NewRequestWithContext(ctx, "POST", address.String(), body) if err != nil { return nil, fmt.Errorf("new req '%s': %w", address.String(), err) } req.Header.Add("Content-Type", "application/octet-stream") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("do: %w", err) } if resp.StatusCode < 200 || resp.StatusCode > 299 { defer resp.Body.Close() return nil, fmt.Errorf("non-2xx status code: %d", resp.StatusCode) } return resp.Body, nil } // RawRequest makes requests to any endpoints, not assuming any particular format except of response is JSON. func (c *Client) RawRequest(ctx context.Context, endpoint string, params interface{}, response interface{}) error { address := *c.address address.Path = endpoint var body io.Reader if params != nil { b, err := utils.MarshalJSON(params) if err != nil { return fmt.Errorf("marshal: %w", err) } body = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, "GET", address.String(), body) if err != nil { return fmt.Errorf("new req '%s': %w", address.String(), err) } req.Header.Add("Content-Type", "application/json") if err := c.submitRequest(req, response); err != nil { return fmt.Errorf("submit request: %w", err) } return nil } // JSONRPC issues a request for a particular method under the JSONRPC endpoint // with the proper envolope for its requests and unwrapping of results for // responses. func (c *Client) JSONRPC(ctx context.Context, method string, params interface{}, response interface{}) error { address := *c.address address.Path = endpointJSONRPC b, err := utils.MarshalJSON(&RequestEnvelope{ ID: "0", JSONRPC: versionJSONRPC, Method: method, Params: params, }) if err != nil { return fmt.Errorf("marshal: %w", err) } req, err := http.NewRequestWithContext(ctx, "GET", address.String(), bytes.NewReader(b)) if err != nil { return fmt.Errorf("new req '%s': %w", address.String(), err) } req.Header.Add("Content-Type", "application/json") rpcResponseBody := &ResponseEnvelope{ Result: response, } if err := c.submitRequest(req, rpcResponseBody); err != nil { return fmt.Errorf("submit request: %w", err) } if rpcResponseBody.Error.Code != 0 || rpcResponseBody.Error.Message != "" { return fmt.Errorf("rpc error: code=%d message=%s", rpcResponseBody.Error.Code, rpcResponseBody.Error.Message, ) } return nil } // submitRequest performs any generic HTTP request to the monero node targeted // by this client making no assumptions about a particular endpoint. func (c *Client) submitRequest(req *http.Request, response interface{}) error { resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("do: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("non-2xx status code: %d", resp.StatusCode) } if err := utils.NewJSONDecoder(resp.Body).Decode(response); err != nil { return fmt.Errorf("decode: %w", err) } return nil }