From ed2ca049b035321aecfd1f8862615c858ac02f8a Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Mon, 28 Sep 2026 10:30:26 -0400 Subject: [PATCH] Simplify GetAccountDataAfterBlock --- solana/client.go | 153 +++++++----------------------------------- solana/client_test.go | 71 ++++++++++++++++++++ 2 files changed, 96 insertions(+), 128 deletions(-) diff --git a/solana/client.go b/solana/client.go index e9aa342..4f10346 100644 --- a/solana/client.go +++ b/solana/client.go @@ -20,6 +20,8 @@ const ( // Reference: https://github.com/solana-labs/solana/blob/71e9958e061493d7545bd28d4ac7a85aaed6ffbb/client/src/rpc_custom_error.rs#L11 rpcNodeUnhealthyCode = -32005 + minContextSlotNotReachedCode = -32016 + invalidParamCode = -32602 // Highest transaction version the client can parse. RPC methods returning @@ -222,31 +224,6 @@ func (c *client) call(out interface{}, method string, params ...interface{}) err return err } -func (c *client) callBatch(method string, requests jsonrpc.RPCRequests) (map[int]jsonrpc.RPCResponse, error) { - var returnValue map[int]jsonrpc.RPCResponse - - _, err := c.retrier.Retry(func() error { - responses, err := c.client.CallBatch(requests) - if err != nil { - return c.handleRpcError(method, err) - } - - responseByID := make(map[int]jsonrpc.RPCResponse) - for _, response := range responses { - if response.Error != nil { - return c.handleRpcError(method, response.Error) - } - - responseByID[response.ID] = *response - } - - returnValue = responseByID - return nil - }) - - return returnValue, err -} - func (c *client) handleRpcError(method string, err error) error { rpcErr, ok := err.(*jsonrpc.RPCError) if !ok { @@ -637,87 +614,21 @@ func (c *client) GetAccountInfo(account ed25519.PublicKey, commitment Commitment } func (c *client) GetAccountDataAfterBlock(account ed25519.PublicKey, slot uint64) ([]byte, uint64, error) { - batchMethodName := "getAccountDataAfterBlock" - - // Setup individual requests to send in the batch. In particular, we're fetching - // the account info along with additional node metadata to decrease the chance of - // getting stale/incorrect result due to the node being behind or on a micro fork. + // Require finalized state evaluated after the provided block, so an RPC node + // that's behind can't return account data from before it. // - // Note: These checks don't protect us from a malicious RPC node - - getBlockHeightRequest := jsonrpc.NewRequest("getBlockHeight", []interface{}{CommitmentFinalized}) - - getBlockRpcConfig := struct { - Encoding string `json:"encoding"` - TransactionDetails string `json:"transactionDetails"` - Rewards bool `json:"rewards"` - MaxSupportedTransactionVersion int `json:"maxSupportedTransactionVersion"` - }{ - Encoding: "base64", - TransactionDetails: "none", - Rewards: false, - MaxSupportedTransactionVersion: maxSupportedTransactionVersion, - } - getBlockRequest := jsonrpc.NewRequest("getBlock", slot, getBlockRpcConfig) - - getAccountInfoRpcConfig := struct { - Commitment Commitment `json:"commitment"` - Encoding string `json:"encoding"` + // Note: This doesn't protect us from a malicious RPC node + rpcConfig := struct { + Commitment string `json:"commitment"` + Encoding string `json:"encoding"` + MinContextSlot uint64 `json:"minContextSlot"` }{ - Commitment: CommitmentFinalized, - Encoding: "base64", + Commitment: CommitmentFinalized.Commitment, + Encoding: "base64", + MinContextSlot: slot + 1, } - getAccountInfoRequest := jsonrpc.NewRequest("getAccountInfo", base58.Encode(account[:]), getAccountInfoRpcConfig) - // Submit the batched RPC call - - responsesByID, err := c.callBatch( - batchMethodName, - jsonrpc.RPCRequests{ - getBlockHeightRequest, - getBlockRequest, - getAccountInfoRequest, - }, - ) - if err != nil { - return nil, 0, err - } - - // Parse each individual RPC response - - if len(responsesByID) != 3 { - return nil, 0, errors.New("received unexpected number of response objects") - } - - getBlockHeightResp, ok := responsesByID[getBlockHeightRequest.ID] - if !ok { - return nil, 0, errors.New("getBlockHeight response missing") - } - - var currentBlockHeight uint64 - if err := getBlockHeightResp.GetObject(¤tBlockHeight); err != nil { - return nil, 0, errors.Wrap(err, "invalid getBlockHeight response") - } - - getBlockResp, ok := responsesByID[getBlockRequest.ID] - if !ok { - return nil, 0, errors.New("getAccount response missing") - } - - type getBlockResponseBody struct { - BlockHeight uint64 `json:"blockHeight"` - } - var unmarshalledGetBlockResp getBlockResponseBody - if err := getBlockResp.GetObject(&unmarshalledGetBlockResp); err != nil { - return nil, 0, errors.New("invalid getBlock response") - } - - getAccountInfoResp, ok := responsesByID[getAccountInfoRequest.ID] - if !ok { - return nil, 0, errors.New("getAccountInfo response missing") - } - - type getAccountInfoRespBody struct { + var resp struct { Context struct { Slot uint64 `json:"slot"` } `json:"context"` @@ -725,41 +636,27 @@ func (c *client) GetAccountDataAfterBlock(account ed25519.PublicKey, slot uint64 Data []string `json:"data"` } `json:"value"` } - var unmarshalledGetAccountInfoResp getAccountInfoRespBody - if err := getAccountInfoResp.GetObject(&unmarshalledGetAccountInfoResp); err != nil { - return nil, 0, errors.Wrap(err, "invalid getAccountInfo response") - } - - // Perform node state safety checks - - // We shouldn't hit this case. The node shouldn't know about the block if - // its finalized blockheight is less than block we've queried for. - if currentBlockHeight < unmarshalledGetBlockResp.BlockHeight { - return nil, 0, ErrStaleData - } - - // Enforce 32 additional finalized blocks on top of the desired block. We're - // effectively enforcing 2x the number of finalized confirmations. - if currentBlockHeight-unmarshalledGetBlockResp.BlockHeight <= 32 { - return nil, 0, ErrStaleData + if err := c.call(&resp, "getAccountInfo", base58.Encode(account[:]), rpcConfig); err != nil { + jsonRPCErr, ok := err.(*jsonrpc.RPCError) + if ok && jsonRPCErr.Code == minContextSlotNotReachedCode { + return nil, 0, ErrStaleData + } + return nil, 0, errors.Wrap(err, "getAccountInfo() failed to send request") } - // This shouldn't happen given the prior checks. It indicates the RPC node - // isn't evaluating account info at the latest finalized block. Regardless, - // it must fail the call because we want account data after a given block. - if unmarshalledGetAccountInfoResp.Context.Slot <= slot { + // Defensive in case the RPC node doesn't honour minContextSlot + if resp.Context.Slot <= slot { return nil, 0, ErrStaleData } - // Everything checks out, so return the account data, if available - if unmarshalledGetAccountInfoResp.Value == nil { - return nil, unmarshalledGetAccountInfoResp.Context.Slot, ErrNoAccountInfo + if resp.Value == nil { + return nil, resp.Context.Slot, ErrNoAccountInfo } - rawData, err := base64.StdEncoding.DecodeString(unmarshalledGetAccountInfoResp.Value.Data[0]) + rawData, err := base64.StdEncoding.DecodeString(resp.Value.Data[0]) if err != nil { return nil, 0, errors.Wrap(err, "invalid base64 encoded account data") } - return rawData, unmarshalledGetAccountInfoResp.Context.Slot, nil + return rawData, resp.Context.Slot, nil } func (c *client) GetSignaturesForAddress(account ed25519.PublicKey, commitment Commitment, limit uint64, before, until string) ([]*TransactionSignature, error) { diff --git a/solana/client_test.go b/solana/client_test.go index 8a1a914..b273363 100644 --- a/solana/client_test.go +++ b/solana/client_test.go @@ -1,9 +1,15 @@ package solana import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSignatureStatus(t *testing.T) { @@ -73,3 +79,68 @@ func TestSignatureStatus(t *testing.T) { assert.Equal(t, tc.finalized, tc.s.Finalized()) } } + +func TestGetAccountDataAfterBlock(t *testing.T) { + account := make(ed25519.PublicKey, ed25519.PublicKeySize) + data := []byte{1, 2, 3} + + for _, tc := range []struct { + name string + response string + expected []byte + slot uint64 + err error + }{ + { + name: "success", + response: `{"jsonrpc":"2.0","id":0,"result":{"context":{"slot":101},"value":{"data":["` + base64.StdEncoding.EncodeToString(data) + `","base64"]}}}`, + expected: data, + slot: 101, + }, + { + name: "min context slot not reached", + response: `{"jsonrpc":"2.0","id":0,"error":{"code":-32016,"message":"Minimum context slot has not been reached"}}`, + err: ErrStaleData, + }, + { + name: "min context slot not honoured", + response: `{"jsonrpc":"2.0","id":0,"result":{"context":{"slot":100},"value":{"data":["` + base64.StdEncoding.EncodeToString(data) + `","base64"]}}}`, + err: ErrStaleData, + }, + { + name: "no account", + response: `{"jsonrpc":"2.0","id":0,"result":{"context":{"slot":101},"value":null}}`, + slot: 101, + err: ErrNoAccountInfo, + }, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + if assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) { + assert.Equal(t, "getAccountInfo", req.Method) + if assert.Len(t, req.Params, 2) { + assert.JSONEq(t, `"11111111111111111111111111111111"`, string(req.Params[0])) + assert.JSONEq(t, `{"commitment":"finalized","encoding":"base64","minContextSlot":101}`, string(req.Params[1])) + } + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.response)) + })) + defer server.Close() + + actual, slot, err := New(server.URL).GetAccountDataAfterBlock(account, 100) + if tc.err != nil { + assert.ErrorIs(t, err, tc.err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.expected, actual) + assert.Equal(t, tc.slot, slot) + }) + } +}