Skip to content

Commit 88373b1

Browse files
authored
feat: provide a SaveAndConfirmFunction on the Solana Chain (#154)
This PR introduces a new Solana RPC client wrapper (`chain/solana/provider/rpcclient`) to provide enhanced transaction handling, including support for retries, transaction modifiers, and simplified confirmation flows. This RPC Client wrapper code is a modifed version of the the one found in `chainlink-ccip` utils. This allows us to avoid importing the entire `chainlink-ccip` package. https://github.com/smartcontractkit/chainlink-ccip/blob/e52d53e1d7f00babee52e4dc462d9f69fbf1466b/chains/solana/utils/common/transactions.go
1 parent 222b040 commit 88373b1

File tree

7 files changed

+532
-12
lines changed

7 files changed

+532
-12
lines changed

.changeset/large-baths-know.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": patch
3+
---
4+
5+
Solana Chain now provides a SendAndConfirm method which is intended to replace the Confirm method.

chain/solana/provider/rpc_provider.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package provider
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"os"
78
"path/filepath"
9+
"time"
810

11+
sollib "github.com/gagliardetto/solana-go"
912
solrpc "github.com/gagliardetto/solana-go/rpc"
13+
solCommonUtil "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/common"
1014

1115
"github.com/smartcontractkit/chainlink-deployments-framework/chain"
1216
"github.com/smartcontractkit/chainlink-deployments-framework/chain/solana"
17+
"github.com/smartcontractkit/chainlink-deployments-framework/chain/solana/provider/rpcclient"
1318
)
1419

1520
// RPCChainProviderConfig holds the configuration to initialize the RPCChainProvider.
@@ -115,16 +120,33 @@ func (p *RPCChainProvider) Initialize() (chain.BlockChain, error) {
115120
}
116121

117122
// Initialize the Solana client with the provided HTTP RPC URL
118-
client := solrpc.New(p.config.HTTPURL)
123+
client := rpcclient.New(solrpc.New(p.config.HTTPURL), privKey)
119124

120125
// Create the Solana chain instance with the provided configuration
121126
p.chain = &solana.Chain{
122127
Selector: p.selector,
123-
Client: client,
128+
Client: client.Client,
124129
URL: p.config.HTTPURL,
125130
DeployerKey: &privKey,
126131
ProgramsPath: p.config.ProgramsPath,
127132
KeypairPath: keypairPath,
133+
SendAndConfirm: func(
134+
ctx context.Context, instructions []sollib.Instruction, txMods ...rpcclient.TxModifier,
135+
) error {
136+
_, err := client.SendAndConfirmTx(ctx, instructions,
137+
rpcclient.WithTxModifiers(txMods...),
138+
rpcclient.WithRetry(1, 50*time.Millisecond),
139+
)
140+
141+
return err
142+
},
143+
Confirm: func(instructions []sollib.Instruction, opts ...solCommonUtil.TxModifier) error {
144+
_, err := solCommonUtil.SendAndConfirm(
145+
context.Background(), client.Client, instructions, privKey, solrpc.CommitmentConfirmed, opts...,
146+
)
147+
148+
return err
149+
},
128150
}
129151

130152
return *p.chain, nil
@@ -143,5 +165,5 @@ func (p *RPCChainProvider) ChainSelector() uint64 {
143165
// BlockChain returns the Aptos chain instance managed by this provider. You must call Initialize
144166
// before using this method to ensure the chain is properly set up.
145167
func (p *RPCChainProvider) BlockChain() chain.BlockChain {
146-
return p.chain
168+
return *p.chain
147169
}

chain/solana/provider/rpc_provider_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ func Test_RPCChainProvider_Initialize(t *testing.T) {
200200
filepath.Join(keypairDirPath, "authority-keypair.json"),
201201
gotChain.KeypairPath,
202202
)
203+
assert.NotNil(t, gotChain.SendAndConfirm)
204+
assert.NotNil(t, gotChain.Confirm)
203205
}
204206
})
205207
}
@@ -228,5 +230,5 @@ func Test_RPCChainProvider_BlockChain(t *testing.T) {
228230
chain: chain,
229231
}
230232

