From 587ef9698a9c95560ce5df91eef93f7e85fe4c02 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 3 Apr 2026 08:30:42 -0700 Subject: [PATCH] fix: consolidate CometBFT RPC response parsing to fix JSON-RPC envelope bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CometBFT HTTP RPC endpoints return responses wrapped in a JSON-RPC envelope: {"jsonrpc":"2.0","id":-1,"result":{...}}. Four independent parsing sites in the codebase disagreed about whether this wrapper existed, causing silent failures in production: - tasks/statesync.go: missing wrapper → "empty block hash" error - rpc/status.go: missing wrapper → LatestHeight() always returned 0 - tasks/result_export.go: missing wrapper → exporter retried forever - shadow/layer0.go + layer1.go: correctly included wrapper This commit introduces a single rpc.Client that strips the envelope once via Get(), with GetRaw() for archival paths. All four sites now use the shared client and canonical response types from rpc/types.go. Additional fixes from Tide review: - Handle JSON-RPC error responses (surface CometBFT error messages) - Configurable timeout via SetTimeout(); StatusClient uses 500ms (matching original) while other paths keep 10s default - All test mocks now use full JSON-RPC envelopes Co-Authored-By: Claude Opus 4.6 (1M context) --- sidecar/rpc/client.go | 115 +++++++++++++++++++++++ sidecar/rpc/client_test.go | 128 ++++++++++++++++++++++++++ sidecar/rpc/status.go | 70 +++++--------- sidecar/rpc/status_test.go | 27 +++++- sidecar/rpc/types.go | 57 ++++++++++++ sidecar/shadow/comparator_test.go | 23 +++-- sidecar/shadow/layer0.go | 47 +++------- sidecar/shadow/layer1.go | 40 +++----- sidecar/shadow/report.go | 8 +- sidecar/shadow/rpc.go | 36 -------- sidecar/tasks/await_condition_test.go | 2 +- sidecar/tasks/peers.go | 27 +----- sidecar/tasks/result_export.go | 57 ++---------- sidecar/tasks/result_export_test.go | 13 ++- sidecar/tasks/statesync.go | 59 ++++-------- sidecar/tasks/statesync_test.go | 49 +++++----- sidecar/tasks/tendermint.go | 19 ---- 17 files changed, 458 insertions(+), 319 deletions(-) create mode 100644 sidecar/rpc/client.go create mode 100644 sidecar/rpc/client_test.go create mode 100644 sidecar/rpc/types.go delete mode 100644 sidecar/shadow/rpc.go delete mode 100644 sidecar/tasks/tendermint.go diff --git a/sidecar/rpc/client.go b/sidecar/rpc/client.go new file mode 100644 index 0000000..c8d407e --- /dev/null +++ b/sidecar/rpc/client.go @@ -0,0 +1,115 @@ +package rpc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const defaultTimeout = 10 * time.Second + +// HTTPDoer abstracts HTTP requests for testability. +type HTTPDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// rpcError represents a JSON-RPC error returned by CometBFT. +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data"` +} + +// envelope is the JSON-RPC response wrapper returned by all CometBFT +// HTTP RPC endpoints (e.g. /status, /block, /block_results). +type envelope struct { + Result json.RawMessage `json:"result"` + Error *rpcError `json:"error,omitempty"` +} + +// Client performs HTTP GET requests against a CometBFT RPC endpoint +// and handles the JSON-RPC envelope unwrapping. +type Client struct { + endpoint string + httpClient HTTPDoer + timeout time.Duration +} + +// NewClient creates a CometBFT RPC client. Pass "" for endpoint to +// use the default localhost address. Pass nil for httpClient to use +// http.DefaultClient with no custom timeout. +func NewClient(endpoint string, httpClient HTTPDoer) *Client { + if endpoint == "" { + endpoint = DefaultEndpoint + } + if httpClient == nil { + httpClient = &http.Client{} + } + return &Client{ + endpoint: endpoint, + httpClient: httpClient, + timeout: defaultTimeout, + } +} + +// SetTimeout overrides the per-request context timeout. +func (c *Client) SetTimeout(d time.Duration) { c.timeout = d } + +// Endpoint returns the configured RPC base URL. +func (c *Client) Endpoint() string { return c.endpoint } + +// Get performs an HTTP GET to endpoint+path, unwraps the JSON-RPC +// envelope, and returns the inner "result" as raw JSON. +func (c *Client) Get(ctx context.Context, path string) (json.RawMessage, error) { + body, err := c.doGet(ctx, path) + if err != nil { + return nil, err + } + + var env envelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, fmt.Errorf("decoding JSON-RPC envelope from %s: %w", path, err) + } + if env.Error != nil { + return nil, fmt.Errorf("JSON-RPC error from %s: %s (code %d, data: %s)", + path, env.Error.Message, env.Error.Code, env.Error.Data) + } + if len(env.Result) == 0 { + return nil, fmt.Errorf("empty result in JSON-RPC response from %s", path) + } + + return env.Result, nil +} + +// GetRaw performs an HTTP GET and returns the entire response body +// without envelope unwrapping. Use for archival paths that store the +// verbatim JSON-RPC response (e.g., S3 export). +func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { + return c.doGet(ctx, path) +} + +func (c *Client) doGet(ctx context.Context, path string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint+path, nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + + return io.ReadAll(resp.Body) +} diff --git a/sidecar/rpc/client_test.go b/sidecar/rpc/client_test.go new file mode 100644 index 0000000..cfe9521 --- /dev/null +++ b/sidecar/rpc/client_test.go @@ -0,0 +1,128 @@ +package rpc + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestClient_Get_UnwrapsEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":-1,"result":{"sync_info":{"latest_block_height":"42"}}}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, nil) + raw, err := c.Get(context.Background(), "/status") + if err != nil { + t.Fatalf("Get: %v", err) + } + + got := string(raw) + if !strings.Contains(got, "latest_block_height") { + t.Errorf("expected unwrapped result containing latest_block_height, got %s", got) + } + if strings.Contains(got, "jsonrpc") { + t.Error("result should not contain the JSON-RPC envelope") + } +} + +func TestClient_Get_EmptyResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":-1}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, nil) + _, err := c.Get(context.Background(), "/status") + if err == nil { + t.Fatal("expected error for empty result") + } + if !strings.Contains(err.Error(), "empty result") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestClient_Get_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("bad gateway")) + })) + defer srv.Close() + + c := NewClient(srv.URL, nil) + _, err := c.Get(context.Background(), "/status") + if err == nil { + t.Fatal("expected error for non-200 response") + } + if !strings.Contains(err.Error(), "502") { + t.Errorf("expected HTTP 502 in error, got: %v", err) + } +} + +func TestClient_Get_MalformedJSON(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, nil) + _, err := c.Get(context.Background(), "/status") + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +func TestClient_GetRaw_ReturnsFullBody(t *testing.T) { + body := `{"jsonrpc":"2.0","id":-1,"result":{"txs_results":[]}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + c := NewClient(srv.URL, nil) + raw, err := c.GetRaw(context.Background(), "/block_results?height=1") + if err != nil { + t.Fatalf("GetRaw: %v", err) + } + if string(raw) != body { + t.Errorf("expected full body %q, got %q", body, string(raw)) + } +} + +func TestClient_Get_RPCError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":-1,"error":{"code":-32603,"message":"Internal error","data":"height 999999 is not available"}}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, nil) + _, err := c.Get(context.Background(), "/block?height=999999") + if err == nil { + t.Fatal("expected error for JSON-RPC error response") + } + if !strings.Contains(err.Error(), "Internal error") { + t.Errorf("expected error to contain CometBFT message, got: %v", err) + } + if !strings.Contains(err.Error(), "height 999999 is not available") { + t.Errorf("expected error to contain data field, got: %v", err) + } +} + +func TestClient_SetTimeout(t *testing.T) { + c := NewClient("", nil) + c.SetTimeout(500 * time.Millisecond) + if c.timeout != 500*time.Millisecond { + t.Errorf("timeout = %v, want 500ms", c.timeout) + } +} + +func TestClient_DefaultEndpoint(t *testing.T) { + c := NewClient("", nil) + if c.Endpoint() != DefaultEndpoint { + t.Errorf("endpoint = %q, want %q", c.Endpoint(), DefaultEndpoint) + } +} diff --git a/sidecar/rpc/status.go b/sidecar/rpc/status.go index bc15b1f..f35273e 100644 --- a/sidecar/rpc/status.go +++ b/sidecar/rpc/status.go @@ -4,87 +4,61 @@ import ( "context" "encoding/json" "fmt" - "io" - "net/http" "strconv" "time" seiconfig "github.com/sei-protocol/sei-config" ) +const statusTimeout = 500 * time.Millisecond + // DefaultEndpoint is the local CometBFT RPC address. var DefaultEndpoint = fmt.Sprintf("http://localhost:%d", seiconfig.PortRPC) -const defaultRequestTimeout = 500 * time.Millisecond - -// StatusClient queries a CometBFT node's /status endpoint. -type StatusClient struct { - endpoint string - httpClient *http.Client -} - // NodeStatus holds the fields we care about from CometBFT /status. type NodeStatus struct { LatestBlockHeight int64 CatchingUp bool } -// Endpoint returns the configured RPC endpoint. -func (c *StatusClient) Endpoint() string { return c.endpoint } - -type statusResponse struct { - SyncInfo struct { - LatestBlockHeight string `json:"latest_block_height"` - CatchingUp bool `json:"catching_up"` - } `json:"sync_info"` +// StatusClient queries a CometBFT node's /status endpoint. +type StatusClient struct { + client *Client } // NewStatusClient creates a client targeting the given RPC endpoint. -// Pass "" for the default localhost endpoint. -func NewStatusClient(endpoint string, httpClient *http.Client) *StatusClient { - if endpoint == "" { - endpoint = DefaultEndpoint - } - if httpClient == nil { - httpClient = &http.Client{} - } - return &StatusClient{endpoint: endpoint, httpClient: httpClient} +// Pass "" for the default localhost endpoint. Pass nil for the default +// HTTP client. +func NewStatusClient(endpoint string, httpClient HTTPDoer) *StatusClient { + c := NewClient(endpoint, httpClient) + c.SetTimeout(statusTimeout) + return &StatusClient{client: c} } +// Endpoint returns the configured RPC endpoint. +func (c *StatusClient) Endpoint() string { return c.client.Endpoint() } + // Status queries the node and returns the parsed status. func (c *StatusClient) Status(ctx context.Context) (*NodeStatus, error) { - ctx, cancel := context.WithTimeout(ctx, defaultRequestTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint+"/status", nil) - if err != nil { - return nil, err - } - - resp, err := c.httpClient.Do(req) + raw, err := c.client.Get(ctx, "/status") if err != nil { return nil, err } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) - } - var rpcResp statusResponse - if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { - return nil, fmt.Errorf("decoding /status: %w", err) + var result StatusResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("decoding /status result: %w", err) } - h, err := strconv.ParseInt(rpcResp.SyncInfo.LatestBlockHeight, 10, 64) + h, err := strconv.ParseInt(result.SyncInfo.LatestBlockHeight, 10, 64) if err != nil { - return nil, fmt.Errorf("parsing latest_block_height %q: %w", rpcResp.SyncInfo.LatestBlockHeight, err) + return nil, fmt.Errorf("parsing latest_block_height %q: %w", + result.SyncInfo.LatestBlockHeight, err) } return &NodeStatus{ LatestBlockHeight: h, - CatchingUp: rpcResp.SyncInfo.CatchingUp, + CatchingUp: result.SyncInfo.CatchingUp, }, nil } diff --git a/sidecar/rpc/status_test.go b/sidecar/rpc/status_test.go index ab24a8e..3ba6129 100644 --- a/sidecar/rpc/status_test.go +++ b/sidecar/rpc/status_test.go @@ -2,14 +2,20 @@ package rpc import ( "context" + "fmt" "net/http" "net/http/httptest" "testing" ) +// wrapResult wraps a JSON payload in the CometBFT JSON-RPC envelope. +func wrapResult(inner string) string { + return fmt.Sprintf(`{"jsonrpc":"2.0","id":-1,"result":%s}`, inner) +} + func TestStatusClient_LatestHeight(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"sync_info":{"latest_block_height":"12345","catching_up":false}}`)) + _, _ = w.Write([]byte(wrapResult(`{"sync_info":{"latest_block_height":"12345","catching_up":false}}`))) })) defer srv.Close() @@ -25,7 +31,7 @@ func TestStatusClient_LatestHeight(t *testing.T) { func TestStatusClient_CatchingUp(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"sync_info":{"latest_block_height":"100","catching_up":true}}`)) + _, _ = w.Write([]byte(wrapResult(`{"sync_info":{"latest_block_height":"100","catching_up":true}}`))) })) defer srv.Close() @@ -79,8 +85,8 @@ func TestStatusClient_InvalidBlockHeight(t *testing.T) { name string payload string }{ - {"empty height", `{"sync_info":{"latest_block_height":"","catching_up":false}}`}, - {"non-numeric height", `{"sync_info":{"latest_block_height":"abc","catching_up":false}}`}, + {"empty height", wrapResult(`{"sync_info":{"latest_block_height":"","catching_up":false}}`)}, + {"non-numeric height", wrapResult(`{"sync_info":{"latest_block_height":"abc","catching_up":false}}`)}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -104,3 +110,16 @@ func TestStatusClient_DefaultEndpoint(t *testing.T) { t.Errorf("endpoint = %q, want %q", c.Endpoint(), DefaultEndpoint) } } + +func TestStatusClient_EmptyResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":-1}`)) + })) + defer srv.Close() + + c := NewStatusClient(srv.URL, nil) + _, err := c.LatestHeight(context.Background()) + if err == nil { + t.Fatal("expected error for empty result") + } +} diff --git a/sidecar/rpc/types.go b/sidecar/rpc/types.go new file mode 100644 index 0000000..d3b881a --- /dev/null +++ b/sidecar/rpc/types.go @@ -0,0 +1,57 @@ +package rpc + +import "encoding/json" + +// StatusResult is the inner "result" of the CometBFT /status response, +// after the JSON-RPC envelope has been stripped by Client.Get. +type StatusResult struct { + NodeInfo NodeInfo `json:"node_info"` + SyncInfo SyncInfo `json:"sync_info"` +} + +// NodeInfo identifies a CometBFT node. +type NodeInfo struct { + ID string `json:"id"` +} + +// SyncInfo reports chain sync state. +type SyncInfo struct { + LatestBlockHeight string `json:"latest_block_height"` + CatchingUp bool `json:"catching_up"` +} + +// BlockResult is the inner "result" of the CometBFT /block response. +type BlockResult struct { + BlockID BlockID `json:"block_id"` + Block Block `json:"block"` +} + +// BlockID identifies a block by hash. +type BlockID struct { + Hash string `json:"hash"` +} + +// Block holds the subset of block fields we need. +type Block struct { + Header BlockHeader `json:"header"` +} + +// BlockHeader holds consensus-critical header fields for comparison. +type BlockHeader struct { + AppHash string `json:"app_hash"` + LastResultsHash string `json:"last_results_hash"` +} + +// BlockResultsResult is the inner "result" of the CometBFT /block_results response. +type BlockResultsResult struct { + TxsResults []TxResult `json:"txs_results"` +} + +// TxResult holds a single transaction execution result. +type TxResult struct { + Code int `json:"code"` + Log string `json:"log"` + GasUsed string `json:"gas_used"` + GasWanted string `json:"gas_wanted"` + Events json.RawMessage `json:"events"` +} diff --git a/sidecar/shadow/comparator_test.go b/sidecar/shadow/comparator_test.go index 055febd..828552e 100644 --- a/sidecar/shadow/comparator_test.go +++ b/sidecar/shadow/comparator_test.go @@ -7,12 +7,17 @@ import ( "net/http" "net/http/httptest" "testing" + + "github.com/sei-protocol/seictl/sidecar/rpc" ) -// blockJSON builds a minimal /block response with the given header fields. +// blockJSON builds a minimal /block JSON-RPC response with the given header fields. func blockJSON(appHash, lastResultsHash string) []byte { resp := map[string]any{ + "jsonrpc": "2.0", + "id": -1, "result": map[string]any{ + "block_id": map[string]any{"hash": ""}, "block": map[string]any{ "header": map[string]any{ "app_hash": appHash, @@ -25,9 +30,11 @@ func blockJSON(appHash, lastResultsHash string) []byte { return b } -// blockResultsJSON builds a minimal /block_results response. -func blockResultsJSON(txs []txResult) []byte { +// blockResultsJSON builds a minimal /block_results JSON-RPC response. +func blockResultsJSON(txs []rpc.TxResult) []byte { resp := map[string]any{ + "jsonrpc": "2.0", + "id": -1, "result": map[string]any{ "txs_results": txs, }, @@ -37,7 +44,7 @@ func blockResultsJSON(txs []txResult) []byte { } // rpcServer creates an httptest server that responds to /block and /block_results. -func rpcServer(appHash, lastResultsHash string, txs []txResult) *httptest.Server { +func rpcServer(appHash, lastResultsHash string, txs []rpc.TxResult) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/block": @@ -104,10 +111,10 @@ func TestCompareBlock_Layer0Divergence(t *testing.T) { } func TestCompareBlock_Layer1TxDivergence(t *testing.T) { - shadowTxs := []txResult{ + shadowTxs := []rpc.TxResult{ {Code: 0, GasUsed: "100", GasWanted: "200", Log: "ok", Events: json.RawMessage(`[]`)}, } - canonicalTxs := []txResult{ + canonicalTxs := []rpc.TxResult{ {Code: 1, GasUsed: "150", GasWanted: "200", Log: "reverted", Events: json.RawMessage(`[]`)}, } @@ -150,11 +157,11 @@ func TestCompareBlock_Layer1TxDivergence(t *testing.T) { } func TestCompareBlock_Layer1TxCountMismatch(t *testing.T) { - shadowTxs := []txResult{ + shadowTxs := []rpc.TxResult{ {Code: 0, GasUsed: "100", GasWanted: "200"}, {Code: 0, GasUsed: "100", GasWanted: "200"}, } - canonicalTxs := []txResult{ + canonicalTxs := []rpc.TxResult{ {Code: 0, GasUsed: "100", GasWanted: "200"}, } diff --git a/sidecar/shadow/layer0.go b/sidecar/shadow/layer0.go index 6d6b74f..1c1997d 100644 --- a/sidecar/shadow/layer0.go +++ b/sidecar/shadow/layer0.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + + "github.com/sei-protocol/seictl/sidecar/rpc" ) // compareLayer0 fetches the block header from both chains at the given @@ -18,13 +20,10 @@ func (c *Comparator) compareLayer0(ctx context.Context, height int64) (*Layer0Re return nil, fmt.Errorf("querying canonical block at height %d: %w", height, err) } - sHeader := shadowBlock.header() - cHeader := canonicalBlock.header() - - sAppHash := sHeader.AppHash - cAppHash := cHeader.AppHash - sLastResults := sHeader.LastResultsHash - cLastResults := cHeader.LastResultsHash + sAppHash := shadowBlock.Block.Header.AppHash + cAppHash := canonicalBlock.Block.Header.AppHash + sLastResults := shadowBlock.Block.Header.LastResultsHash + cLastResults := canonicalBlock.Block.Header.LastResultsHash // Gas is summed from block_results; the block header doesn't carry it // directly. For L0 we compare what the header gives us. Gas comparison @@ -51,36 +50,18 @@ func (c *Comparator) compareLayer0(ctx context.Context, height int64) (*Layer0Re return result, nil } -// blockResponse is the subset of the Tendermint /block RPC response -// that we need for header comparison. -type blockResponse struct { - Result struct { - Block struct { - Header blockHeader `json:"header"` - } `json:"block"` - } `json:"result"` -} - -type blockHeader struct { - AppHash string `json:"app_hash"` - LastResultsHash string `json:"last_results_hash"` -} - -func (b *blockResponse) header() blockHeader { - return b.Result.Block.Header -} - -// queryBlock fetches the block at the given height from a Tendermint RPC endpoint. -func queryBlock(ctx context.Context, rpcEndpoint string, height int64) (*blockResponse, error) { - url := fmt.Sprintf("%s/block?height=%d", rpcEndpoint, height) - body, err := rpcGet(ctx, url) +// queryBlock fetches the block at the given height from a CometBFT RPC endpoint +// and returns the header fields needed for comparison. +func queryBlock(ctx context.Context, rpcEndpoint string, height int64) (*rpc.BlockResult, error) { + c := rpc.NewClient(rpcEndpoint, nil) + raw, err := c.Get(ctx, fmt.Sprintf("/block?height=%d", height)) if err != nil { return nil, err } - var resp blockResponse - if err := json.Unmarshal(body, &resp); err != nil { + var result rpc.BlockResult + if err := json.Unmarshal(raw, &result); err != nil { return nil, fmt.Errorf("decoding /block response: %w", err) } - return &resp, nil + return &result, nil } diff --git a/sidecar/shadow/layer1.go b/sidecar/shadow/layer1.go index 2e0770c..397663f 100644 --- a/sidecar/shadow/layer1.go +++ b/sidecar/shadow/layer1.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "reflect" + + "github.com/sei-protocol/seictl/sidecar/rpc" ) // compareLayer1 fetches block_results from both chains and compares @@ -19,8 +21,8 @@ func (c *Comparator) compareLayer1(ctx context.Context, height int64) (*Layer1Re return nil, fmt.Errorf("querying canonical block_results at height %d: %w", height, err) } - sTxs := shadowResults.txResults() - cTxs := canonicalResults.txResults() + sTxs := shadowResults.TxsResults + cTxs := canonicalResults.TxsResults result := &Layer1Result{ TotalTxs: max(len(sTxs), len(cTxs)), @@ -67,7 +69,7 @@ func (c *Comparator) compareLayer1(ctx context.Context, height int64) (*Layer1Re // compareTxReceipts compares critical fields from two transaction results. // Returns nil when the receipts match. -func compareTxReceipts(idx int, shadow, canonical txResult) *TxDivergence { +func compareTxReceipts(idx int, shadow, canonical rpc.TxResult) *TxDivergence { var fields []FieldDivergence if shadow.Code != canonical.Code { @@ -102,37 +104,17 @@ func compareTxReceipts(idx int, shadow, canonical txResult) *TxDivergence { return &TxDivergence{TxIndex: idx, Fields: fields} } -// blockResultsResponse is the subset of the Tendermint /block_results -// RPC response needed for transaction receipt comparison. -type blockResultsResponse struct { - Result struct { - TxsResults []txResult `json:"txs_results"` - } `json:"result"` -} - -type txResult struct { - Code int `json:"code"` - Log string `json:"log"` - GasUsed string `json:"gas_used"` - GasWanted string `json:"gas_wanted"` - Events json.RawMessage `json:"events"` -} - -func (b *blockResultsResponse) txResults() []txResult { - return b.Result.TxsResults -} - // queryBlockResults fetches /block_results at the given height. -func queryBlockResults(ctx context.Context, rpcEndpoint string, height int64) (*blockResultsResponse, error) { - url := fmt.Sprintf("%s/block_results?height=%d", rpcEndpoint, height) - body, err := rpcGet(ctx, url) +func queryBlockResults(ctx context.Context, rpcEndpoint string, height int64) (*rpc.BlockResultsResult, error) { + c := rpc.NewClient(rpcEndpoint, nil) + raw, err := c.Get(ctx, fmt.Sprintf("/block_results?height=%d", height)) if err != nil { return nil, err } - var resp blockResultsResponse - if err := json.Unmarshal(body, &resp); err != nil { + var result rpc.BlockResultsResult + if err := json.Unmarshal(raw, &result); err != nil { return nil, fmt.Errorf("decoding /block_results response: %w", err) } - return &resp, nil + return &result, nil } diff --git a/sidecar/shadow/report.go b/sidecar/shadow/report.go index 3c3a1a9..7a61a4e 100644 --- a/sidecar/shadow/report.go +++ b/sidecar/shadow/report.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "time" + + "github.com/sei-protocol/seictl/sidecar/rpc" ) // BuildDivergenceReport captures a complete investigation artifact at the @@ -47,11 +49,9 @@ func captureChainSnapshot(ctx context.Context, rpcEndpoint string, height int64) } func fetchRawBlock(ctx context.Context, rpcEndpoint string, height int64) ([]byte, error) { - url := fmt.Sprintf("%s/block?height=%d", rpcEndpoint, height) - return rpcGet(ctx, url) + return rpc.NewClient(rpcEndpoint, nil).GetRaw(ctx, fmt.Sprintf("/block?height=%d", height)) } func fetchRawBlockResults(ctx context.Context, rpcEndpoint string, height int64) ([]byte, error) { - url := fmt.Sprintf("%s/block_results?height=%d", rpcEndpoint, height) - return rpcGet(ctx, url) + return rpc.NewClient(rpcEndpoint, nil).GetRaw(ctx, fmt.Sprintf("/block_results?height=%d", height)) } diff --git a/sidecar/shadow/rpc.go b/sidecar/shadow/rpc.go deleted file mode 100644 index dffa131..0000000 --- a/sidecar/shadow/rpc.go +++ /dev/null @@ -1,36 +0,0 @@ -package shadow - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "time" -) - -const rpcTimeout = 10 * time.Second - -// rpcGet performs an HTTP GET to the given URL and returns the response body. -func rpcGet(ctx context.Context, url string) ([]byte, error) { - ctx, cancel := context.WithTimeout(ctx, rpcTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(body)) - } - - return io.ReadAll(resp.Body) -} diff --git a/sidecar/tasks/await_condition_test.go b/sidecar/tasks/await_condition_test.go index 5305f4d..7f650b7 100644 --- a/sidecar/tasks/await_condition_test.go +++ b/sidecar/tasks/await_condition_test.go @@ -25,7 +25,7 @@ func heightServer(heights ...int64) *httptest.Server { idx++ } mu.Unlock() - fmt.Fprintf(w, `{"sync_info":{"latest_block_height":"%d","catching_up":false}}`, heights[i]) + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":-1,"result":{"sync_info":{"latest_block_height":"%d","catching_up":false}}}`, heights[i]) })) } diff --git a/sidecar/tasks/peers.go b/sidecar/tasks/peers.go index a2525b8..518c035 100644 --- a/sidecar/tasks/peers.go +++ b/sidecar/tasks/peers.go @@ -4,17 +4,15 @@ import ( "context" "encoding/json" "fmt" - "io" - "net/http" "path/filepath" "strings" - "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/sei-protocol/seictl/sidecar/engine" + "github.com/sei-protocol/seictl/sidecar/rpc" "github.com/sei-protocol/seilog" ) @@ -311,30 +309,15 @@ func instanceIP(instance ec2types.Instance) string { return "" } -var nodeIDHTTPClient = &http.Client{Timeout: 5 * time.Second} - func defaultQueryNodeID(ctx context.Context, ip string) (string, error) { - reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:26657/status", ip), nil) - if err != nil { - return "", fmt.Errorf("building request: %w", err) - } - - resp, err := nodeIDHTTPClient.Do(req) + c := rpc.NewClient(fmt.Sprintf("http://%s:26657", ip), nil) + raw, err := c.Get(ctx, "/status") if err != nil { return "", fmt.Errorf("GET /status: %w", err) } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("reading response body: %w", err) - } - var status tendermintStatusResponse - if err := json.Unmarshal(body, &status); err != nil { + var status rpc.StatusResult + if err := json.Unmarshal(raw, &status); err != nil { return "", fmt.Errorf("parsing /status response: %w", err) } diff --git a/sidecar/tasks/result_export.go b/sidecar/tasks/result_export.go index e210ba9..726aee4 100644 --- a/sidecar/tasks/result_export.go +++ b/sidecar/tasks/result_export.go @@ -1,13 +1,11 @@ package tasks import ( - "bytes" "compress/gzip" "context" "encoding/json" "fmt" "io" - "net/http" "os" "path/filepath" "strconv" @@ -18,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager" seiconfig "github.com/sei-protocol/sei-config" "github.com/sei-protocol/seictl/sidecar/engine" + "github.com/sei-protocol/seictl/sidecar/rpc" seis3 "github.com/sei-protocol/seictl/sidecar/s3" "github.com/sei-protocol/seilog" ) @@ -233,38 +232,20 @@ func (e *ResultExporter) collectResults(ctx context.Context, rpcEndpoint string, } func queryLatestHeight(ctx context.Context, rpcEndpoint string) (int64, error) { - url := rpcEndpoint + "/status" - ctx, cancel := context.WithTimeout(ctx, exportRPCTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return 0, err - } - - resp, err := http.DefaultClient.Do(req) + c := rpc.NewClient(rpcEndpoint, nil) + raw, err := c.Get(ctx, "/status") if err != nil { return 0, err } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(body)) - } - - var rpcResp struct { - SyncInfo struct { - LatestBlockHeight string `json:"latest_block_height"` - } `json:"sync_info"` - } - if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + var result rpc.StatusResult + if err := json.Unmarshal(raw, &result); err != nil { return 0, fmt.Errorf("decoding /status response: %w", err) } - h, err := strconv.ParseInt(rpcResp.SyncInfo.LatestBlockHeight, 10, 64) + h, err := strconv.ParseInt(result.SyncInfo.LatestBlockHeight, 10, 64) if err != nil { - return 0, fmt.Errorf("parsing latest_block_height %q: %w", rpcResp.SyncInfo.LatestBlockHeight, err) + return 0, fmt.Errorf("parsing latest_block_height %q: %w", result.SyncInfo.LatestBlockHeight, err) } if h <= 0 { return 0, fmt.Errorf("latest_block_height is %d, node may still be syncing", h) @@ -273,31 +254,11 @@ func queryLatestHeight(ctx context.Context, rpcEndpoint string) (int64, error) { } func queryBlockResults(ctx context.Context, rpcEndpoint string, height int64) (json.RawMessage, error) { - url := fmt.Sprintf("%s/block_results?height=%d", rpcEndpoint, height) - ctx, cancel := context.WithTimeout(ctx, exportRPCTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + c := rpc.NewClient(rpcEndpoint, nil) + body, err := c.GetRaw(ctx, fmt.Sprintf("/block_results?height=%d", height)) if err != nil { return nil, err } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - return json.RawMessage(body), nil } diff --git a/sidecar/tasks/result_export_test.go b/sidecar/tasks/result_export_test.go index dd9098d..44e3981 100644 --- a/sidecar/tasks/result_export_test.go +++ b/sidecar/tasks/result_export_test.go @@ -43,9 +43,9 @@ func fakeRPCServer(latestHeight int64) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/status": - fmt.Fprintf(w, `{"sync_info":{"latest_block_height":"%d"}}`, latestHeight) + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":-1,"result":{"sync_info":{"latest_block_height":"%d"}}}`, latestHeight) case r.URL.Path == "/block_results": - fmt.Fprint(w, `{}`) + fmt.Fprint(w, `{"jsonrpc":"2.0","id":-1,"result":{}}`) default: http.NotFound(w, r) } @@ -136,7 +136,7 @@ func TestExportRPCNon200Status(t *testing.T) { func TestQueryLatestHeight_ZeroHeight(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, `{"sync_info":{"latest_block_height":"0"}}`) + fmt.Fprint(w, `{"jsonrpc":"2.0","id":-1,"result":{"sync_info":{"latest_block_height":"0"}}}`) })) defer srv.Close() @@ -468,10 +468,13 @@ func fakeRPCAndBlockServer(latestHeight int64, appHash, lastResultsHash string, return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/status": - fmt.Fprintf(w, `{"sync_info":{"latest_block_height":"%d"}}`, latestHeight) + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":-1,"result":{"sync_info":{"latest_block_height":"%d"}}}`, latestHeight) case r.URL.Path == "/block": resp := map[string]any{ + "jsonrpc": "2.0", + "id": -1, "result": map[string]any{ + "block_id": map[string]any{"hash": ""}, "block": map[string]any{ "header": map[string]any{ "app_hash": appHash, @@ -483,6 +486,8 @@ func fakeRPCAndBlockServer(latestHeight int64, appHash, lastResultsHash string, json.NewEncoder(w).Encode(resp) case r.URL.Path == "/block_results": resp := map[string]any{ + "jsonrpc": "2.0", + "id": -1, "result": map[string]any{ "txs_results": txResults, }, diff --git a/sidecar/tasks/statesync.go b/sidecar/tasks/statesync.go index ba3e53b..5095bfc 100644 --- a/sidecar/tasks/statesync.go +++ b/sidecar/tasks/statesync.go @@ -4,16 +4,15 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "os" "path/filepath" "strconv" "strings" - "time" "github.com/sei-protocol/seictl/internal/patch" "github.com/sei-protocol/seictl/sidecar/engine" + "github.com/sei-protocol/seictl/sidecar/rpc" "github.com/sei-protocol/seilog" ) @@ -22,7 +21,6 @@ var ssLog = seilog.NewLogger("seictl", "task", "state-sync") const ( stateSyncMarkerFile = ".sei-sidecar-statesync-done" trustHeightOffset = 2000 - rpcTimeout = 10 * time.Second ) // StateSyncConfig holds the trust point and RPC servers for Tendermint state sync. @@ -35,21 +33,16 @@ type StateSyncConfig struct { BackfillBlocks int64 } -// HTTPDoer abstracts HTTP requests for testability. -type HTTPDoer interface { - Do(req *http.Request) (*http.Response, error) -} - // StateSyncConfigurer discovers a trust point from peers and writes the config file. type StateSyncConfigurer struct { homeDir string - httpClient HTTPDoer + httpClient rpc.HTTPDoer } // NewStateSyncConfigurer creates a configurer targeting the given home directory. -func NewStateSyncConfigurer(homeDir string, client HTTPDoer) *StateSyncConfigurer { +func NewStateSyncConfigurer(homeDir string, client rpc.HTTPDoer) *StateSyncConfigurer { if client == nil { - client = &http.Client{Timeout: rpcTimeout} + client = &http.Client{} } return &StateSyncConfigurer{homeDir: homeDir, httpClient: client} } @@ -199,34 +192,38 @@ func extractRPCHosts(peers []string, maxHosts int) []string { return hosts } +// rpcClientForHost builds an rpc.Client targeting a peer's RPC endpoint. +func (s *StateSyncConfigurer) rpcClientForHost(host string) *rpc.Client { + return rpc.NewClient(fmt.Sprintf("http://%s:26657", host), s.httpClient) +} + func (s *StateSyncConfigurer) queryLatestHeight(ctx context.Context, host string) (int64, error) { - url := fmt.Sprintf("http://%s:26657/status", host) - body, err := s.doGet(ctx, url) + raw, err := s.rpcClientForHost(host).Get(ctx, "/status") if err != nil { return 0, err } - var status tendermintStatusResponse - if err := json.Unmarshal(body, &status); err != nil { + var status rpc.StatusResult + if err := json.Unmarshal(raw, &status); err != nil { return 0, fmt.Errorf("parsing status response: %w", err) } - var height int64 - if _, err := fmt.Sscanf(status.SyncInfo.LatestBlockHeight, "%d", &height); err != nil { + height, err := strconv.ParseInt(status.SyncInfo.LatestBlockHeight, 10, 64) + if err != nil { return 0, fmt.Errorf("parsing height %q: %w", status.SyncInfo.LatestBlockHeight, err) } return height, nil } func (s *StateSyncConfigurer) queryBlockHash(ctx context.Context, host string, height int64) (string, error) { - url := fmt.Sprintf("http://%s:26657/block?height=%d", host, height) - body, err := s.doGet(ctx, url) + path := fmt.Sprintf("/block?height=%d", height) + raw, err := s.rpcClientForHost(host).Get(ctx, path) if err != nil { return "", err } - var block tendermintBlockResponse - if err := json.Unmarshal(body, &block); err != nil { + var block rpc.BlockResult + if err := json.Unmarshal(raw, &block); err != nil { return "", fmt.Errorf("parsing block response: %w", err) } hash := block.BlockID.Hash @@ -240,26 +237,6 @@ func (s *StateSyncConfigurer) queryBlockHash(ctx context.Context, host string, h return hash, nil } -func (s *StateSyncConfigurer) doGet(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("building request for %s: %w", url, err) - } - - resp, err := s.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("GET %s: %w", url, err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response from %s: %w", url, err) - } - - return body, nil -} - func writeStateSyncToConfig(homeDir string, cfg StateSyncConfig) error { configPath := filepath.Join(homeDir, "config", "config.toml") ss := map[string]any{ diff --git a/sidecar/tasks/statesync_test.go b/sidecar/tasks/statesync_test.go index fda9e40..cf4438a 100644 --- a/sidecar/tasks/statesync_test.go +++ b/sidecar/tasks/statesync_test.go @@ -37,6 +37,11 @@ func jsonResponse(body string) *http.Response { } } +// wrapResult wraps inner JSON in the CometBFT JSON-RPC envelope. +func wrapResult(inner string) string { + return fmt.Sprintf(`{"jsonrpc":"2.0","id":-1,"result":%s}`, inner) +} + func setupPeersInConfig(t *testing.T, homeDir string, peers []string) { t.Helper() configDir := filepath.Join(homeDir, "config") @@ -56,12 +61,12 @@ func TestStateSyncConfigurer_Success(t *testing.T) { hash := generateBlockHash() mock := &mockHTTPDoer{ responses: map[string]*http.Response{ - "http://1.2.3.4:26657/status": jsonResponse(`{ + "http://1.2.3.4:26657/status": jsonResponse(wrapResult(`{ "sync_info": {"latest_block_height": "10000"} - }`), - "http://1.2.3.4:26657/block?height=8000": jsonResponse(fmt.Sprintf(`{ + }`)), + "http://1.2.3.4:26657/block?height=8000": jsonResponse(wrapResult(fmt.Sprintf(`{ "block_id": {"hash": %q} - }`, hash)), + }`, hash))), }, } @@ -124,12 +129,12 @@ func TestStateSyncConfigurer_LowHeight(t *testing.T) { hash := generateBlockHash() mock := &mockHTTPDoer{ responses: map[string]*http.Response{ - "http://10.0.0.1:26657/status": jsonResponse(`{ + "http://10.0.0.1:26657/status": jsonResponse(wrapResult(`{ "sync_info": {"latest_block_height": "500"} - }`), - "http://10.0.0.1:26657/block?height=1": jsonResponse(fmt.Sprintf(`{ + }`)), + "http://10.0.0.1:26657/block?height=1": jsonResponse(wrapResult(fmt.Sprintf(`{ "block_id": {"hash": %q} - }`, hash)), + }`, hash))), }, } @@ -170,12 +175,12 @@ func TestStateSyncConfigurer_InvalidBlockHash(t *testing.T) { mock := &mockHTTPDoer{ responses: map[string]*http.Response{ - "http://1.2.3.4:26657/status": jsonResponse(`{ + "http://1.2.3.4:26657/status": jsonResponse(wrapResult(`{ "sync_info": {"latest_block_height": "10000"} - }`), - "http://1.2.3.4:26657/block?height=8000": jsonResponse(fmt.Sprintf(`{ + }`)), + "http://1.2.3.4:26657/block?height=8000": jsonResponse(wrapResult(fmt.Sprintf(`{ "block_id": {"hash": %q} - }`, tt.hash)), + }`, tt.hash))), }, } @@ -258,12 +263,12 @@ func TestStateSyncConfigurer_Handler(t *testing.T) { hash := generateBlockHash() mock := &mockHTTPDoer{ responses: map[string]*http.Response{ - "http://1.2.3.4:26657/status": jsonResponse(`{ + "http://1.2.3.4:26657/status": jsonResponse(wrapResult(`{ "sync_info": {"latest_block_height": "5000"} - }`), - "http://1.2.3.4:26657/block?height=3000": jsonResponse(fmt.Sprintf(`{ + }`)), + "http://1.2.3.4:26657/block?height=3000": jsonResponse(wrapResult(fmt.Sprintf(`{ "block_id": {"hash": %q} - }`, hash)), + }`, hash))), }, } @@ -288,12 +293,12 @@ func TestStateSyncConfigurer_NetworkWithBackfill(t *testing.T) { hash := generateBlockHash() mock := &mockHTTPDoer{ responses: map[string]*http.Response{ - "http://1.2.3.4:26657/status": jsonResponse(`{ + "http://1.2.3.4:26657/status": jsonResponse(wrapResult(`{ "sync_info": {"latest_block_height": "10000"} - }`), - "http://1.2.3.4:26657/block?height=8000": jsonResponse(fmt.Sprintf(`{ + }`)), + "http://1.2.3.4:26657/block?height=8000": jsonResponse(wrapResult(fmt.Sprintf(`{ "block_id": {"hash": %q} - }`, hash)), + }`, hash))), }, } @@ -332,9 +337,9 @@ func TestStateSyncConfigurer_LocalSnapshot(t *testing.T) { hash := generateBlockHash() mock := &mockHTTPDoer{ responses: map[string]*http.Response{ - "http://1.2.3.4:26657/block?height=198030000": jsonResponse(fmt.Sprintf(`{ + "http://1.2.3.4:26657/block?height=198030000": jsonResponse(wrapResult(fmt.Sprintf(`{ "block_id": {"hash": %q} - }`, hash)), + }`, hash))), }, } diff --git a/sidecar/tasks/tendermint.go b/sidecar/tasks/tendermint.go deleted file mode 100644 index f284fe4..0000000 --- a/sidecar/tasks/tendermint.go +++ /dev/null @@ -1,19 +0,0 @@ -package tasks - -// tendermintStatusResponse is the minimal shape of the Tendermint /status JSON response, -// shared by peer discovery (node ID) and state sync (latest height). -type tendermintStatusResponse struct { - NodeInfo struct { - ID string `json:"id"` - } `json:"node_info"` - SyncInfo struct { - LatestBlockHeight string `json:"latest_block_height"` - } `json:"sync_info"` -} - -// tendermintBlockResponse is the minimal shape of the Tendermint /block JSON response. -type tendermintBlockResponse struct { - BlockID struct { - Hash string `json:"hash"` - } `json:"block_id"` -}