|
| 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