diff --git a/rpc/ws/signatureSubscribe.go b/rpc/ws/signatureSubscribe.go index 1fdc2707..0cb3d0ed 100644 --- a/rpc/ws/signatureSubscribe.go +++ b/rpc/ws/signatureSubscribe.go @@ -15,19 +15,97 @@ package ws import ( + "bytes" "context" + "fmt" + + stdjson "github.com/goccy/go-json" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" ) +// signatureReceivedMarker is the literal value the validator emits +// for the "received" notification when EnableReceivedNotification is +// set on the subscription request. +const signatureReceivedMarker = "receivedSignature" + type SignatureResult struct { Context struct { Slot uint64 } `json:"context"` - Value struct { - Err any `json:"err"` - } `json:"value"` + Value SignatureValue `json:"value"` +} + +// SignatureValue carries the two shapes a signatureNotification can take. +// +// When EnableReceivedNotification is false (the RPC default) only the +// status shape is emitted and `Err` is the field consumers need +// (nil == success). +// +// When EnableReceivedNotification is true the validator may additionally +// emit a "receivedSignature" string before the final status; that one +// sets `ReceivedSignature` to true and `Err` is left at its zero value. +type SignatureValue struct { + // Err is the transaction execution error reported in a status + // notification. nil means the transaction succeeded. + Err any `json:"err,omitempty"` + + // ReceivedSignature is true when the notification is the "received" + // marker the validator emits once it observes the transaction in + // its mempool. Only sent when EnableReceivedNotification is set on + // SignatureSubscribeOpts. + ReceivedSignature bool `json:"-"` +} + +// UnmarshalJSON dispatches on the wire shape of the notification value: +// a JSON string "receivedSignature" → ReceivedSignature=true; a JSON +// object → status, fill Err. +func (v *SignatureValue) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || string(trimmed) == "null" { + return nil + } + switch trimmed[0] { + case '"': + var s string + if err := stdjson.Unmarshal(trimmed, &s); err != nil { + return fmt.Errorf("signatureNotification value: %w", err) + } + if s != signatureReceivedMarker { + return fmt.Errorf("signatureNotification value: unexpected marker %q", s) + } + v.ReceivedSignature = true + return nil + case '{': + // Alias avoids recursion into this UnmarshalJSON. + type alias struct { + Err any `json:"err"` + } + var a alias + if err := stdjson.Unmarshal(trimmed, &a); err != nil { + return fmt.Errorf("signatureNotification value: %w", err) + } + v.Err = a.Err + return nil + default: + return fmt.Errorf("signatureNotification value: unexpected JSON %s", string(trimmed)) + } +} + +// SignatureSubscribeOpts mirrors the optional configuration object the +// signatureSubscribe RPC method accepts. See +// https://solana.com/docs/rpc/websocket/signaturesubscribe. +type SignatureSubscribeOpts struct { + // Commitment selects the bank state the validator should use when + // deciding the transaction has reached the requested level. + Commitment rpc.CommitmentType + + // EnableReceivedNotification opts into the additional "received" + // notification emitted as soon as the validator picks the + // transaction up in its mempool (in addition to the final status + // notification). Defaults to false to match the RPC default. + EnableReceivedNotification bool } // SignatureSubscribe subscribes to a transaction signature to receive @@ -36,11 +114,28 @@ type SignatureResult struct { func (cl *Client) SignatureSubscribe( signature solana.Signature, // Transaction Signature. commitment rpc.CommitmentType, // (optional) +) (*SignatureSubscription, error) { + return cl.SignatureSubscribeWithOpts(signature, &SignatureSubscribeOpts{ + Commitment: commitment, + }) +} + +// SignatureSubscribeWithOpts subscribes to a transaction signature and +// forwards the full SignatureSubscribeOpts configuration object to the +// validator, including EnableReceivedNotification. +func (cl *Client) SignatureSubscribeWithOpts( + signature solana.Signature, + opts *SignatureSubscribeOpts, ) (*SignatureSubscription, error) { params := []any{signature.String()} conf := map[string]any{} - if commitment != "" { - conf["commitment"] = commitment + if opts != nil { + if opts.Commitment != "" { + conf["commitment"] = opts.Commitment + } + if opts.EnableReceivedNotification { + conf["enableReceivedNotification"] = true + } } genSub, err := cl.subscribe( diff --git a/rpc/ws/signatureSubscribe_test.go b/rpc/ws/signatureSubscribe_test.go new file mode 100644 index 00000000..0e5c9659 --- /dev/null +++ b/rpc/ws/signatureSubscribe_test.go @@ -0,0 +1,80 @@ +// 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" +) + +// TestSignatureValueUnmarshalStatus covers the default notification shape: +// `{ "value": { "err": null } }` for a successful transaction. +func TestSignatureValueUnmarshalStatus(t *testing.T) { + var res SignatureResult + require.NoError(t, stdjson.Unmarshal([]byte(`{ + "context": {"slot": 42}, + "value": {"err": null} + }`), &res)) + require.Equal(t, uint64(42), res.Context.Slot) + require.Nil(t, res.Value.Err) + require.False(t, res.Value.ReceivedSignature) +} + +// TestSignatureValueUnmarshalStatusWithErr covers the failed-tx branch +// where `err` is a non-null object. +func TestSignatureValueUnmarshalStatusWithErr(t *testing.T) { + var res SignatureResult + require.NoError(t, stdjson.Unmarshal([]byte(`{ + "context": {"slot": 42}, + "value": {"err": {"InstructionError": [0, "InvalidAccountData"]}} + }`), &res)) + require.NotNil(t, res.Value.Err) + require.False(t, res.Value.ReceivedSignature) +} + +// TestSignatureValueUnmarshalReceived covers the second notification +// shape introduced by EnableReceivedNotification: `"value": +// "receivedSignature"`. Without the custom unmarshaler the default +// decoder would fail because the field is typed as a struct. +func TestSignatureValueUnmarshalReceived(t *testing.T) { + var res SignatureResult + require.NoError(t, stdjson.Unmarshal([]byte(`{ + "context": {"slot": 7}, + "value": "receivedSignature" + }`), &res)) + require.True(t, res.Value.ReceivedSignature) + require.Nil(t, res.Value.Err) +} + +// TestSignatureValueUnmarshalUnknownMarker rejects unexpected string +// markers so a future RPC change surfaces as a decode error rather +// than a silent miscategorisation. +func TestSignatureValueUnmarshalUnknownMarker(t *testing.T) { + var v SignatureValue + err := v.UnmarshalJSON([]byte(`"someUnknownMarker"`)) + require.Error(t, err) +} + +// TestSignatureValueUnmarshalNull treats null as a no-op (rather than +// an error) so the field can be omitted by an upstream RPC without +// breaking notification dispatch. +func TestSignatureValueUnmarshalNull(t *testing.T) { + var v SignatureValue + require.NoError(t, v.UnmarshalJSON([]byte(`null`))) + require.False(t, v.ReceivedSignature) + require.Nil(t, v.Err) +}