diff --git a/rpc/client.go b/rpc/client.go new file mode 100644 index 0000000..452467d --- /dev/null +++ b/rpc/client.go @@ -0,0 +1,153 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +// Package rpc provides a typed client for the Lethean daemon JSON-RPC API. +package rpc + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Client is a Lethean daemon RPC client. +type Client struct { + url string // Base URL with /json_rpc path for JSON-RPC calls. + baseURL string // Base URL without path for legacy calls. + httpClient *http.Client +} + +// NewClient creates a client for the daemon at the given URL. +// If the URL has no path, "/json_rpc" is appended automatically. +func NewClient(daemonURL string) *Client { + return NewClientWithHTTP(daemonURL, &http.Client{Timeout: 30 * time.Second}) +} + +// NewClientWithHTTP creates a client with a custom http.Client. +func NewClientWithHTTP(daemonURL string, httpClient *http.Client) *Client { + u, err := url.Parse(daemonURL) + if err != nil { + // Fall through with raw URL. + return &Client{url: daemonURL + "/json_rpc", baseURL: daemonURL, httpClient: httpClient} + } + baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host) + if u.Path == "" || u.Path == "/" { + u.Path = "/json_rpc" + } + return &Client{ + url: u.String(), + baseURL: baseURL, + httpClient: httpClient, + } +} + +// RPCError represents a JSON-RPC error returned by the daemon. +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *RPCError) Error() string { + return fmt.Sprintf("rpc error %d: %s", e.Code, e.Message) +} + +// JSON-RPC 2.0 envelope types. +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Method string `json:"method"` + Params any `json:"params"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result"` + Error *jsonRPCError `json:"error,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// call makes a JSON-RPC 2.0 call to /json_rpc. +func (c *Client) call(method string, params any, result any) error { + reqBody, err := json.Marshal(jsonRPCRequest{ + JSONRPC: "2.0", + ID: "0", + Method: method, + Params: params, + }) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + resp, err := c.httpClient.Post(c.url, "application/json", bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("post %s: %w", method, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http %d from %s", resp.StatusCode, method) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + var rpcResp jsonRPCResponse + if err := json.Unmarshal(body, &rpcResp); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + + if rpcResp.Error != nil { + return &RPCError{Code: rpcResp.Error.Code, Message: rpcResp.Error.Message} + } + + if result != nil && len(rpcResp.Result) > 0 { + if err := json.Unmarshal(rpcResp.Result, result); err != nil { + return fmt.Errorf("unmarshal result: %w", err) + } + } + return nil +} + +// legacyCall makes a plain JSON POST to a legacy URI path (e.g. /getheight). +func (c *Client) legacyCall(path string, params any, result any) error { + reqBody, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + url := c.baseURL + path + resp, err := c.httpClient.Post(url, "application/json", bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("post %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http %d from %s", resp.StatusCode, path) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if result != nil { + if err := json.Unmarshal(body, result); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + } + return nil +} diff --git a/rpc/client_test.go b/rpc/client_test.go new file mode 100644 index 0000000..0bcad18 --- /dev/null +++ b/rpc/client_test.go @@ -0,0 +1,164 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package rpc + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_Good_JSONRPCCall(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request format. + if r.Method != http.MethodPost { + t.Errorf("method: got %s, want POST", r.Method) + } + if r.URL.Path != "/json_rpc" { + t.Errorf("path: got %s, want /json_rpc", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + var req jsonRPCRequest + json.Unmarshal(body, &req) + if req.JSONRPC != "2.0" { + t.Errorf("jsonrpc: got %q, want %q", req.JSONRPC, "2.0") + } + if req.Method != "getblockcount" { + t.Errorf("method: got %q, want %q", req.Method, "getblockcount") + } + + // Return a valid response. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jsonRPCResponse{ + JSONRPC: "2.0", + ID: json.RawMessage(`"0"`), + Result: json.RawMessage(`{"count":6300,"status":"OK"}`), + }) + })) + defer srv.Close() + + c := NewClient(srv.URL) + var result struct { + Count uint64 `json:"count"` + Status string `json:"status"` + } + err := c.call("getblockcount", struct{}{}, &result) + if err != nil { + t.Fatalf("call: %v", err) + } + if result.Count != 6300 { + t.Errorf("count: got %d, want 6300", result.Count) + } + if result.Status != "OK" { + t.Errorf("status: got %q, want %q", result.Status, "OK") + } +} + +func TestClient_Good_LegacyCall(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/getheight" { + t.Errorf("path: got %s, want /getheight", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"height":6300,"status":"OK"}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL) + var result struct { + Height uint64 `json:"height"` + Status string `json:"status"` + } + err := c.legacyCall("/getheight", struct{}{}, &result) + if err != nil { + t.Fatalf("legacyCall: %v", err) + } + if result.Height != 6300 { + t.Errorf("height: got %d, want 6300", result.Height) + } +} + +func TestClient_Bad_RPCError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jsonRPCResponse{ + JSONRPC: "2.0", + ID: json.RawMessage(`"0"`), + Error: &jsonRPCError{ + Code: -2, + Message: "TOO_BIG_HEIGHT", + }, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL) + var result struct{} + err := c.call("getblockheaderbyheight", struct{ Height uint64 }{999999999}, &result) + if err == nil { + t.Fatal("expected error") + } + rpcErr, ok := err.(*RPCError) + if !ok { + t.Fatalf("expected *RPCError, got %T: %v", err, err) + } + if rpcErr.Code != -2 { + t.Errorf("code: got %d, want -2", rpcErr.Code) + } +} + +func TestClient_Bad_ConnectionRefused(t *testing.T) { + c := NewClient("http://127.0.0.1:1") // Unlikely to be listening + var result struct{} + err := c.call("getblockcount", struct{}{}, &result) + if err == nil { + t.Fatal("expected connection error") + } +} + +func TestClient_Good_URLAppendPath(t *testing.T) { + // NewClient should append /json_rpc if path is empty. + c := NewClient("http://localhost:46941") + if c.url != "http://localhost:46941/json_rpc" { + t.Errorf("url: got %q, want %q", c.url, "http://localhost:46941/json_rpc") + } + + // If path already present, leave it alone. + c2 := NewClient("http://localhost:46941/json_rpc") + if c2.url != "http://localhost:46941/json_rpc" { + t.Errorf("url: got %q, want %q", c2.url, "http://localhost:46941/json_rpc") + } +} + +func TestClient_Bad_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`not json`)) + })) + defer srv.Close() + + c := NewClient(srv.URL) + var result struct{} + err := c.call("getblockcount", struct{}{}, &result) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestClient_Bad_HTTP500(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c := NewClient(srv.URL) + var result struct{} + err := c.call("getblockcount", struct{}{}, &result) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} diff --git a/rpc/types.go b/rpc/types.go new file mode 100644 index 0000000..dc7e620 --- /dev/null +++ b/rpc/types.go @@ -0,0 +1,78 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package rpc + +// BlockHeader is a block header as returned by daemon RPC. +// Returned by getlastblockheader, getblockheaderbyheight, getblockheaderbyhash. +type BlockHeader struct { + MajorVersion uint8 `json:"major_version"` + MinorVersion uint8 `json:"minor_version"` + Timestamp uint64 `json:"timestamp"` + PrevHash string `json:"prev_hash"` + Nonce uint64 `json:"nonce"` + OrphanStatus bool `json:"orphan_status"` + Height uint64 `json:"height"` + Depth uint64 `json:"depth"` + Hash string `json:"hash"` + Difficulty string `json:"difficulty"` + Reward uint64 `json:"reward"` +} + +// DaemonInfo is the daemon status as returned by getinfo. +type DaemonInfo struct { + Height uint64 `json:"height"` + TxCount uint64 `json:"tx_count"` + TxPoolSize uint64 `json:"tx_pool_size"` + AltBlocksCount uint64 `json:"alt_blocks_count"` + OutgoingConnectionsCount uint64 `json:"outgoing_connections_count"` + IncomingConnectionsCount uint64 `json:"incoming_connections_count"` + SynchronizedConnectionsCount uint64 `json:"synchronized_connections_count"` + DaemonNetworkState uint64 `json:"daemon_network_state"` + SynchronizationStartHeight uint64 `json:"synchronization_start_height"` + MaxNetSeenHeight uint64 `json:"max_net_seen_height"` + PowDifficulty uint64 `json:"pow_difficulty"` + PosDifficulty string `json:"pos_difficulty"` + BlockReward uint64 `json:"block_reward"` + DefaultFee uint64 `json:"default_fee"` + MinimumFee uint64 `json:"minimum_fee"` + LastBlockTimestamp uint64 `json:"last_block_timestamp"` + LastBlockHash string `json:"last_block_hash"` + AliasCount uint64 `json:"alias_count"` + TotalCoins string `json:"total_coins"` + PosAllowed bool `json:"pos_allowed"` + CurrentMaxAllowedBlockSize uint64 `json:"current_max_allowed_block_size"` +} + +// BlockDetails is a full block with metadata as returned by get_blocks_details. +type BlockDetails struct { + Height uint64 `json:"height"` + Timestamp uint64 `json:"timestamp"` + ActualTimestamp uint64 `json:"actual_timestamp"` + BaseReward uint64 `json:"base_reward"` + SummaryReward uint64 `json:"summary_reward"` + TotalFee uint64 `json:"total_fee"` + ID string `json:"id"` + PrevID string `json:"prev_id"` + Difficulty string `json:"difficulty"` + Type uint64 `json:"type"` + IsOrphan bool `json:"is_orphan"` + CumulativeSize uint64 `json:"block_cumulative_size"` + Blob string `json:"blob"` + ObjectInJSON string `json:"object_in_json"` + Transactions []TxInfo `json:"transactions_details"` +} + +// TxInfo is transaction metadata as returned by get_tx_details. +type TxInfo struct { + ID string `json:"id"` + BlobSize uint64 `json:"blob_size"` + Fee uint64 `json:"fee"` + Amount uint64 `json:"amount"` + Timestamp uint64 `json:"timestamp"` + KeeperBlock int64 `json:"keeper_block"` + Blob string `json:"blob"` + ObjectInJSON string `json:"object_in_json"` +}