Skip to content

Commit 91cef9e

Browse files
committed
feat(ws): support enableReceivedNotification in SignatureSubscribe
Add SignatureSubscribeWithOpts and SignatureSubscribeOpts, mirroring the optional configuration object the signatureSubscribe RPC method accepts. EnableReceivedNotification opts the subscription into the additional "received" notification the validator emits as soon as the transaction enters its mempool, in addition to the final status notification. The notification value can now arrive as either a status object ({"err": ...}) or the literal string "receivedSignature". A new SignatureValue named type with a custom UnmarshalJSON dispatches on JSON shape so the existing result.Value.Err access keeps working and the new ReceivedSignature field surfaces the marker variant. The existing SignatureSubscribe entry point delegates to the new opts form, preserving its (signature, commitment) signature. Adds unit coverage for both notification shapes, the null/empty case, and an unknown-marker rejection.
1 parent 20713fb commit 91cef9e

2 files changed

Lines changed: 180 additions & 5 deletions

File tree

rpc/ws/signatureSubscribe.go

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,97 @@
1515
package ws
1616

1717
import (
18+
"bytes"
1819
"context"
20+
"fmt"
21+
22+
stdjson "github.com/goccy/go-json"
1923

2024
"github.com/gagliardetto/solana-go"
2125
"github.com/gagliardetto/solana-go/rpc"
2226
)
2327

28+
// signatureReceivedMarker is the literal value the validator emits
29+
// for the "received" notification when EnableReceivedNotification is
30+
// set on the subscription request.
31+
const signatureReceivedMarker = "receivedSignature"
32+
2433
type SignatureResult struct {
2534
Context struct {
2635
Slot uint64
2736
} `json:"context"`
28-
Value struct {
29-
Err any `json:"err"`
30-
} `json:"value"`
37+
Value SignatureValue `json:"value"`
38+
}
39+
40+
// SignatureValue carries the two shapes a signatureNotification can take.
41+
//
42+
// When EnableReceivedNotification is false (the RPC default) only the
43+
// status shape is emitted and `Err` is the field consumers need
44+
// (nil == success).
45+
//
46+
// When EnableReceivedNotification is true the validator may additionally
47+
// emit a "receivedSignature" string before the final status; that one
48+
// sets `ReceivedSignature` to true and `Err` is left at its zero value.
49+
type SignatureValue struct {
50+
// Err is the transaction execution error reported in a status
51+
// notification. nil means the transaction succeeded.
52+
Err any `json:"err,omitempty"`
53+
54+
// ReceivedSignature is true when the notification is the "received"
55+
// marker the validator emits once it observes the transaction in
56+
// its mempool. Only sent when EnableReceivedNotification is set on
57+
// SignatureSubscribeOpts.
58+
ReceivedSignature bool `json:"-"`
59+
}
60+
61+
// UnmarshalJSON dispatches on the wire shape of the notification value:
62+
// a JSON string "receivedSignature" → ReceivedSignature=true; a JSON
63+
// object → status, fill Err.
64+
func (v *SignatureValue) UnmarshalJSON(data []byte) error {
65+
trimmed := bytes.TrimSpace(data)
66+
if len(trimmed) == 0 || string(trimmed) == "null" {
67+
return nil
68+
}
69+
switch trimmed[0] {
70+
case '"':
71+
var s string
72+
if err := stdjson.Unmarshal(trimmed, &s); err != nil {
73+
return fmt.Errorf("signatureNotification value: %w", err)
74+
}
75+
if s != signatureReceivedMarker {
76+
return fmt.Errorf("signatureNotification value: unexpected marker %q", s)
77+
}
78+
v.ReceivedSignature = true
79+
return nil
80+
case '{':
81+
// Alias avoids recursion into this UnmarshalJSON.
82+
type alias struct {
83+
Err any `json:"err"`
84+
}
85+
var a alias
86+
if err := stdjson.Unmarshal(trimmed, &a); err != nil {
87+
return fmt.Errorf("signatureNotification value: %w", err)
88+
}
89+
v.Err = a.Err
90+
return nil
91+
default:
92+
return fmt.Errorf("signatureNotification value: unexpected JSON %s", string(trimmed))
93+
}
94+
}
95+
96+
// SignatureSubscribeOpts mirrors the optional configuration object the
97+
// signatureSubscribe RPC method accepts. See
98+
// https://solana.com/docs/rpc/websocket/signaturesubscribe.
99+
type SignatureSubscribeOpts struct {
100+
// Commitment selects the bank state the validator should use when
101+
// deciding the transaction has reached the requested level.
102+
Commitment rpc.CommitmentType
103+
104+
// EnableReceivedNotification opts into the additional "received"
105+
// notification emitted as soon as the validator picks the
106+
// transaction up in its mempool (in addition to the final status
107+
// notification). Defaults to false to match the RPC default.
108+
EnableReceivedNotification bool
31109
}
32110