231-
assert.Equal(t, chain, p.BlockChain())
233+
assert.Equal(t, *chain, p.BlockChain())
232234
}
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
package rpcclient
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"time"
9+
10+
"github.com/avast/retry-go/v4"
11+
sollib "github.com/gagliardetto/solana-go"
12+
solrpc "github.com/gagliardetto/solana-go/rpc"
13+
"github.com/gagliardetto/solana-go/rpc/jsonrpc"
14+
)
15+
16+
// sendConfig defines the configuration for sending transactions.
17+
type sendConfig struct {
18+
// RetryAttempts determines how many times to retry sending transactions. This applies to all
19+
// underlying RPC client calls. If set to 0, retries will continue indefinitely until success.
20+
RetryAttempts uint
21+
// RetryDelay is the duration to wait between retry attempts.
22+
RetryDelay time.Duration
23+
// ConfirmRetryAttempts sets a fixed number of attempts for confirming transactions.
24+
// This is used specifically for confirmation retries, independent of RetryAttempts and is not
25+
// configurable by the user.
26+
ConfirmRetryAttempts uint
27+
// TxModifiers is a slice of functions that modify the transaction before sending.
28+
// These can be used to add signers, set compute unit limits, adjust fees, etc.
29+
TxModifiers []TxModifier
30+
// Commitment specifies the desired commitment level for the transaction.
31+
// Currently set to Confirmed and not user-configurable, but may be made adjustable in the future.
32+
Commitment solrpc.CommitmentType
33+
}
34+
35+
// RetryOpts returns the retry options for sending transactions.
36+
func (c *sendConfig) RetryOpts(ctx context.Context) []retry.Option {
37+
return []retry.Option{
38+
retry.Context(ctx),
39+
retry.Attempts(c.RetryAttempts),
40+
retry.Delay(c.RetryDelay),
41+
retry.DelayType(retry.FixedDelay),
42+
}
43+
}
44+
45+
// ConfirmRetryOpts returns the retry options for confirming transactions.
46+
func (c *sendConfig) ConfirmRetryOpts(ctx context.Context) []retry.Option {
47+
return []retry.Option{
48+
retry.Context(ctx),
49+
retry.Attempts(c.ConfirmRetryAttempts),
50+
retry.Delay(c.RetryDelay),
51+
retry.DelayType(retry.FixedDelay),
52+
}
53+
}
54+
55+
// sendAndConfirmConfigDefault provides a default configuration for sending and confirming
56+
// transactions.
57+
var sendAndConfirmConfigDefault = sendConfig{
58+
RetryAttempts: 1,
59+
RetryDelay: 50 * time.Millisecond,
60+
ConfirmRetryAttempts: 500,
61+
TxModifiers: make([]TxModifier, 0),
62+
Commitment: solrpc.CommitmentConfirmed,
63+
}
64+
65+
// SendOpt is a functional option type that allows for configuring Send operations.
66+
type SendOpt func(*sendConfig)
67+
68+
// WithRetry sets the number of retry attempts and the delay between retries for sending transactions.
69+
func WithRetry(attempts uint, delay time.Duration) SendOpt {
70+
return func(config *sendConfig) {
71+
config.RetryAttempts = attempts
72+
config.RetryDelay = delay
73+
}
74+
}
75+
76+
// WithTxModifiers allows adding transaction modifiers to the send configuration.
77+
func WithTxModifiers(modifiers ...TxModifier) SendOpt {
78+
return func(config *sendConfig) {
79+
config.TxModifiers = append(config.TxModifiers, modifiers...)
80+
}
81+
}
82+
83+
// Client is a wrapper around the solana RPC client that provides additional functionality
84+
// such as sending transactions with lookup tables and handling retries.
85+
type Client struct {
86+
*solrpc.Client
87+
88+
DeployerKey sollib.PrivateKey
89+
}
90+
91+
// New creates a new Client instance with the provided Solana RPC client and deployer's private
92+
// key.
93+
func New(client *solrpc.Client, deployerKey sollib.PrivateKey) *Client {
94+
return &Client{
95+
Client: client,
96+
DeployerKey: deployerKey,
97+
}
98+
}
99+
100+
// SendAndConfirmTx builds, signs, sends, and confirms a transaction using the given instructions.
101+
// It applies any provided options for retries and transaction modification, fetches the latest blockhash,
102+
// signs with the deployer's key, and waits for the transaction to be confirmed.
103+
func (c *Client) SendAndConfirmTx(
104+
ctx context.Context,
105+
instructions []sollib.Instruction,
106+
opts ...SendOpt,
107+
) (*solrpc.GetTransactionResult, error) {
108+
// Initialize the configuration with defaults or provided options.
109+
config := sendAndConfirmConfigDefault
110+
for _, opt := range opts {
111+
opt(&config)
112+
}
113+
114+
// Fetch the latest blockhash to use in the transaction.
115+
hashRes, err := c.getLatestBlockhash(ctx, config.Commitment, config.RetryOpts(ctx)...)
116+
if err != nil {
117+
return nil, fmt.Errorf("error getting latest blockhash: %w", err)
118+
}
119+
120+
// Construct the transaction with the blockhash and instructions
121+
tx, err := c.newTx(hashRes.Value.Blockhash, instructions)
122+
if err != nil {
123+
return nil, fmt.Errorf("error constructing transaction: %w", err)
124+
}
125+
126+
// Build the signers map
127+
signers := map[sollib.PublicKey]sollib.PrivateKey{}
128+
signers[c.DeployerKey.PublicKey()] = c.DeployerKey
129+
130+
// Apply TxModifiers to the transaction.
131+
for _, o := range config.TxModifiers {
132+
if err = o(tx, signers); err != nil {
133+
return nil, err
134+
}
135+
}
136+
137+
// Sign the transaction
138+
if _, err = tx.Sign(func(pub sollib.PublicKey) *sollib.PrivateKey {
139+
// We know that the deployer key is always present in the signers map,
140+
// and is inserted into the transaction in `newTx`, so we can safely
141+
// retrieve it without checking for existence.
142+
priv := signers[pub]
143+
144+
return &priv
145+
}); err != nil {
146+
return nil, err
147+
}
148+
149+
// Send the transaction
150+
txsig, err := c.sendTx(ctx, tx, solrpc.TransactionOpts{
151+
SkipPreflight: false, // Do not skipPreflight since it is expected to pass, preflight can help debug
152+
PreflightCommitment: config.Commitment,
153+
}, config.RetryOpts(ctx)...)
154+
if err != nil {
155+
return nil, fmt.Errorf("error sending transaction: %w", err)
156+
}
157+
158+
// Confirm the transaction
159+
err = c.confirmTx(ctx, txsig, config.ConfirmRetryOpts(ctx)...)
160+
if err != nil {
161+
return nil, fmt.Errorf("error confirming transaction: %w", err)
162+
}
163+
164+
// Get the transaction result
165+
transactionRes, err := c.getTransactionResult(ctx, txsig, config.Commitment, config.RetryOpts(ctx)...)
166+
if err != nil {
167+
return nil, fmt.Errorf("error getting transaction result: %w", err)
168+
}
169+
170+
return transactionRes, nil
171+
}
172+
173+
// newTx constructs a new Solana transaction with the provided recent blockhash and instructions.
174+
// It does not include any lookup tables, but this can be extended in the future if needed.
175+
func (c *Client) newTx(
176+
recentBlockHash sollib.Hash,
177+
instructions []sollib.Instruction,
178+
) (*sollib.Transaction, error) {
179+
// No lookup tables required, can be made configurable later if needed
180+
lookupTables := sollib.TransactionAddressTables(
181+
map[sollib.PublicKey]sollib.PublicKeySlice{},
182+
)
183+
184+
// Construct the transaction with the provided instructions and blockhash.
185+
return sollib.NewTransaction(
186+
instructions,
187+
recentBlockHash,
188+
lookupTables,
189+
sollib.TransactionPayer(c.DeployerKey.PublicKey()),
190+
)
191+
}
192+
193+
// getLatestBlockhash fetches the latest blockhash from the Solana RPC client, retrying if
194+
// necessary based on the provided retry options.
195+
// It retries fetching the signature status based on the provided retry options.
196+
func (c *Client) getLatestBlockhash(
197+
ctx context.Context, commitment solrpc.CommitmentType, retryOpts ...retry.Option,
198+
) (*solrpc.GetLatestBlockhashResult, error) {
199+
var result *solrpc.GetLatestBlockhashResult
200+
201+
err := retry.Do(func() error {
202+
var rerr error
203+
204+
result, rerr = c.GetLatestBlockhash(ctx, commitment)
205+
206+
return rerr
207+
}, retryOpts...)
208+
209+
return result, err
210+
}
211+
212+
// sendTx sends a transaction to the Solana network using the provided transaction options.
213+
// It retries fetching the signature status based on the provided retry options.
214+
func (c *Client) sendTx(
215+
ctx context.Context,
216+
tx *sollib.Transaction,
217+
txOpts solrpc.TransactionOpts,
218+
retryOpts ...retry.Option,
219+
) (sollib.Signature, error) {
220+
var txsig sollib.Signature
221+
222+
err := retry.Do(func() error {
223+
var rerr error
224+
225+
txsig, rerr = c.SendTransactionWithOpts(ctx, tx, txOpts)
226+
if rerr != nil {
227+
// Handle specific RPC errors
228+
var rpcErr *jsonrpc.RPCError
229+
if errors.As(rerr, &rpcErr) {
230+
if strings.Contains(rpcErr.Message, "Blockhash not found") {
231+
// this can happen when the blockhash we retrieved above is not yet visible to
232+
// the rpc given we get the blockhash from the same rpc, this should not
233+
// happen, but we see it in practice. We attempt to retry to see if it
234+
// resolves.
235+
return fmt.Errorf("blockhash not found, retrying: %w", rerr)
236+
}
237+
238+
return retry.Unrecoverable(
239+
fmt.Errorf("unexpected error (most likely contract related), will not retry: %w", rerr),
240+
)
241+
}
242+
243+
// Not an RPC error — should only happen when we fail to hit the rpc service
244+
return fmt.Errorf("unexpected error (could not hit rpc service): %w", rerr)
245+
}
246+
247+
return nil
248+
}, retryOpts...)
249+
250+
return txsig, err
251+
}
252+
253+
// confirmTx checks the status of a transaction signature until it is confirmed or finalized.
254+
// It retries fetching the signature status based on the provided retry options.
255+
func (c *Client) confirmTx(
256+
ctx context.Context,
257+
txsig sollib.Signature,
258+
retryOpts ...retry.Option,
259+
) error {
260+
var status solrpc.ConfirmationStatusType
261+
262+
return retry.Do(func() error {
263+
// Success
264+
if status == solrpc.ConfirmationStatusConfirmed || status == solrpc.ConfirmationStatusFinalized {
265+
return nil
266+
}
267+
268+
statusRes, err := c.GetSignatureStatuses(ctx, true, txsig)
269+
if err != nil {
270+
// Retry if we hit an error fetching the signature status. Mainnet can be flakey.
271+
return err
272+
}
273+
274+
if statusRes != nil && len(statusRes.Value) > 0 && statusRes.Value[0] != nil {
275+
status = statusRes.Value[0].ConfirmationStatus
276+
}
277+
278+
return nil
279+
}, retryOpts...)
280+
}
281+
282+
// getTransactionResult retrieves the result of a transaction by its signature.
283+
// It retries fetching the transaction result based on the provided retry options.
284+
func (c *Client) getTransactionResult(
285+
ctx context.Context,
286+
txsig sollib.Signature,
287+
commitment solrpc.CommitmentType,
288+
retryOpts ...retry.Option,
289+
) (*solrpc.GetTransactionResult, error) {
290+
ver := uint64(0)
291+
292+
var result *solrpc.GetTransactionResult
293+
err := retry.Do(func() error {
294+
var rerr error
295+
296+
result, rerr = c.GetTransaction(ctx, txsig, &solrpc.GetTransactionOpts{
297+
Commitment: commitment,
298+
MaxSupportedTransactionVersion: &ver,
299+
})
300+
301+
return rerr
302+
}, retryOpts...)
303+
304+
return result, err
305+
}

0 commit comments

Comments
 (0)