From 84919c94386891ab9dd8f4c62ebb362d988404f1 Mon Sep 17 00:00:00 2001 From: ozpool Date: Wed, 13 May 2026 12:20:02 +0530 Subject: [PATCH] feat(rpc): forward MinContextSlot in getProgramAccounts and getTokenAccounts Three Solana RPC methods accept an optional `minContextSlot` per the spec but the Go bindings dropped it on the floor: - getProgramAccounts (via GetProgramAccountsOpts) - getTokenAccountsByOwner (via GetTokenAccountsOpts) - getTokenAccountsByDelegate (via GetTokenAccountsOpts) Without `minContextSlot`, callers on lagging RPC nodes can read state from a slot older than what they have already observed, which breaks read-your-own-writes and slot-pinned pagination flows. Follow-up to #245, which added the same field to GetMultipleAccountsWithOpts. Pattern is identical: a new `MinContextSlot *uint64` on the existing opts struct, forwarded into the params object only when non-nil. Purely additive. Tests pin the wire shape for each of the three methods using the existing mockJSONRPC harness. --- rpc/getMinContextSlot_test.go | 138 ++++++++++++++++++++++++++++++ rpc/getProgramAccounts.go | 3 + rpc/getTokenAccountsByDelegate.go | 6 ++ rpc/getTokenAccountsByOwner.go | 3 + rpc/types.go | 3 + 5 files changed, 153 insertions(+) create mode 100644 rpc/getMinContextSlot_test.go diff --git a/rpc/getMinContextSlot_test.go b/rpc/getMinContextSlot_test.go new file mode 100644 index 000000000..cc5473aaa --- /dev/null +++ b/rpc/getMinContextSlot_test.go @@ -0,0 +1,138 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gagliardetto/solana-go" + stdjson "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClient_GetProgramAccountsWithOpts_MinContextSlot pins that the +// minContextSlot opt is forwarded into the params object. +func TestClient_GetProgramAccountsWithOpts_MinContextSlot(t *testing.T) { + responseBody := `[{"account":{"data":["dGVzdA==","base64"],"executable":true,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":206},"pubkey":"7xLk17EQQ5KLDLDe44wCmupJKJjTGd8hs3eSVVhCx932"}]` + server, closer := mockJSONRPC(t, stdjson.RawMessage(wrapIntoRPC(responseBody))) + defer closer() + client := New(server.URL) + + pubkeyString := "7xLk17EQQ5KLDLDe44wCmupJKJjTGd8hs3eSVVhCx932" + pubKey := solana.MustPublicKeyFromBase58(pubkeyString) + + minSlot := uint64(123456789) + _, err := client.GetProgramAccountsWithOpts( + context.Background(), + pubKey, + &GetProgramAccountsOpts{ + MinContextSlot: &minSlot, + }, + ) + require.NoError(t, err) + + reqBody := server.RequestBody(t) + reqBody["id"] = any(nil) + + assert.Equal(t, + map[string]any{ + "id": any(nil), + "jsonrpc": "2.0", + "method": "getProgramAccounts", + "params": []any{ + pubkeyString, + map[string]any{ + "encoding": "base64", + "minContextSlot": float64(minSlot), + }, + }, + }, + reqBody, + ) +} + +// TestClient_GetTokenAccountsByOwner_MinContextSlot pins that the +// minContextSlot opt is forwarded into the params object for the owner variant. +func TestClient_GetTokenAccountsByOwner_MinContextSlot(t *testing.T) { + responseBody := `{"context":{"slot":1},"value":[]}` + server, closer := mockJSONRPC(t, stdjson.RawMessage(wrapIntoRPC(responseBody))) + defer closer() + client := New(server.URL) + + ownerString := "7xLk17EQQ5KLDLDe44wCmupJKJjTGd8hs3eSVVhCx932" + owner := solana.MustPublicKeyFromBase58(ownerString) + programIDString := "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + programID := solana.MustPublicKeyFromBase58(programIDString) + + minSlot := uint64(987654321) + _, err := client.GetTokenAccountsByOwner( + context.Background(), + owner, + &GetTokenAccountsConfig{ProgramId: &programID}, + &GetTokenAccountsOpts{MinContextSlot: &minSlot}, + ) + require.NoError(t, err) + + reqBody := server.RequestBody(t) + reqBody["id"] = any(nil) + + assert.Equal(t, + map[string]any{ + "id": any(nil), + "jsonrpc": "2.0", + "method": "getTokenAccountsByOwner", + "params": []any{ + ownerString, + map[string]any{"programId": programIDString}, + map[string]any{ + "encoding": "base64", + "minContextSlot": float64(minSlot), + }, + }, + }, + reqBody, + ) +} + +// TestClient_GetTokenAccountsByDelegate_MinContextSlot pins the same for the +// delegate variant. +func TestClient_GetTokenAccountsByDelegate_MinContextSlot(t *testing.T) { + responseBody := `{"context":{"slot":1},"value":[]}` + server, closer := mockJSONRPC(t, stdjson.RawMessage(wrapIntoRPC(responseBody))) + defer closer() + client := New(server.URL) + + delegateString := "7xLk17EQQ5KLDLDe44wCmupJKJjTGd8hs3eSVVhCx932" + delegate := solana.MustPublicKeyFromBase58(delegateString) + programIDString := "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + programID := solana.MustPublicKeyFromBase58(programIDString) + + minSlot := uint64(42) + _, err := client.GetTokenAccountsByDelegate( + context.Background(), + delegate, + &GetTokenAccountsConfig{ProgramId: &programID}, + &GetTokenAccountsOpts{MinContextSlot: &minSlot}, + ) + require.NoError(t, err) + + reqBody := server.RequestBody(t) + reqBody["id"] = any(nil) + + assert.Equal(t, + map[string]any{ + "id": any(nil), + "jsonrpc": "2.0", + "method": "getTokenAccountsByDelegate", + "params": []any{ + delegateString, + map[string]any{"programId": programIDString}, + map[string]any{ + "encoding": "base64", + "minContextSlot": float64(minSlot), + }, + }, + }, + reqBody, + ) +} diff --git a/rpc/getProgramAccounts.go b/rpc/getProgramAccounts.go index 4c584602b..994939c05 100644 --- a/rpc/getProgramAccounts.go +++ b/rpc/getProgramAccounts.go @@ -90,6 +90,9 @@ func buildGetProgramAccountsParams(publicKey solana.PublicKey, opts *GetProgramA if opts.SortResults != nil { obj["sortResults"] = *opts.SortResults } + if opts.MinContextSlot != nil { + obj["minContextSlot"] = *opts.MinContextSlot + } } return []any{publicKey, obj} } diff --git a/rpc/getTokenAccountsByDelegate.go b/rpc/getTokenAccountsByDelegate.go index 4fbe2fd78..519a95d81 100644 --- a/rpc/getTokenAccountsByDelegate.go +++ b/rpc/getTokenAccountsByDelegate.go @@ -37,6 +37,9 @@ type GetTokenAccountsOpts struct { Encoding solana.EncodingType `json:"encoding,omitempty"` DataSlice *DataSlice `json:"dataSlice,omitempty"` + + // The minimum slot that the request can be evaluated at. + MinContextSlot *uint64 `json:"minContextSlot,omitempty"` } // GetTokenAccountsByDelegate returns all SPL Token accounts by approved Delegate. @@ -87,6 +90,9 @@ func (cl *Client) GetTokenAccountsByDelegate( return nil, errors.New("cannot use dataSlice with EncodingJSONParsed") } } + if opts.MinContextSlot != nil { + optsObj["minContextSlot"] = *opts.MinContextSlot + } if len(optsObj) > 0 { params = append(params, optsObj) } diff --git a/rpc/getTokenAccountsByOwner.go b/rpc/getTokenAccountsByOwner.go index 5aaf58128..c26354919 100644 --- a/rpc/getTokenAccountsByOwner.go +++ b/rpc/getTokenAccountsByOwner.go @@ -69,6 +69,9 @@ func (cl *Client) GetTokenAccountsByOwner( return nil, errors.New("cannot use dataSlice with EncodingJSONParsed") } } + if opts.MinContextSlot != nil { + optsObj["minContextSlot"] = *opts.MinContextSlot + } if len(optsObj) > 0 { params = append(params, optsObj) } diff --git a/rpc/types.go b/rpc/types.go index 1cb06dbda..de3d77b63 100644 --- a/rpc/types.go +++ b/rpc/types.go @@ -466,6 +466,9 @@ type GetProgramAccountsOpts struct { // Sort the results (useful for deterministic pagination). SortResults *bool `json:"sortResults,omitempty"` + + // The minimum slot that the request can be evaluated at. + MinContextSlot *uint64 `json:"minContextSlot,omitempty"` } type GetProgramAccountsResult []*KeyedAccount