33111
// SignatureSubscribe subscribes to a transaction signature to receive
@@ -36,11 +114,28 @@ type SignatureResult struct {
36114
func (cl *Client) SignatureSubscribe(
37115
signature solana.Signature, // Transaction Signature.
38116
commitment rpc.CommitmentType, // (optional)
117+
) (*SignatureSubscription, error) {
118+
return cl.SignatureSubscribeWithOpts(signature, &SignatureSubscribeOpts{
119+
Commitment: commitment,
120+
})
121+
}
122+
123+
// SignatureSubscribeWithOpts subscribes to a transaction signature and
124+
// forwards the full SignatureSubscribeOpts configuration object to the
125+
// validator, including EnableReceivedNotification.
126+
func (cl *Client) SignatureSubscribeWithOpts(
127+
signature solana.Signature,
128+
opts *SignatureSubscribeOpts,
39129
) (*SignatureSubscription, error) {
40130
params := []any{signature.String()}
41131
conf := map[string]any{}
42-
if commitment != "" {
43-
conf["commitment"] = commitment
132+
if opts != nil {
133+
if opts.Commitment != "" {
134+
conf["commitment"] = opts.Commitment
135+
}
136+
if opts.EnableReceivedNotification {
137+
conf["enableReceivedNotification"] = true
138+
}
44139
}
45140

46141
genSub, err := cl.subscribe(

rpc/ws/signatureSubscribe_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2026 github.com/gagliardetto
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package ws
16+
17+
import (
18+
"testing"
19+
20+
stdjson "github.com/goccy/go-json"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
// TestSignatureValueUnmarshalStatus covers the default notification shape:
25+
// `{ "value": { "err": null } }` for a successful transaction.
26+
func TestSignatureValueUnmarshalStatus(t *testing.T) {
27+
var res SignatureResult
28+
require.NoError(t, stdjson.Unmarshal([]byte(`{
29+
"context": {"slot": 42},
30+
"value": {"err": null}
31+
}`), &res))
32+
require.Equal(t, uint64(42), res.Context.Slot)
33+
require.Nil(t, res.Value.Err)
34+
require.False(t, res.Value.ReceivedSignature)
35+
}
36+
37+
// TestSignatureValueUnmarshalStatusWithErr covers the failed-tx branch
38+
// where `err` is a non-null object.
39+
func TestSignatureValueUnmarshalStatusWithErr(t *testing.T) {
40+
var res SignatureResult
41+
require.NoError(t, stdjson.Unmarshal([]byte(`{
42+
"context": {"slot": 42},
43+
"value": {"err": {"InstructionError": [0, "InvalidAccountData"]}}
44+
}`), &res))
45+
require.NotNil(t, res.Value.Err)
46+
require.False(t, res.Value.ReceivedSignature)
47+
}
48+
49+
// TestSignatureValueUnmarshalReceived covers the second notification
50+
// shape introduced by EnableReceivedNotification: `"value":
51+
// "receivedSignature"`. Without the custom unmarshaler the default
52+
// decoder would fail because the field is typed as a struct.
53+
func TestSignatureValueUnmarshalReceived(t *testing.T) {
54+
var res SignatureResult
55+
require.NoError(t, stdjson.Unmarshal([]byte(`{
56+
"context": {"slot": 7},
57+
"value": "receivedSignature"
58+
}`), &res))
59+
require.True(t, res.Value.ReceivedSignature)
60+
require.Nil(t, res.Value.Err)
61+
}
62+
63+
// TestSignatureValueUnmarshalUnknownMarker rejects unexpected string
64+
// markers so a future RPC change surfaces as a decode error rather
65+
// than a silent miscategorisation.
66+
func TestSignatureValueUnmarshalUnknownMarker(t *testing.T) {
67+
var v SignatureValue
68+
err := v.UnmarshalJSON([]byte(`"someUnknownMarker"`))
69+
require.Error(t, err)
70+
}
71+
72+
// TestSignatureValueUnmarshalNull treats null as a no-op (rather than
73+
// an error) so the field can be omitted by an upstream RPC without
74+
// breaking notification dispatch.
75+
func TestSignatureValueUnmarshalNull(t *testing.T) {
76+
var v SignatureValue
77+
require.NoError(t, v.UnmarshalJSON([]byte(`null`)))
78+
require.False(t, v.ReceivedSignature)
79+
require.Nil(t, v.Err)
80+
}

0 commit comments

Comments
 (0)