Skip to content

Commit 8f66eb2

Browse files
fix(solana): replace solanacommon.sendTransaction with custom code (#388)
The custom code is adapted from `solanaCommon.SendTransactionLookupTablesWithRetries`, but it allows the client code us to customize the number of retries and the delay. If we eventually get a better version in `chainlink-ccip/chains/solana` we should drop this custom implementation go back to using the shared implementation.
1 parent 54403e4 commit 8f66eb2

File tree

11 files changed

+236
-36
lines changed

11 files changed

+236
-36
lines changed

.changeset/swift-insects-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@smartcontractkit/mcms": minor
3+
---
4+
5+
fix(solana): use solanacommon.SendTransaction() that supports retries

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ require (
189189
github.com/shoenig/go-m1cpu v0.1.6 // indirect
190190
github.com/shopspring/decimal v1.4.0 // indirect
191191
github.com/sirupsen/logrus v1.9.3 // indirect
192-
github.com/smartcontractkit/chainlink-ccip v0.0.0-20250320090719-315440f5b0a7 // indirect
193192
github.com/smartcontractkit/chainlink-common v0.6.1-0.20250329081313-84ec641e0758 // indirect
194193
github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 // indirect
195194
github.com/smartcontractkit/libocr v0.0.0-20250220133800-f3b940c4f298 // indirect

go.sum

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -599,14 +599,6 @@ github.com/smartcontractkit/chain-selectors v1.0.48 h1:wa03tlcGj08qZfv1p+LXZIimp
599599
github.com/smartcontractkit/chain-selectors v1.0.48/go.mod h1:xsKM0aN3YGcQKTPRPDDtPx2l4mlTN1Djmg0VVXV40b8=
600600
github.com/smartcontractkit/chainlink-aptos v0.0.0-20250414155853-651b4e583ee9 h1:lw8RZ8IR4UX1M7djAB3IuMtcAqFX4Z4bzQczClfb8bs=
601601
github.com/smartcontractkit/chainlink-aptos v0.0.0-20250414155853-651b4e583ee9/go.mod h1:Sq/ddMOYch6ZuAnW2k5u9V4+TlGKFzuHQnTM8JXEU+g=
602-
github.com/smartcontractkit/chainlink-ccip v0.0.0-20250320090719-315440f5b0a7 h1:/VKrPJRo4y58whyBRhc9Fszu2eTNn0LNISaS0pjhTpk=
603-
github.com/smartcontractkit/chainlink-ccip v0.0.0-20250320090719-315440f5b0a7/go.mod h1:AhqYIeGF2k94J+/gzRx5dQttlgUdZid2N6E4HlHVIVA=
604-
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250422205932-c33527859fd6 h1:EWpKT2h/jyqCcblfmtHuY5oN2k8k2Mrmi5KqX+hc2lo=
605-
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250422205932-c33527859fd6/go.mod h1:k3/Z6AvwurPUlfuDFEonRbkkiTSgNSrtVNhJEWNlUZA=
606-
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250512155317-4615e5659ce4 h1:ZopJiCUcshj79A3tjh0f5cncXEjlfp7QOPWFppb4gxM=
607-
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250512155317-4615e5659ce4/go.mod h1:k3/Z6AvwurPUlfuDFEonRbkkiTSgNSrtVNhJEWNlUZA=
608-
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250512193142-11507db18550 h1:oNwu6Nk5Qs9R/cIJbrnFSsM+6icd5qg2FHLxLbYymNQ=
609-
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250512193142-11507db18550/go.mod h1:k3/Z6AvwurPUlfuDFEonRbkkiTSgNSrtVNhJEWNlUZA=
610602
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250515132731-ad40fab9b75e h1:4Hx+m9MovqsayvKdYjnj6OQ5pxXkpU2LDz0UNRuqoA8=
611603
github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20250515132731-ad40fab9b75e/go.mod h1:k3/Z6AvwurPUlfuDFEonRbkkiTSgNSrtVNhJEWNlUZA=
612604
github.com/smartcontractkit/chainlink-common v0.6.1-0.20250329081313-84ec641e0758 h1:SyaVoJtYZ54dO4HyUcfWsuvC0hRsjk+Lyy/k++WQc7Y=

sdk/solana/common.go

Lines changed: 158 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import (
44
"context"
55
"encoding/binary"
66
"fmt"
7+
"time"
78

89
"github.com/ethereum/go-ethereum/common"
910
"github.com/gagliardetto/solana-go"
1011
"github.com/gagliardetto/solana-go/rpc"
1112
"github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/mcm"
1213
"github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/timelock"
13-
solanaCommon "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/common"
1414
)
1515

1616
const (
@@ -73,7 +73,8 @@ func FindTimelockOperationPDA(
7373
}
7474

7575
func FindTimelockBypasserOperationPDA(
76-
programID solana.PublicKey, timelockID PDASeed, opID [32]byte) (solana.PublicKey, error) {
76+
programID solana.PublicKey, timelockID PDASeed, opID [32]byte,
77+
) (solana.PublicKey, error) {
7778
seeds := [][]byte{[]byte("timelock_bypasser_operation"), timelockID[:], opID[:]}
7879
return findPDA(programID, seeds)
7980
}
@@ -138,6 +139,16 @@ type SendAndConfirmFn func(
138139
auth solana.PrivateKey,
139140
builder any,
140141
commitmentType rpc.CommitmentType,
142+
opts ...sendTransactionOption,
143+
) (string, *rpc.GetTransactionResult, error)
144+
145+
type SendAndConfirmInstructionsFn func(
146+
ctx context.Context,
147+
client *rpc.Client,
148+
auth solana.PrivateKey,
149+
instructions []solana.Instruction,
150+
commitmentType rpc.CommitmentType,
151+
opts ...sendTransactionOption,
141152
) (string, *rpc.GetTransactionResult, error)
142153

143154
// sendAndConfirm contains the default logic for sending and confirming instructions.
@@ -147,13 +158,14 @@ func sendAndConfirm(
147158
auth solana.PrivateKey,
148159
instructionBuilder any,
149160
commitmentType rpc.CommitmentType,
161+
opts ...sendTransactionOption,
150162
) (string, *rpc.GetTransactionResult, error) {
151163
instruction, err := validateAndBuildSolanaInstruction(instructionBuilder)
152164
if err != nil {
153165
return "", nil, fmt.Errorf("unable to validate and build instruction: %w", err)
154166
}
155167

156-
return sendAndConfirmInstructions(ctx, client, auth, []solana.Instruction{instruction}, commitmentType)
168+
return sendAndConfirmInstructions(ctx, client, auth, []solana.Instruction{instruction}, commitmentType, opts...)
157169
}
158170

159171
// sendAndConfirm contains the default logic for sending and confirming instructions.
@@ -163,8 +175,9 @@ func sendAndConfirmInstructions(
163175
auth solana.PrivateKey,
164176
instructions []solana.Instruction,
165177
commitmentType rpc.CommitmentType,
178+
opts ...sendTransactionOption,
166179
) (string, *rpc.GetTransactionResult, error) {
167-
result, err := solanaCommon.SendAndConfirm(ctx, client, instructions, auth, commitmentType)
180+
result, err := sendTransaction(ctx, client, instructions, auth, commitmentType, opts...)
168181
if err != nil {
169182
return "", nil, fmt.Errorf("unable to send instruction: %w", err)
170183
}
@@ -193,3 +206,144 @@ func chunkIndexes(numItems int, chunkSize int) [][2]int {
193206

194207
return indexes
195208
}
209+
210+
type sendTransactionOptions struct {
211+
retries int
212+
delay time.Duration
213+
skipPreflight bool
214+
}
215+
216+
var defaultSendTransactionOptions = func() *sendTransactionOptions {
217+
return &sendTransactionOptions{
218+
retries: 500, //nolint:mnd
219+
delay: 50 * time.Millisecond, //nolint:mnd
220+
skipPreflight: false,
221+
}
222+
}
223+
224+
type sendTransactionOption func(*sendTransactionOptions)
225+
226+
func WithRetries(retries int) sendTransactionOption {
227+
return func(opts *sendTransactionOptions) { opts.retries = retries }
228+
}
229+
230+
func WithDelay(delay time.Duration) sendTransactionOption {
231+
return func(opts *sendTransactionOptions) {
232+
opts.delay = delay
233+
}
234+
}
235+
236+
func WithSkipPreflight(skipPreflight bool) sendTransactionOption {
237+
return func(opts *sendTransactionOptions) {
238+
opts.skipPreflight = skipPreflight
239+
}
240+
}
241+
242+
func sendTransaction(
243+
ctx context.Context,
244+
rpcClient *rpc.Client,
245+
instructions []solana.Instruction,
246+
signerAndPayer solana.PrivateKey,
247+
commitment rpc.CommitmentType,
248+
opts ...sendTransactionOption,
249+
) (*rpc.GetTransactionResult, error) {
250+
var errBlockHash error
251+
var hashRes *rpc.GetLatestBlockhashResult
252+
253+
sendTransactionOptions := defaultSendTransactionOptions()
254+
for _, opt := range opts {
255+
opt(sendTransactionOptions)
256+
}
257+
258+
for range sendTransactionOptions.retries {
259+
hashRes, errBlockHash = rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed)
260+
if errBlockHash != nil {
261+
fmt.Println("GetLatestBlockhash error:", errBlockHash) //nolint:forbidigo
262+
time.Sleep(sendTransactionOptions.delay)
263+
264+
continue
265+
}
266+
267+
break
268+
}
269+
if errBlockHash != nil {
270+
fmt.Println("GetLatestBlockhash error after retries:", errBlockHash) //nolint:forbidigo
271+
return nil, errBlockHash
272+
}
273+
274+
tx, err := solana.NewTransaction(instructions, hashRes.Value.Blockhash, solana.TransactionPayer(signerAndPayer.PublicKey()))
275+
if err != nil {
276+
return nil, err
277+
}
278+
279+
// build signers map
280+
signers := map[solana.PublicKey]solana.PrivateKey{signerAndPayer.PublicKey(): signerAndPayer}
281+
_, err = tx.Sign(func(pub solana.PublicKey) *solana.PrivateKey {
282+
priv, ok := signers[pub]
283+
if !ok {
284+
fmt.Printf("ERROR: Missing signer private key for %s\n", pub) //nolint:forbidigo
285+
}
286+
287+
return &priv
288+
})
289+
if err != nil {
290+
return nil, err
291+
}
292+
293+
var txsig solana.Signature
294+
for range sendTransactionOptions.retries {
295+
txOpts := rpc.TransactionOpts{SkipPreflight: sendTransactionOptions.skipPreflight, PreflightCommitment: commitment}
296+
txsig, err = rpcClient.SendTransactionWithOpts(ctx, tx, txOpts)
297+
if err != nil {
298+
fmt.Println("Error sending transaction:", err) //nolint:forbidigo
299+
time.Sleep(sendTransactionOptions.delay)
300+
301+
continue
302+
}
303+
304+
break
305+
}
306+
// If tx failed with rpc error, we should not retry as confirmation will never happen
307+
if err != nil {
308+
fmt.Println("Error sending transaction after retries:", err) //nolint:forbidigo
309+
return nil, err
310+
}
311+
312+
var txStatus rpc.ConfirmationStatusType
313+
count := 0
314+
for txStatus != rpc.ConfirmationStatusConfirmed && txStatus != rpc.ConfirmationStatusFinalized {
315+
if count > sendTransactionOptions.retries {
316+
return nil, fmt.Errorf("unable to find transaction within timeout (sig: %v)", txsig)
317+
}
318+
count++
319+
statusRes, sigErr := rpcClient.GetSignatureStatuses(ctx, true, txsig)
320+
if sigErr != nil {
321+
fmt.Println(sigErr) //nolint:forbidigo // debugging if tx errors; mainnet can be flakey
322+
time.Sleep(sendTransactionOptions.delay)
323+
324+
continue
325+
}
326+
if statusRes != nil && len(statusRes.Value) > 0 && statusRes.Value[0] != nil {
327+
txStatus = statusRes.Value[0].ConfirmationStatus
328+
}
329+
time.Sleep(sendTransactionOptions.delay)
330+
}
331+
332+
v := uint64(0)
333+
var errGetTx error
334+
var transactionRes *rpc.GetTransactionResult
335+
txOpts := &rpc.GetTransactionOpts{Commitment: commitment, MaxSupportedTransactionVersion: &v}
336+
for range sendTransactionOptions.retries {
337+
transactionRes, errGetTx = rpcClient.GetTransaction(ctx, txsig, txOpts)
338+
if errGetTx != nil {
339+
fmt.Println("Error getting transaction:", errGetTx) //nolint:forbidigo
340+
time.Sleep(sendTransactionOptions.delay)
341+
342+
continue
343+
}
344+
345+
break
346+
}
347+
348+
return transactionRes, errGetTx
349+
}

sdk/solana/common_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ func Test_sendAndConfirm(t *testing.T) {
126126
name string
127127
setup func(*mocks.JSONRPCClient)
128128
builder any
129+
opts []sendTransactionOption
129130
wantSignature string
130131
wantTransaction *rpc.GetTransactionResult
131132
wantErr string
@@ -184,6 +185,7 @@ func Test_sendAndConfirm(t *testing.T) {
184185
"NyH6sKKEbAMjxzG9qLTcwd1yEmv46Z94XmH5Pp9AXJps8EofvpPdUn5bp7rzKnztWmxskBiVRnp4DwaHujhHvFh",
185186
nil, fmt.Errorf("send and confirm error"))
186187
},
188+
opts: []sendTransactionOption{WithRetries(1)},
187189
wantErr: "unable to send instruction: send and confirm error",
188190
},
189191
}
@@ -195,7 +197,7 @@ func Test_sendAndConfirm(t *testing.T) {
195197
client := rpc.NewWithCustomRPCClient(mockJSONRPCClient)
196198
tt.setup(mockJSONRPCClient)
197199

198-
gotSignature, gotTransaction, err := sendAndConfirm(ctx, client, auth, tt.builder, commitmentType)
200+
gotSignature, gotTransaction, err := sendAndConfirm(ctx, client, auth, tt.builder, commitmentType, tt.opts...)
199201

200202
if tt.wantErr == "" {
201203
require.NoError(t, err)

sdk/solana/configurer.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type Configurer struct {
2828
auth solana.PrivateKey
2929
skipSend bool
3030
authorityAccount solana.PublicKey
31+
sendAndConfirm SendAndConfirmInstructionsFn
3132
}
3233

3334
// NewConfigurer creates a new Configurer for Solana chains.
@@ -49,6 +50,7 @@ func NewConfigurer(
4950
chainSelector: chainSelector,
5051
skipSend: false,
5152
authorityAccount: auth.PublicKey(),
53+
sendAndConfirm: sendAndConfirmInstructions,
5254
}
5355
for _, opt := range options {
5456
opt(configurer)
@@ -142,7 +144,7 @@ func (c *Configurer) SetConfig(
142144

143145
var signature string
144146
if !c.skipSend {
145-
signature, err = c.sendInstructions(ctx, c.client, c.auth)
147+
signature, err = c.sendInstructions(ctx, c.client, c.auth, c.sendAndConfirm)
146148
if err != nil {
147149
return types.TransactionResult{}, fmt.Errorf("unable to set config: %w", err)
148150
}
@@ -226,6 +228,7 @@ func (c *instructionCollection) sendInstructions(
226228
ctx context.Context,
227229
client *rpc.Client,
228230
auth solana.PrivateKey,
231+
sendAndConfirmFn SendAndConfirmInstructionsFn,
229232
) (string, error) {
230233
if len(auth) == 0 {
231234
return "", nil
@@ -234,8 +237,7 @@ func (c *instructionCollection) sendInstructions(
234237
var signature string
235238
var err error
236239
for i, instruction := range c.instructions {
237-
signature, _, err = sendAndConfirmInstructions(ctx, client, auth,
238-
[]solana.Instruction{instruction}, rpc.CommitmentConfirmed)
240+
signature, _, err = sendAndConfirmFn(ctx, client, auth, []solana.Instruction{instruction}, rpc.CommitmentConfirmed)
239241
if err != nil {
240242
return "", fmt.Errorf("unable to send instruction %d - %s: %w", i, instruction.label, err)
241243
}

sdk/solana/configurer_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ func TestConfigurer_SetConfig(t *testing.T) {
160160
setup: func(t *testing.T, configurer *Configurer, mockJSONRPCClient *mocks.JSONRPCClient) {
161161
t.Helper()
162162

163+
configurer.sendAndConfirm = sendAndConfirmInstructionsWithoutRetries
164+
163165
mockSolanaTransaction(t, mockJSONRPCClient, 10, 20,
164166
"4PQcRHQJT4cRQZooAhZMAP9ZXJsAka9DeKvXeYvXAvPpHb4Qkc5rmTSHDA2SZSh9aKPBguBx4kmcyHHbkytoAiRr",
165167
nil, fmt.Errorf("initialize signers error"))
@@ -173,6 +175,8 @@ func TestConfigurer_SetConfig(t *testing.T) {
173175
setup: func(t *testing.T, configurer *Configurer, mockJSONRPCClient *mocks.JSONRPCClient) {
174176
t.Helper()
175177

178+
configurer.sendAndConfirm = sendAndConfirmInstructionsWithoutRetries
179+
176180
// initialize signers
177181
mockSolanaTransaction(t, mockJSONRPCClient, 10, 20,
178182
"4PQcRHQJT4cRQZooAhZMAP9ZXJsAka9DeKvXeYvXAvPpHb4Qkc5rmTSHDA2SZSh9aKPBguBx4kmcyHHbkytoAiRr", nil, nil)
@@ -191,6 +195,8 @@ func TestConfigurer_SetConfig(t *testing.T) {
191195
setup: func(t *testing.T, configurer *Configurer, mockJSONRPCClient *mocks.JSONRPCClient) {
192196
t.Helper()
193197

198+
configurer.sendAndConfirm = sendAndConfirmInstructionsWithoutRetries
199+
194200
// initialize signers + append signers
195201
mockSolanaTransaction(t, mockJSONRPCClient, 10, 20,
196202
"4PQcRHQJT4cRQZooAhZMAP9ZXJsAka9DeKvXeYvXAvPpHb4Qkc5rmTSHDA2SZSh9aKPBguBx4kmcyHHbkytoAiRr", nil, nil)
@@ -211,6 +217,8 @@ func TestConfigurer_SetConfig(t *testing.T) {
211217
setup: func(t *testing.T, configurer *Configurer, mockJSONRPCClient *mocks.JSONRPCClient) {
212218
t.Helper()
213219

220+
configurer.sendAndConfirm = sendAndConfirmInstructionsWithoutRetries
221+
214222
// initialize signers + append signers + finalize signers
215223
mockSolanaTransaction(t, mockJSONRPCClient, 10, 20,
216224
"4PQcRHQJT4cRQZooAhZMAP9ZXJsAka9DeKvXeYvXAvPpHb4Qkc5rmTSHDA2SZSh9aKPBguBx4kmcyHHbkytoAiRr", nil, nil)
@@ -260,3 +268,15 @@ func newTestConfigurer(
260268

261269
return NewConfigurer(client, auth, chainSelector, options...), mockJSONRPCClient
262270
}
271+
272+
func sendAndConfirmInstructionsWithoutRetries(
273+
ctx context.Context,
274+
client *rpc.Client,
275+
auth solana.PrivateKey,
276+
instructions []solana.Instruction,
277+
commitmentType rpc.CommitmentType,
278+
opts ...sendTransactionOption,
279+
) (string, *rpc.GetTransactionResult, error) {
280+
opts = append(opts, WithRetries(1))
281+
return sendAndConfirmInstructions(ctx, client, auth, instructions, commitmentType, opts...)
282+
}

0 commit comments

Comments
 (0)