Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 100 additions & 5 deletions rpc/ws/signatureSubscribe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
80 changes: 80 additions & 0 deletions rpc/ws/signatureSubscribe_test.go
Original file line number Diff line number Diff line change
@@ -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)
}