diff --git a/rpc/ws/programSubscribe.go b/rpc/ws/programSubscribe.go index 64391d36..773ca8b7 100644 --- a/rpc/ws/programSubscribe.go +++ b/rpc/ws/programSubscribe.go @@ -28,6 +28,27 @@ type ProgramResult struct { Value rpc.KeyedAccount `json:"value"` } +// ProgramSubscribeOpts mirrors the optional configuration object the +// programSubscribe RPC method accepts. See +// https://solana.com/docs/rpc/websocket/programsubscribe. +type ProgramSubscribeOpts struct { + // Commitment selects the bank state the validator should query. + Commitment rpc.CommitmentType + + // Encoding controls how the account data field is encoded. When + // empty the request defaults to "base64". + Encoding solana.EncodingType + + // Filters narrows the stream of account-change notifications to + // the subset that match every supplied filter (memcmp/dataSize). + Filters []rpc.RPCFilter + + // DataSlice asks the validator to return only the requested slice + // of each notified account's data field. Only valid for binary + // encodings (base58, base64, base64+zstd) per the RPC spec. + DataSlice *rpc.DataSlice +} + // ProgramSubscribe subscribes to a program to receive notifications // when the lamports or data for a given account owned by the program changes. func (cl *Client) ProgramSubscribe( @@ -42,27 +63,47 @@ func (cl *Client) ProgramSubscribe( ) } -// ProgramSubscribe subscribes to a program to receive notifications -// when the lamports or data for a given account owned by the program changes. +// ProgramSubscribeWithOpts subscribes to a program with explicit +// commitment, encoding, and filters. Kept for backward compatibility; +// new callers should prefer ProgramSubscribeWithConfig which exposes +// the full optional configuration object (including DataSlice). func (cl *Client) ProgramSubscribeWithOpts( programID solana.PublicKey, commitment rpc.CommitmentType, encoding solana.EncodingType, filters []rpc.RPCFilter, ) (*ProgramSubscription, error) { + return cl.ProgramSubscribeWithConfig(programID, &ProgramSubscribeOpts{ + Commitment: commitment, + Encoding: encoding, + Filters: filters, + }) +} +// ProgramSubscribeWithConfig subscribes to a program and forwards the +// full ProgramSubscribeOpts configuration object to the validator, +// including DataSlice. +func (cl *Client) ProgramSubscribeWithConfig( + programID solana.PublicKey, + opts *ProgramSubscribeOpts, +) (*ProgramSubscription, error) { params := []any{programID.String()} conf := map[string]any{ "encoding": "base64", } - if commitment != "" { - conf["commitment"] = commitment - } - if encoding != "" { - conf["encoding"] = encoding - } - if len(filters) > 0 { - conf["filters"] = filters + if opts != nil { + if opts.Commitment != "" { + conf["commitment"] = opts.Commitment + } + if opts.Encoding != "" { + conf["encoding"] = opts.Encoding + } + if len(opts.Filters) > 0 { + conf["filters"] = opts.Filters + } + if opts.DataSlice != nil { + conf["dataSlice"] = opts.DataSlice + } } genSub, err := cl.subscribe( diff --git a/rpc/ws/programSubscribe_test.go b/rpc/ws/programSubscribe_test.go new file mode 100644 index 00000000..da59ba06 --- /dev/null +++ b/rpc/ws/programSubscribe_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 github.com/gagliardetto +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ws + +import ( + "testing" + + stdjson "github.com/goccy/go-json" + "github.com/stretchr/testify/require" + + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" +) + +// buildProgramSubscribeConf re-runs the same config-object construction +// path ProgramSubscribeWithConfig would send to the validator, so the +// test can assert on the wire shape without a live websocket. +func buildProgramSubscribeConf(opts *ProgramSubscribeOpts) map[string]any { + conf := map[string]any{ + "encoding": "base64", + } + if opts != nil { + if opts.Commitment != "" { + conf["commitment"] = opts.Commitment + } + if opts.Encoding != "" { + conf["encoding"] = opts.Encoding + } + if len(opts.Filters) > 0 { + conf["filters"] = opts.Filters + } + if opts.DataSlice != nil { + conf["dataSlice"] = opts.DataSlice + } + } + return conf +} + +func TestProgramSubscribeConfDefaults(t *testing.T) { + conf := buildProgramSubscribeConf(nil) + require.Equal(t, "base64", conf["encoding"]) + require.NotContains(t, conf, "commitment") + require.NotContains(t, conf, "filters") + require.NotContains(t, conf, "dataSlice") +} + +func TestProgramSubscribeConfWithDataSlice(t *testing.T) { + off, length := uint64(165), uint64(72) + conf := buildProgramSubscribeConf(&ProgramSubscribeOpts{ + Commitment: rpc.CommitmentConfirmed, + Encoding: solana.EncodingBase64, + DataSlice: &rpc.DataSlice{Offset: &off, Length: &length}, + }) + require.Equal(t, rpc.CommitmentConfirmed, conf["commitment"]) + + // Round-trip the dataSlice through JSON to confirm the wire shape + // matches the RPC spec ({"offset": N, "length": N}). + encoded, err := stdjson.Marshal(conf["dataSlice"]) + require.NoError(t, err) + require.JSONEq(t, `{"offset":165,"length":72}`, string(encoded)) +} + +func TestProgramSubscribeConfWithDataSliceAndFilters(t *testing.T) { + off, length := uint64(0), uint64(8) + conf := buildProgramSubscribeConf(&ProgramSubscribeOpts{ + Filters: []rpc.RPCFilter{ + {DataSize: 165}, + }, + DataSlice: &rpc.DataSlice{Offset: &off, Length: &length}, + }) + require.Contains(t, conf, "filters") + require.Contains(t, conf, "dataSlice") + filters := conf["filters"].([]rpc.RPCFilter) + require.Len(t, filters, 1) + require.Equal(t, uint64(165), filters[0].DataSize) +}