diff --git a/pkg/solana/txm/txm.go b/pkg/solana/txm/txm.go index 2a99a6c44..e34c99cef 100644 --- a/pkg/solana/txm/txm.go +++ b/pkg/solana/txm/txm.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math" "math/big" "strings" "sync" @@ -20,6 +19,7 @@ import ( commontypes "github.com/smartcontractkit/chainlink-common/pkg/types" "github.com/smartcontractkit/chainlink-common/pkg/utils" bigmath "github.com/smartcontractkit/chainlink-common/pkg/utils/big_math" + "github.com/smartcontractkit/chainlink-common/pkg/utils/mathutil" "github.com/smartcontractkit/chainlink-solana/pkg/solana/client" "github.com/smartcontractkit/chainlink-solana/pkg/solana/config" @@ -33,6 +33,7 @@ const ( MaxSigsToConfirm = 256 // max number of signatures in GetSignatureStatus call EstimateComputeUnitLimitBuffer = 10 // percent buffer added on top of estimated compute unit limits to account for any variance TxReapInterval = 10 * time.Second // interval of time between reaping transactions that have met the retention threshold + MaxComputeUnitLimit = 1_400_000 // max compute unit limit a transaction can have ) var _ services.Service = (*Txm)(nil) @@ -616,7 +617,7 @@ func (txm *Txm) Enqueue(ctx context.Context, accountID string, tx *solanaGo.Tran select { case txm.chSend <- msg: default: - txm.lggr.Errorw("failed to enqeue tx", "queueFull", len(txm.chSend) == MaxQueueLen, "tx", msg) + txm.lggr.Errorw("failed to enqueue tx", "queueFull", len(txm.chSend) == MaxQueueLen, "tx", msg) return fmt.Errorf("failed to enqueue transaction for %s", accountID) } return nil @@ -646,7 +647,28 @@ func (txm *Txm) GetTransactionStatus(ctx context.Context, transactionID string) // EstimateComputeUnitLimit estimates the compute unit limit needed for a transaction. // It simulates the provided transaction to determine the used compute and applies a buffer to it. func (txm *Txm) EstimateComputeUnitLimit(ctx context.Context, tx *solanaGo.Transaction) (uint32, error) { - res, err := txm.simulateTx(ctx, tx) + txCopy := *tx + + // Set max compute unit limit when simulating a transaction to avoid getting an error for exceeding the default 200k compute unit limit + if computeUnitLimitErr := fees.SetComputeUnitLimit(&txCopy, fees.ComputeUnitLimit(MaxComputeUnitLimit)); computeUnitLimitErr != nil { + txm.lggr.Errorw("failed to set compute unit limit when simulating tx", "error", computeUnitLimitErr) + return 0, computeUnitLimitErr + } + + // Sign and set signature in tx copy for simulation + txMsg, marshalErr := txCopy.Message.MarshalBinary() + if marshalErr != nil { + return 0, fmt.Errorf("failed to marshal tx message: %w", marshalErr) + } + sigBytes, signErr := txm.ks.Sign(ctx, txCopy.Message.AccountKeys[0].String(), txMsg) + if signErr != nil { + return 0, fmt.Errorf("failed to sign transaction: %w", signErr) + } + var sig [64]byte + copy(sig[:], sigBytes) + txCopy.Signatures = append(txCopy.Signatures, sig) + + res, err := txm.simulateTx(ctx, &txCopy) if err != nil { return 0, err } @@ -654,8 +676,8 @@ func (txm *Txm) EstimateComputeUnitLimit(ctx context.Context, tx *solanaGo.Trans // Return error if response err is non-nil to avoid broadcasting a tx destined to fail if res.Err != nil { sig := solanaGo.Signature{} - if len(tx.Signatures) > 0 { - sig = tx.Signatures[0] + if len(txCopy.Signatures) > 0 { + sig = txCopy.Signatures[0] } txm.processSimulationError("", sig, res) return 0, fmt.Errorf("simulated tx returned error: %v", res.Err) @@ -672,13 +694,10 @@ func (txm *Txm) EstimateComputeUnitLimit(ctx context.Context, tx *solanaGo.Trans // Add buffer to the used compute estimate unitsConsumed = bigmath.AddPercentage(new(big.Int).SetUint64(unitsConsumed), EstimateComputeUnitLimitBuffer).Uint64() - if unitsConsumed > math.MaxUint32 { - txm.lggr.Debug("compute units used with buffer greater than uint32 max", "unitsConsumed", unitsConsumed) - // Do not return error to allow falling back to default compute unit limit - return 0, nil - } + // Ensure unitsConsumed does not exceed the max compute unit limit for a transaction after adding buffer + unitsConsumed = mathutil.Min(unitsConsumed, MaxComputeUnitLimit) - return uint32(unitsConsumed), nil + return uint32(unitsConsumed), nil //nolint // unitsConsumed can only be a maximum of 1.4M } // simulateTx simulates transactions using the SimulateTx client method @@ -690,7 +709,8 @@ func (txm *Txm) simulateTx(ctx context.Context, tx *solanaGo.Transaction) (res * return } - res, err = client.SimulateTx(ctx, tx, nil) // use default options (does not verify signatures) + // Simulate with signature verification enabled since it can have an impact on the compute units used + res, err = client.SimulateTx(ctx, tx, &rpc.SimulateTransactionOpts{SigVerify: true, Commitment: txm.cfg.Commitment()}) if err != nil { // This error can occur if endpoint goes down or if invalid signature txm.lggr.Errorw("failed to simulate tx", "error", err) diff --git a/pkg/solana/txm/txm_internal_test.go b/pkg/solana/txm/txm_internal_test.go index d246220a7..f19b26b9a 100644 --- a/pkg/solana/txm/txm_internal_test.go +++ b/pkg/solana/txm/txm_internal_test.go @@ -910,6 +910,8 @@ func TestTxm_compute_unit_limit_estimation(t *testing.T) { t.Run("simulation_succeeds", func(t *testing.T) { // Test tx is not discarded due to confirm timeout and tracked to finalization tx, signed := getTx(t, 1, mkey) + // add signature and compute unit limit to tx for simulation (excludes compute unit price) + simulateTx := addSigAndLimitToTx(t, mkey, solana.PublicKey{}, *tx, MaxComputeUnitLimit) sig := randomSignature(t) var wg sync.WaitGroup wg.Add(3) @@ -917,11 +919,11 @@ func TestTxm_compute_unit_limit_estimation(t *testing.T) { computeUnitConsumed := uint64(1_000_000) computeUnitLimit := fees.ComputeUnitLimit(uint32(bigmath.AddPercentage(new(big.Int).SetUint64(computeUnitConsumed), EstimateComputeUnitLimitBuffer).Uint64())) mc.On("SendTx", mock.Anything, signed(0, true, computeUnitLimit)).Return(sig, nil) - // First simulated before broadcast without signature or compute unit limit set - mc.On("SimulateTx", mock.Anything, tx, mock.Anything).Run(func(mock.Arguments) { + // First simulation before broadcast with signature and max compute unit limit set + mc.On("SimulateTx", mock.Anything, simulateTx, mock.Anything).Run(func(mock.Arguments) { wg.Done() }).Return(&rpc.SimulateTransactionResult{UnitsConsumed: &computeUnitConsumed}, nil).Once() - // Second simulated after broadcast with signature and compute unit limit set + // Second simulation after broadcast with signature and compute unit limit set mc.On("SimulateTx", mock.Anything, signed(0, true, computeUnitLimit), mock.Anything).Run(func(mock.Arguments) { wg.Done() }).Return(&rpc.SimulateTransactionResult{UnitsConsumed: &computeUnitConsumed}, nil).Once() @@ -982,11 +984,13 @@ func TestTxm_compute_unit_limit_estimation(t *testing.T) { t.Run("simulation_returns_error", func(t *testing.T) { // Test tx is not discarded due to confirm timeout and tracked to finalization - tx, signed := getTx(t, 1, mkey) + tx, _ := getTx(t, 1, mkey) + // add signature and compute unit limit to tx for simulation (excludes compute unit price) + simulateTx := addSigAndLimitToTx(t, mkey, solana.PublicKey{}, *tx, MaxComputeUnitLimit) sig := randomSignature(t) - - mc.On("SendTx", mock.Anything, signed(0, true, fees.ComputeUnitLimit(0))).Return(sig, nil).Panic("SendTx should never be called").Maybe() - mc.On("SimulateTx", mock.Anything, tx, mock.Anything).Return(&rpc.SimulateTransactionResult{Err: errors.New("tx err")}, nil).Once() + mc.On("SendTx", mock.Anything, mock.Anything).Return(sig, nil).Panic("SendTx should never be called").Maybe() + // First simulation before broadcast with max compute unit limit + mc.On("SimulateTx", mock.Anything, simulateTx, mock.Anything).Return(&rpc.SimulateTransactionResult{Err: errors.New("tx err")}, nil).Once() // tx should NOT be able to queue assert.Error(t, txm.Enqueue(ctx, t.Name(), tx, nil)) @@ -1072,3 +1076,17 @@ func TestTxm_Enqueue(t *testing.T) { }) } } + +func addSigAndLimitToTx(t *testing.T, keystore SimpleKeystore, pubkey solana.PublicKey, tx solana.Transaction, limit fees.ComputeUnitLimit) *solana.Transaction { + txCopy := tx + // sign tx + txMsg, err := tx.Message.MarshalBinary() + require.NoError(t, err) + sigBytes, err := keystore.Sign(context.Background(), pubkey.String(), txMsg) + require.NoError(t, err) + var sig [64]byte + copy(sig[:], sigBytes) + txCopy.Signatures = append(txCopy.Signatures, sig) + require.NoError(t, fees.SetComputeUnitLimit(&txCopy, limit)) + return &txCopy +} diff --git a/pkg/solana/txm/txm_unit_test.go b/pkg/solana/txm/txm_unit_test.go index bb2108f4e..0bac3e478 100644 --- a/pkg/solana/txm/txm_unit_test.go +++ b/pkg/solana/txm/txm_unit_test.go @@ -14,6 +14,7 @@ import ( solanaClient "github.com/smartcontractkit/chainlink-solana/pkg/solana/client" clientmocks "github.com/smartcontractkit/chainlink-solana/pkg/solana/client/mocks" "github.com/smartcontractkit/chainlink-solana/pkg/solana/config" + "github.com/smartcontractkit/chainlink-solana/pkg/solana/fees" solanatxm "github.com/smartcontractkit/chainlink-solana/pkg/solana/txm" keyMocks "github.com/smartcontractkit/chainlink-solana/pkg/solana/txm/mocks" @@ -25,11 +26,11 @@ import ( func TestTxm_EstimateComputeUnitLimit(t *testing.T) { t.Parallel() - ctx := tests.Context(t) // setup mock keystore mkey := keyMocks.NewSimpleKeystore(t) + mkey.On("Sign", mock.Anything, mock.Anything, mock.Anything).Return([]byte{}, nil) // setup key key, err := solana.NewRandomPrivateKey() @@ -57,7 +58,17 @@ func TestTxm_EstimateComputeUnitLimit(t *testing.T) { Blockhash: solana.Hash{}, }, }, nil).Once() - client.On("SimulateTx", mock.Anything, mock.Anything, mock.Anything).Return(&rpc.SimulateTransactionResult{ + client.On("SimulateTx", mock.Anything, mock.IsType(&solana.Transaction{}), mock.IsType(&rpc.SimulateTransactionOpts{})).Run(func(args mock.Arguments) { + // Validate max compute unit limit is set in transaction + tx := args.Get(1).(*solana.Transaction) + limit, err := fees.ParseComputeUnitLimit(tx.Message.Instructions[len(tx.Message.Instructions)-1].Data) + require.NoError(t, err) + require.Equal(t, fees.ComputeUnitLimit(solanatxm.MaxComputeUnitLimit), limit) + + // Validate signature verification is enabled + opts := args.Get(2).(*rpc.SimulateTransactionOpts) + require.True(t, opts.SigVerify) + }).Return(&rpc.SimulateTransactionResult{ Err: nil, UnitsConsumed: &usedCompute, }, nil).Once() @@ -111,6 +122,34 @@ func TestTxm_EstimateComputeUnitLimit(t *testing.T) { require.NoError(t, err) require.Equal(t, uint32(0), computeUnitLimit) }) + + t.Run("simulation returns max compute unit limit if adding buffer exceeds it", func(t *testing.T) { + usedCompute := uint64(1_400_000) + client.On("LatestBlockhash", mock.Anything).Return(&rpc.GetLatestBlockhashResult{ + Value: &rpc.LatestBlockhashResult{ + LastValidBlockHeight: 100, + Blockhash: solana.Hash{}, + }, + }, nil).Once() + client.On("SimulateTx", mock.Anything, mock.IsType(&solana.Transaction{}), mock.IsType(&rpc.SimulateTransactionOpts{})).Run(func(args mock.Arguments) { + // Validate max compute unit limit is set in transaction + tx := args.Get(1).(*solana.Transaction) + limit, err := fees.ParseComputeUnitLimit(tx.Message.Instructions[len(tx.Message.Instructions)-1].Data) + require.NoError(t, err) + require.Equal(t, fees.ComputeUnitLimit(solanatxm.MaxComputeUnitLimit), limit) + + // Validate signature verification is enabled + opts := args.Get(2).(*rpc.SimulateTransactionOpts) + require.True(t, opts.SigVerify) + }).Return(&rpc.SimulateTransactionResult{ + Err: nil, + UnitsConsumed: &usedCompute, + }, nil).Once() + tx := createTx(t, client, pubKey, pubKey, pubKeyReceiver, solana.LAMPORTS_PER_SOL) + computeUnitLimit, err := txm.EstimateComputeUnitLimit(ctx, tx) + require.NoError(t, err) + require.Equal(t, uint32(1_400_000), computeUnitLimit) + }) } func createTx(t *testing.T, client solanaClient.ReaderWriter, signer solana.PublicKey, sender solana.PublicKey, receiver solana.PublicKey, amt uint64) *solana.Transaction {