diff --git a/core/chains/evm/txmgr/evm_tx_store.go b/core/chains/evm/txmgr/evm_tx_store.go index 84fe9a02c6a..c0bf94c64df 100644 --- a/core/chains/evm/txmgr/evm_tx_store.go +++ b/core/chains/evm/txmgr/evm_tx_store.go @@ -19,6 +19,7 @@ import ( nullv4 "gopkg.in/guregu/null.v4" "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" + "github.com/smartcontractkit/chainlink/v2/common/txmgr" txmgrtypes "github.com/smartcontractkit/chainlink/v2/common/txmgr/types" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" @@ -55,7 +56,7 @@ type TxStoreWebApi interface { FindTxByHash(hash common.Hash) (*Tx, error) Transactions(offset, limit int) ([]Tx, int, error) TxAttempts(offset, limit int) ([]TxAttempt, int, error) - TransactionsWithAttempts(offset, limit int) ([]Tx, int, error) + TransactionsWithAttempts(selector TransactionsWithAttemptsSelector) ([]Tx, int, error) FindTxAttempt(hash common.Hash) (*TxAttempt, error) FindTxWithAttempts(etxID int64) (etx Tx, err error) } @@ -440,18 +441,32 @@ func (o *evmTxStore) Transactions(offset, limit int) (txs []Tx, count int, err e return } +type TransactionsWithAttemptsSelector struct { + IdempotencyKey *string + Offset uint64 + Limit uint64 +} + // TransactionsWithAttempts returns all eth transactions with at least one attempt // limited by passed parameters. Attempts are sorted by id. -func (o *evmTxStore) TransactionsWithAttempts(offset, limit int) (txs []Tx, count int, err error) { - sql := `SELECT count(*) FROM evm.txes WHERE id IN (SELECT DISTINCT eth_tx_id FROM evm.tx_attempts)` - if err = o.q.Get(&count, sql); err != nil { - return +func (o *evmTxStore) TransactionsWithAttempts(selector TransactionsWithAttemptsSelector) (txs []Tx, count int, err error) { + query := " FROM evm.txes WHERE id IN (SELECT DISTINCT eth_tx_id FROM evm.tx_attempts)" + var args []interface{} + if selector.IdempotencyKey != nil { + args = append(args, *selector.IdempotencyKey) + query += " AND idempotency_key = ?" } - sql = `SELECT * FROM evm.txes WHERE id IN (SELECT DISTINCT eth_tx_id FROM evm.tx_attempts) ORDER BY id desc LIMIT $1 OFFSET $2` + countQuery := sqlx.Rebind(sqlx.DOLLAR, "SELECT count(*)"+query) + if err = o.q.Get(&count, countQuery, args...); err != nil { + return nil, 0, fmt.Errorf("failed to exec count query: %w, sql: %s", err, countQuery) + } + + selectQuery := sqlx.Rebind(sqlx.DOLLAR, "SELECT * "+query+" ORDER BY id desc LIMIT ? OFFSET ?") + args = append(args, selector.Limit, selector.Offset) var dbTxs []DbEthTx - if err = o.q.Select(&dbTxs, sql, limit, offset); err != nil { - return + if err = o.q.Select(&dbTxs, selectQuery, args...); err != nil { + return nil, 0, fmt.Errorf("failed to exec select query: %w, sql: %s", err, selectQuery) } txs = dbEthTxsToEvmEthTxs(dbTxs) err = o.preloadTxAttempts(txs) diff --git a/core/chains/evm/txmgr/evm_tx_store_test.go b/core/chains/evm/txmgr/evm_tx_store_test.go index 73bfc6fc85a..78066f3e35d 100644 --- a/core/chains/evm/txmgr/evm_tx_store_test.go +++ b/core/chains/evm/txmgr/evm_tx_store_test.go @@ -59,7 +59,10 @@ func TestORM_TransactionsWithAttempts(t *testing.T) { require.NoError(t, err) require.Equal(t, 3, count) - txs, count, err := txStore.TransactionsWithAttempts(0, 100) // should omit tx3 + txs, count, err := txStore.TransactionsWithAttempts(txmgr.TransactionsWithAttemptsSelector{ + Offset: 0, + Limit: 100, + }) // should omit tx3 require.NoError(t, err) assert.Equal(t, 2, count, "only eth txs with attempts are counted") assert.Len(t, txs, 2) @@ -70,11 +73,24 @@ func TestORM_TransactionsWithAttempts(t *testing.T) { assert.Equal(t, int64(3), *txs[0].TxAttempts[0].BroadcastBeforeBlockNum, "attempts should be sorted by created_at") assert.Equal(t, int64(2), *txs[0].TxAttempts[1].BroadcastBeforeBlockNum, "attempts should be sorted by created_at") - txs, count, err = txStore.TransactionsWithAttempts(0, 1) + txs, count, err = txStore.TransactionsWithAttempts(txmgr.TransactionsWithAttemptsSelector{ + Offset: 0, + Limit: 1, + }) require.NoError(t, err) assert.Equal(t, 2, count, "only eth txs with attempts are counted") assert.Len(t, txs, 1, "limit should apply to length of results") assert.Equal(t, evmtypes.Nonce(1), *txs[0].Sequence, "transactions should be sorted by nonce") + + txs, count, err = txStore.TransactionsWithAttempts(txmgr.TransactionsWithAttemptsSelector{ + IdempotencyKey: tx2.IdempotencyKey, + Offset: 0, + Limit: 10, + }) + require.NoError(t, err) + assert.Equal(t, 1, count, "only eth tx with specified idempotency key") + assert.Equal(t, tx2.ID, txs[0].ID) + assert.Equal(t, tx2.IdempotencyKey, txs[0].IdempotencyKey) } func TestORM_Transactions(t *testing.T) { diff --git a/core/chains/evm/txmgr/mocks/evm_tx_store.go b/core/chains/evm/txmgr/mocks/evm_tx_store.go index f491bda40bb..d528de819ad 100644 --- a/core/chains/evm/txmgr/mocks/evm_tx_store.go +++ b/core/chains/evm/txmgr/mocks/evm_tx_store.go @@ -16,6 +16,8 @@ import ( time "time" + txmgr "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txmgr" + types "github.com/smartcontractkit/chainlink/v2/common/txmgr/types" uuid "github.com/google/uuid" @@ -924,32 +926,32 @@ func (_m *EvmTxStore) Transactions(offset int, limit int) ([]types.Tx[*big.Int, return r0, r1, r2 } -// TransactionsWithAttempts provides a mock function with given fields: offset, limit -func (_m *EvmTxStore) TransactionsWithAttempts(offset int, limit int) ([]types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], int, error) { - ret := _m.Called(offset, limit) +// TransactionsWithAttempts provides a mock function with given fields: selector +func (_m *EvmTxStore) TransactionsWithAttempts(selector txmgr.TransactionsWithAttemptsSelector) ([]types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], int, error) { + ret := _m.Called(selector) var r0 []types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee] var r1 int var r2 error - if rf, ok := ret.Get(0).(func(int, int) ([]types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], int, error)); ok { - return rf(offset, limit) + if rf, ok := ret.Get(0).(func(txmgr.TransactionsWithAttemptsSelector) ([]types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], int, error)); ok { + return rf(selector) } - if rf, ok := ret.Get(0).(func(int, int) []types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee]); ok { - r0 = rf(offset, limit) + if rf, ok := ret.Get(0).(func(txmgr.TransactionsWithAttemptsSelector) []types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee]); ok { + r0 = rf(selector) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee]) } } - if rf, ok := ret.Get(1).(func(int, int) int); ok { - r1 = rf(offset, limit) + if rf, ok := ret.Get(1).(func(txmgr.TransactionsWithAttemptsSelector) int); ok { + r1 = rf(selector) } else { r1 = ret.Get(1).(int) } - if rf, ok := ret.Get(2).(func(int, int) error); ok { - r2 = rf(offset, limit) + if rf, ok := ret.Get(2).(func(txmgr.TransactionsWithAttemptsSelector) error); ok { + r2 = rf(selector) } else { r2 = ret.Error(2) } diff --git a/core/config/docs/core.toml b/core/config/docs/core.toml index 148de90cd95..f8d87e466ca 100644 --- a/core/config/docs/core.toml +++ b/core/config/docs/core.toml @@ -13,6 +13,8 @@ FeedsManager = true # Default LogPoller = false # Default # UICSAKeys enables CSA Keys in the UI. UICSAKeys = false # Default +# TransactionService enables `POST /v2/transactions/evm` endpoint. +TransactionService = false # Default [Database] # DefaultIdleInTxSessionTimeout is the maximum time allowed for a transaction to be open and idle before timing out. See Postgres `idle_in_transaction_session_timeout` for more details. diff --git a/core/config/feature_config.go b/core/config/feature_config.go index fbb3a4ea541..09e7d7e608f 100644 --- a/core/config/feature_config.go +++ b/core/config/feature_config.go @@ -4,4 +4,5 @@ type Feature interface { FeedsManager() bool UICSAKeys() bool LogPoller() bool + TransactionService() bool } diff --git a/core/config/toml/types.go b/core/config/toml/types.go index 61962d43e5f..5dbd12f1cd2 100644 --- a/core/config/toml/types.go +++ b/core/config/toml/types.go @@ -291,9 +291,10 @@ func (p *PrometheusSecrets) validateMerge(f *PrometheusSecrets) (err error) { } type Feature struct { - FeedsManager *bool - LogPoller *bool - UICSAKeys *bool + FeedsManager *bool + LogPoller *bool + UICSAKeys *bool + TransactionService *bool } func (f *Feature) setFrom(f2 *Feature) { @@ -306,6 +307,10 @@ func (f *Feature) setFrom(f2 *Feature) { if v := f2.UICSAKeys; v != nil { f.UICSAKeys = v } + + if v := f2.TransactionService; v != nil { + f.TransactionService = v + } } type Database struct { diff --git a/core/internal/cltest/factories.go b/core/internal/cltest/factories.go index 741afe828f9..7c1584c6205 100644 --- a/core/internal/cltest/factories.go +++ b/core/internal/cltest/factories.go @@ -135,7 +135,9 @@ func EmptyCLIContext() *cli.Context { } func NewEthTx(t *testing.T, fromAddress common.Address) txmgr.Tx { + idempotencyKey := uuid.New().String() return txmgr.Tx{ + IdempotencyKey: &idempotencyKey, FromAddress: fromAddress, ToAddress: testutils.NewAddress(), EncodedPayload: []byte{1, 2, 3}, diff --git a/core/services/chainlink/config_feature.go b/core/services/chainlink/config_feature.go index 2e968df052d..bfd0b3eeaeb 100644 --- a/core/services/chainlink/config_feature.go +++ b/core/services/chainlink/config_feature.go @@ -17,3 +17,7 @@ func (f *featureConfig) LogPoller() bool { func (f *featureConfig) UICSAKeys() bool { return *f.c.UICSAKeys } + +func (f *featureConfig) TransactionService() bool { + return *f.c.TransactionService +} diff --git a/core/services/chainlink/config_feature_test.go b/core/services/chainlink/config_feature_test.go index bc0418c157b..0d20568f2f9 100644 --- a/core/services/chainlink/config_feature_test.go +++ b/core/services/chainlink/config_feature_test.go @@ -18,4 +18,5 @@ func TestFeatureConfig(t *testing.T) { assert.True(t, f.LogPoller()) assert.True(t, f.FeedsManager()) assert.True(t, f.UICSAKeys()) + assert.True(t, f.TransactionService()) } diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go index 6a835e09c89..5088c75d7d2 100644 --- a/core/services/chainlink/config_general.go +++ b/core/services/chainlink/config_general.go @@ -296,6 +296,10 @@ func (g *generalConfig) FeatureUICSAKeys() bool { return *g.c.Feature.UICSAKeys } +func (g *generalConfig) FeatureTransactionService() bool { + return *g.c.Feature.TransactionService +} + func (g *generalConfig) AutoPprof() config.AutoPprof { return &autoPprofConfig{c: g.c.AutoPprof, rootDir: g.RootDir} } diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go index 891c0a490fb..a0062ac2b2e 100644 --- a/core/services/chainlink/config_test.go +++ b/core/services/chainlink/config_test.go @@ -250,9 +250,10 @@ func TestConfig_Marshal(t *testing.T) { } full.Feature = toml.Feature{ - FeedsManager: ptr(true), - LogPoller: ptr(true), - UICSAKeys: ptr(true), + FeedsManager: ptr(true), + LogPoller: ptr(true), + UICSAKeys: ptr(true), + TransactionService: ptr(true), } full.Database = toml.Database{ DefaultIdleInTxSessionTimeout: models.MustNewDuration(time.Minute), @@ -703,6 +704,7 @@ Headers = ['Authorization: token', 'X-SomeOther-Header: value with spaces | and FeedsManager = true LogPoller = true UICSAKeys = true +TransactionService = true `}, {"Database", Config{Core: toml.Core{Database: full.Database}}, `[Database] DefaultIdleInTxSessionTimeout = '1m0s' diff --git a/core/services/chainlink/testdata/config-empty-effective.toml b/core/services/chainlink/testdata/config-empty-effective.toml index b897fba7f10..3f471d1aced 100644 --- a/core/services/chainlink/testdata/config-empty-effective.toml +++ b/core/services/chainlink/testdata/config-empty-effective.toml @@ -6,6 +6,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/core/services/chainlink/testdata/config-full.toml b/core/services/chainlink/testdata/config-full.toml index 531c98d7344..e8f715bf7a0 100644 --- a/core/services/chainlink/testdata/config-full.toml +++ b/core/services/chainlink/testdata/config-full.toml @@ -6,6 +6,7 @@ ShutdownGracePeriod = '10s' FeedsManager = true LogPoller = true UICSAKeys = true +TransactionService = true [Database] DefaultIdleInTxSessionTimeout = '1m0s' diff --git a/core/services/chainlink/testdata/config-multi-chain-effective.toml b/core/services/chainlink/testdata/config-multi-chain-effective.toml index c743601ced8..732c54bf06f 100644 --- a/core/services/chainlink/testdata/config-multi-chain-effective.toml +++ b/core/services/chainlink/testdata/config-multi-chain-effective.toml @@ -6,6 +6,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/core/store/models/common.go b/core/store/models/common.go index 10f391861e1..edeec825823 100644 --- a/core/store/models/common.go +++ b/core/store/models/common.go @@ -662,3 +662,15 @@ func (h ServiceHeader) Validate() (err error) { } return } + +// CreateEVMTransactionRequest represents a request to create request to submit evm transaction. +type CreateEVMTransactionRequest struct { + IdempotencyKey string `json:"idempotencyKey"` + ChainID *utils.Big `json:"chainID"` + ToAddress *common.Address `json:"toAddress"` + FromAddress common.Address `json:"fromAddress"` // optional, if not set we use one of accounts available for specified chain + EncodedPayload string `json:"encodedPayload"` // hex encoded payload + Value *utils.Big `json:"value"` + ForwarderAddress common.Address `json:"forwarderAddress"` + FeeLimit uint32 `json:"feeLimit"` +} diff --git a/core/web/evm_transactions_controller.go b/core/web/evm_transactions_controller.go index 2b2fd2d554f..7e21e6295e8 100644 --- a/core/web/evm_transactions_controller.go +++ b/core/web/evm_transactions_controller.go @@ -2,9 +2,20 @@ package web import ( "database/sql" + "math/big" "net/http" + "github.com/ethereum/go-ethereum/common/hexutil" + + txmgrcommon "github.com/smartcontractkit/chainlink/v2/common/txmgr" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txmgr" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/logger/audit" "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore" + "github.com/smartcontractkit/chainlink/v2/core/store/models" + "github.com/smartcontractkit/chainlink/v2/core/utils" "github.com/smartcontractkit/chainlink/v2/core/web/presenters" "github.com/ethereum/go-ethereum/common" @@ -19,7 +30,16 @@ type TransactionsController struct { // Index returns paginated transactions func (tc *TransactionsController) Index(c *gin.Context, size, page, offset int) { - txs, count, err := tc.App.TxmStorageService().TransactionsWithAttempts(offset, size) + var idempotencyKey *string + rawIdempotencyKey := c.Query("idempotencyKey") + if rawIdempotencyKey != "" { + idempotencyKey = &rawIdempotencyKey + } + txs, count, err := tc.App.TxmStorageService().TransactionsWithAttempts(txmgr.TransactionsWithAttemptsSelector{ + IdempotencyKey: idempotencyKey, + Offset: uint64(offset), + Limit: uint64(size), + }) ptxs := make([]presenters.EthTxResource, len(txs)) for i, tx := range txs { tx.TxAttempts[0].Tx = tx @@ -47,3 +67,118 @@ func (tc *TransactionsController) Show(c *gin.Context) { jsonAPIResponse(c, presenters.NewEthTxResourceFromAttempt(*ethTxAttempt), "transaction") } + +type EvmTransactionController struct { + Enabled bool + Logger logger.SugaredLogger + AuditLogger audit.AuditLogger + Chains evm.LegacyChainContainer + KeyStore keystore.Eth +} + +func NewEVMTransactionController(app chainlink.Application) *EvmTransactionController { + return &EvmTransactionController{ + Enabled: app.GetConfig().Feature().TransactionService(), + Logger: app.GetLogger(), + AuditLogger: app.GetAuditLogger(), + Chains: app.GetRelayers().LegacyEVMChains(), + KeyStore: app.GetKeyStore().Eth(), + } +} + +// Create signs and sends transaction from specified address. If address is not provided uses one of enabled keys for +// specified chain. +func (tc *EvmTransactionController) Create(c *gin.Context) { + if !tc.Enabled { + jsonAPIError(c, http.StatusUnprocessableEntity, + errors.New("transactions creation disabled. To enable set Feature.TransactionService=true")) + return + } + var tx models.CreateEVMTransactionRequest + if err := c.ShouldBindJSON(&tx); err != nil { + jsonAPIError(c, http.StatusBadRequest, err) + return + } + + if tx.IdempotencyKey == "" { + jsonAPIError(c, http.StatusBadRequest, errors.New("idempotencyKey must be set")) + return + } + + decoded, err := hexutil.Decode(tx.EncodedPayload) + if err != nil { + jsonAPIError(c, http.StatusBadRequest, errors.Errorf("encodedPayload is malformed: %v", err)) + return + } + + if tx.ChainID == nil { + jsonAPIError(c, http.StatusBadRequest, errors.New("chainID must be set")) + return + } + + if tx.ToAddress == nil { + jsonAPIError(c, http.StatusBadRequest, errors.New("toAddress must be set")) + return + } + + chain, err := getChain(tc.Chains, tx.ChainID.String()) + if err != nil { + if errors.Is(err, ErrMissingChainID) { + jsonAPIError(c, http.StatusUnprocessableEntity, err) + return + } + + tc.Logger.Errorf("Failed to get chain", "err", err) + jsonAPIError(c, http.StatusInternalServerError, err) + return + } + + if tx.FromAddress == utils.ZeroAddress { + tx.FromAddress, err = tc.KeyStore.GetRoundRobinAddress(tx.ChainID.ToInt()) + if err != nil { + jsonAPIError(c, http.StatusUnprocessableEntity, errors.Errorf("failed to get fromAddress: %v", err)) + return + } + } else { + err = tc.KeyStore.CheckEnabled(tx.FromAddress, tx.ChainID.ToInt()) + if err != nil { + jsonAPIError(c, http.StatusUnprocessableEntity, + errors.Errorf("fromAddress %v is not available: %v", tx.FromAddress, err)) + return + } + } + + if tx.FeeLimit == 0 { + tx.FeeLimit = chain.Config().EVM().GasEstimator().LimitDefault() + } + + value := tx.Value.ToInt() + if value == nil { + value = big.NewInt(0) + } + etx, err := chain.TxManager().CreateTransaction(c, txmgr.TxRequest{ + IdempotencyKey: &tx.IdempotencyKey, + FromAddress: tx.FromAddress, + ToAddress: *tx.ToAddress, + EncodedPayload: decoded, + Value: *value, + FeeLimit: tx.FeeLimit, + ForwarderAddress: tx.ForwarderAddress, + Strategy: txmgrcommon.NewSendEveryStrategy(), + }) + if err != nil { + jsonAPIError(c, http.StatusInternalServerError, errors.Errorf("transaction failed: %v", err)) + return + } + + tc.AuditLogger.Audit(audit.EthTransactionCreated, map[string]interface{}{ + "ethTX": etx, + }) + + // We have successfully accepted user's request, but have noting to return at the moment. + // We deliberately avoid using EthTxResource here due to its misleading design. It has ID of `txmgr.TxAttempt` and + // state of `txmgr.Tx`, so caller might end up with resource that has ID equal to hash of tx attempt that was + // not included into the chain, while `attributes.state` is `confirmed`. + // User is expected to track transaction status using IdempotencyKey and `/v2/transactions/evm`. + c.Status(http.StatusAccepted) +} diff --git a/core/web/evm_transactions_controller_test.go b/core/web/evm_transactions_controller_test.go index 9135be432de..9c3549fb488 100644 --- a/core/web/evm_transactions_controller_test.go +++ b/core/web/evm_transactions_controller_test.go @@ -1,15 +1,36 @@ package web_test import ( + "bytes" + "encoding/json" "fmt" + "math/big" "net/http" + "net/http/httptest" "testing" + "github.com/cometbft/cometbft/libs/rand" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + + txmgrcommon "github.com/smartcontractkit/chainlink/v2/common/txmgr" + txmMocks "github.com/smartcontractkit/chainlink/v2/common/txmgr/mocks" txmgrtypes "github.com/smartcontractkit/chainlink/v2/common/txmgr/types" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" + evmConfigMocks "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/mocks" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" + evmMocks "github.com/smartcontractkit/chainlink/v2/core/chains/evm/mocks" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txmgr" + evmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/types" "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/logger/audit" + ksMocks "github.com/smartcontractkit/chainlink/v2/core/services/keystore/mocks" + "github.com/smartcontractkit/chainlink/v2/core/store/models" + "github.com/smartcontractkit/chainlink/v2/core/utils" "github.com/smartcontractkit/chainlink/v2/core/web" "github.com/smartcontractkit/chainlink/v2/core/web/presenters" @@ -42,7 +63,10 @@ func TestTransactionsController_Index_Success(t *testing.T) { attempt.BroadcastBeforeBlockNum = &blockNum require.NoError(t, txStore.InsertTxAttempt(&attempt)) - _, count, err := txStore.TransactionsWithAttempts(0, 100) + _, count, err := txStore.TransactionsWithAttempts(txmgr.TransactionsWithAttemptsSelector{ + Offset: 0, + Limit: 100, + }) require.NoError(t, err) require.Equal(t, count, 3) @@ -63,6 +87,49 @@ func TestTransactionsController_Index_Success(t *testing.T) { require.Equal(t, "3", txs[1].SentAt, "expected tx attempts order by sentAt descending") } +func TestTransactionsController_Index_Success_IdempotencyKey(t *testing.T) { + t.Parallel() + + app := cltest.NewApplicationWithKey(t) + require.NoError(t, app.Start(testutils.Context(t))) + + db := app.GetSqlxDB() + txStore := cltest.NewTestTxStore(t, app.GetSqlxDB(), app.GetConfig().Database()) + ethKeyStore := cltest.NewKeyStore(t, db, app.Config.Database()).Eth() + client := app.NewHTTPClient(nil) + _, from := cltest.MustInsertRandomKey(t, ethKeyStore) + + cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, txStore, 0, 1, from) + tx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, txStore, 3, 2, from) + + // add second tx attempt for tx + blockNum := int64(3) + attempt := cltest.NewLegacyEthTxAttempt(t, tx.ID) + attempt.State = txmgrtypes.TxAttemptBroadcast + attempt.TxFee = gas.EvmFee{Legacy: assets.NewWeiI(3)} + attempt.BroadcastBeforeBlockNum = &blockNum + require.NoError(t, txStore.InsertTxAttempt(&attempt)) + + _, count, err := txStore.TransactionsWithAttempts(txmgr.TransactionsWithAttemptsSelector{ + Offset: 0, + Limit: 100, + }) + require.NoError(t, err) + require.Equal(t, count, 2) + + resp, cleanup := client.Get(fmt.Sprintf("/v2/transactions?size=%d&idempotencyKey=%s", 10, *tx.IdempotencyKey)) + t.Cleanup(cleanup) + cltest.AssertServerResponse(t, resp, http.StatusOK) + + var links jsonapi.Links + var txs []presenters.EthTxResource + body := cltest.ParseResponseBody(t, resp) + require.NoError(t, web.ParsePaginatedResponse(body, &txs, &links)) + + require.Len(t, txs, 1) + require.Equal(t, attempt.Hash, txs[0].Hash, "expected tx attempts order by sentAt descending") +} + func TestTransactionsController_Index_Error(t *testing.T) { t.Parallel() @@ -125,3 +192,327 @@ func TestTransactionsController_Show_NotFound(t *testing.T) { t.Cleanup(cleanup) cltest.AssertServerResponse(t, resp, http.StatusNotFound) } + +func TestTransactionsController_Create(t *testing.T) { + t.Parallel() + const txCreatePath = "/v2/transactions/evm" + t.Run("Returns error if endpoint is disabled", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, txCreatePath, nil) + router := gin.New() + controller := &web.EvmTransactionController{ + Enabled: false, + } + router.POST(txCreatePath, controller.Create) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + cltest.AssertServerResponse(t, resp.Result(), http.StatusUnprocessableEntity) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, "transactions creation disabled. To enable set Feature.TransactionService=true", respError.Error()) + }) + + createTx := func(controller *web.EvmTransactionController, request interface{}) *httptest.ResponseRecorder { + body, err := json.Marshal(&request) + assert.NoError(t, err) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, txCreatePath, bytes.NewBuffer(body)) + router := gin.New() + controller.Enabled = true + router.POST(txCreatePath, controller.Create) + router.ServeHTTP(w, req) + return w + } + + t.Run("Fails on malformed json", func(t *testing.T) { + resp := createTx(&web.EvmTransactionController{}, "Hello") + + cltest.AssertServerResponse(t, resp.Result(), http.StatusBadRequest) + }) + t.Run("Fails on missing Idempotency key", func(t *testing.T) { + request := models.CreateEVMTransactionRequest{ + ToAddress: ptr(common.HexToAddress("0xFA01FA015C8A5332987319823728982379128371")), + } + + resp := createTx(&web.EvmTransactionController{}, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusBadRequest) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, "idempotencyKey must be set", respError.Error()) + }) + t.Run("Fails on malformed payload", func(t *testing.T) { + request := models.CreateEVMTransactionRequest{ + ToAddress: ptr(common.HexToAddress("0xFA01FA015C8A5332987319823728982379128371")), + FromAddress: common.HexToAddress("0x0000000000000000000000000000000000000000"), + IdempotencyKey: "idempotency_key", + } + + resp := createTx(&web.EvmTransactionController{}, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusBadRequest) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, "encodedPayload is malformed: empty hex string", respError.Error()) + }) + t.Run("Fails if chain ID is not set", func(t *testing.T) { + request := models.CreateEVMTransactionRequest{ + ToAddress: ptr(common.HexToAddress("0xFA01FA015C8A5332987319823728982379128371")), + FromAddress: common.HexToAddress("0x0000000000000000000000000000000000000000"), + IdempotencyKey: "idempotency_key", + EncodedPayload: "0x", + } + + resp := createTx(&web.EvmTransactionController{}, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusBadRequest) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, "chainID must be set", respError.Error()) + }) + t.Run("Fails if toAddress is not specified", func(t *testing.T) { + request := models.CreateEVMTransactionRequest{ + ToAddress: nil, + FromAddress: common.HexToAddress("0x0000000000000000000000000000000000000000"), + IdempotencyKey: "idempotency_key", + EncodedPayload: "0x", + ChainID: utils.NewBigI(0), + } + + resp := createTx(&web.EvmTransactionController{}, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusBadRequest) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, "toAddress must be set", respError.Error()) + }) + chainID := utils.NewBigI(673728) + t.Run("Fails if requested chain that is not available", func(t *testing.T) { + request := models.CreateEVMTransactionRequest{ + ToAddress: ptr(common.HexToAddress("0xFA01FA015C8A5332987319823728982379128371")), + FromAddress: common.HexToAddress("0x0000000000000000000000000000000000000000"), + IdempotencyKey: "idempotency_key", + EncodedPayload: "0x", + ChainID: chainID, + } + + chainContainer := evmMocks.NewLegacyChainContainer(t) + chainContainer.On("Get", chainID.String()).Return(nil, web.ErrMissingChainID).Once() + controller := &web.EvmTransactionController{ + Chains: chainContainer, + } + resp := createTx(controller, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusUnprocessableEntity) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, web.ErrMissingChainID.Error(), respError.Error()) + }) + t.Run("Fails when fromAddress is not specified and there are no available keys ", func(t *testing.T) { + request := models.CreateEVMTransactionRequest{ + ToAddress: ptr(common.HexToAddress("0xFA01FA015C8A5332987319823728982379128371")), + IdempotencyKey: "idempotency_key", + EncodedPayload: "0x", + ChainID: chainID, + } + + chainContainer := evmMocks.NewLegacyChainContainer(t) + chain := evmMocks.NewChain(t) + chainContainer.On("Get", chainID.String()).Return(chain, nil).Once() + + ethKeystore := ksMocks.NewEth(t) + ethKeystore.On("GetRoundRobinAddress", chainID.ToInt()). + Return(nil, errors.New("failed to get key")).Once() + resp := createTx(&web.EvmTransactionController{ + Chains: chainContainer, + KeyStore: ethKeystore, + }, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusUnprocessableEntity) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, "failed to get fromAddress: failed to get key", respError.Error()) + }) + t.Run("Fails when specified fromAddress is not available for the chain", func(t *testing.T) { + fromAddr := common.HexToAddress("0xFA01FA015C8A5332987319823728982379128371") + request := models.CreateEVMTransactionRequest{ + ToAddress: ptr(common.HexToAddress("0xFA01FA015C8A5332987319823728982379128371")), + FromAddress: fromAddr, + IdempotencyKey: "idempotency_key", + EncodedPayload: "0x", + ChainID: chainID, + } + + chainContainer := evmMocks.NewLegacyChainContainer(t) + chain := evmMocks.NewChain(t) + chainContainer.On("Get", chainID.String()).Return(chain, nil).Once() + + ethKeystore := ksMocks.NewEth(t) + ethKeystore.On("CheckEnabled", fromAddr, chainID.ToInt()). + Return(errors.New("no eth key exists with address")).Once() + resp := createTx(&web.EvmTransactionController{ + Chains: chainContainer, + KeyStore: ethKeystore, + }, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusUnprocessableEntity) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, + "fromAddress 0xfa01fA015c8A5332987319823728982379128371 is not available: no eth key exists with address", + respError.Error()) + }) + + _, _, evmConfig := txmgr.MakeTestConfigs(t) + feeLimit := evmConfig.GasEstimator().LimitDefault() + newChain := func(t *testing.T, txm txmgr.TxManager) evm.Chain { + chain := evmMocks.NewChain(t) + chain.On("TxManager").Return(txm) + + config := evmConfigMocks.NewChainScopedConfig(t) + config.On("EVM").Return(evmConfig).Maybe() + chain.On("Config").Return(config).Maybe() + + return chain + } + t.Run("Correctly populates fields for TxRequest", func(t *testing.T) { + payload := []byte("tx_payload") + value := big.NewInt(rand.Int64()) + + feeLimitOverride := feeLimit + 10 + request := models.CreateEVMTransactionRequest{ + ToAddress: ptr(common.HexToAddress("0xEA746B853DcFFA7535C64882E191eE31BE8CE711")), + FromAddress: common.HexToAddress("0x39364605296d7c77e7C2089F0e48D527bb309d38"), + IdempotencyKey: "idempotency_key", + EncodedPayload: "0x" + fmt.Sprintf("%X", payload), + ChainID: chainID, + Value: utils.NewBig(value), + ForwarderAddress: common.HexToAddress("0x59C2B3875797c521396e7575D706B9188894eAF2"), + FeeLimit: feeLimitOverride, + } + + txm := txmMocks.NewTxManager[*big.Int, *evmtypes.Head, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee](t) + expectedError := errors.New("stub error to shortcut execution") + txm.On("CreateTransaction", mock.Anything, txmgr.TxRequest{ + IdempotencyKey: &request.IdempotencyKey, + FromAddress: request.FromAddress, + ToAddress: *request.ToAddress, + EncodedPayload: payload, + Value: *value, + FeeLimit: feeLimitOverride, + ForwarderAddress: request.ForwarderAddress, + Strategy: txmgrcommon.NewSendEveryStrategy(), + }).Return(txmgr.Tx{}, expectedError).Once() + + chainContainer := evmMocks.NewLegacyChainContainer(t) + chain := newChain(t, txm) + chainContainer.On("Get", chainID.String()).Return(chain, nil).Once() + + ethKeystore := ksMocks.NewEth(t) + ethKeystore.On("CheckEnabled", request.FromAddress, chainID.ToInt()).Return(nil).Once() + resp := createTx(&web.EvmTransactionController{ + Chains: chainContainer, + KeyStore: ethKeystore, + }, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusInternalServerError) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, fmt.Sprintf("transaction failed: %v", expectedError), + respError.Error()) + }) + t.Run("Correctly populates fields for TxRequest with defaults", func(t *testing.T) { + request := models.CreateEVMTransactionRequest{ + ToAddress: ptr(common.HexToAddress("0xEA746B853DcFFA7535C64882E191eE31BE8CE711")), + IdempotencyKey: "idempotency_key", + EncodedPayload: "0x", + ChainID: chainID, + } + + expectedFromAddress := common.HexToAddress("0x59C2B3875797c521396e7575D706B9188894eAF2") + ethKeystore := ksMocks.NewEth(t) + ethKeystore.On("GetRoundRobinAddress", chainID.ToInt()).Return(expectedFromAddress, nil).Once() + + txm := txmMocks.NewTxManager[*big.Int, *evmtypes.Head, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee](t) + expectedError := errors.New("stub error to shortcut execution") + txm.On("CreateTransaction", mock.Anything, txmgr.TxRequest{ + IdempotencyKey: &request.IdempotencyKey, + FromAddress: expectedFromAddress, + ToAddress: *request.ToAddress, + EncodedPayload: []byte{}, + Value: big.Int{}, + FeeLimit: feeLimit, + Strategy: txmgrcommon.NewSendEveryStrategy(), + }).Return(txmgr.Tx{}, expectedError).Once() + + chainContainer := evmMocks.NewLegacyChainContainer(t) + chain := newChain(t, txm) + chainContainer.On("Get", chainID.String()).Return(chain, nil).Once() + + resp := createTx(&web.EvmTransactionController{ + Chains: chainContainer, + KeyStore: ethKeystore, + }, request).Result() + + // we do not really care about results here as main check is during CreateTransaction call + cltest.AssertServerResponse(t, resp, http.StatusInternalServerError) + respError := cltest.ParseJSONAPIErrors(t, resp.Body) + require.Equal(t, fmt.Sprintf("transaction failed: %v", expectedError), + respError.Error()) + }) + + payload := []byte("tx_payload") + const txID = int64(54323) + newTxFromRequest := func(request models.CreateEVMTransactionRequest) txmgr.Tx { + return txmgr.Tx{ + ID: txID, + EncodedPayload: payload, + FromAddress: request.FromAddress, + FeeLimit: feeLimit, + State: txmgrcommon.TxInProgress, + ToAddress: *request.ToAddress, + Value: *request.Value.ToInt(), + ChainID: chainID.ToInt(), + } + } + newTxManager := func(request models.CreateEVMTransactionRequest) txmgr.TxManager { + txm := txmMocks.NewTxManager[*big.Int, *evmtypes.Head, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee](t) + tx := newTxFromRequest(request) + txm.On("CreateTransaction", mock.Anything, txmgr.TxRequest{ + IdempotencyKey: &request.IdempotencyKey, + FromAddress: request.FromAddress, + ToAddress: *request.ToAddress, + EncodedPayload: payload, + Value: *request.Value.ToInt(), + FeeLimit: feeLimit, + Strategy: txmgrcommon.NewSendEveryStrategy(), + }).Return(tx, nil).Once() + return txm + } + t.Run("Happy path", func(t *testing.T) { + request := models.CreateEVMTransactionRequest{ + FromAddress: common.HexToAddress("0x59C2B3875797c521396e7575D706B9188894eAF2"), + ToAddress: ptr(common.HexToAddress("0xEA746B853DcFFA7535C64882E191eE31BE8CE711")), + IdempotencyKey: "idempotency_key", + EncodedPayload: "0x" + fmt.Sprintf("%X", payload), + ChainID: chainID, + Value: utils.NewBigI(6838712), + } + + ethKeystore := ksMocks.NewEth(t) + ethKeystore.On("CheckEnabled", request.FromAddress, chainID.ToInt()).Return(nil).Once() + + txm := newTxManager(request) + + chainContainer := evmMocks.NewLegacyChainContainer(t) + chain := newChain(t, txm) + chainContainer.On("Get", chainID.String()).Return(chain, nil).Once() + block := int64(56345431) + txWithAttempts := newTxFromRequest(request) + txWithAttempts.TxAttempts = []txmgr.TxAttempt{ + { + Hash: common.HexToHash("0xa1ce83ee556cbcfc6541d5909b0d7f28f6a77399d3bd4340246f684a0f25a7f5"), + BroadcastBeforeBlockNum: &block, + }, + } + + resp := createTx(&web.EvmTransactionController{ + AuditLogger: audit.NoopLogger, + Chains: chainContainer, + KeyStore: ethKeystore, + }, request).Result() + + cltest.AssertServerResponse(t, resp, http.StatusAccepted) + }) +} diff --git a/core/web/resolver/testdata/config-empty-effective.toml b/core/web/resolver/testdata/config-empty-effective.toml index b897fba7f10..3f471d1aced 100644 --- a/core/web/resolver/testdata/config-empty-effective.toml +++ b/core/web/resolver/testdata/config-empty-effective.toml @@ -6,6 +6,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/core/web/resolver/testdata/config-full.toml b/core/web/resolver/testdata/config-full.toml index 6cd6eaabc3c..20fb64bb614 100644 --- a/core/web/resolver/testdata/config-full.toml +++ b/core/web/resolver/testdata/config-full.toml @@ -6,6 +6,7 @@ ShutdownGracePeriod = '10s' FeedsManager = true LogPoller = true UICSAKeys = true +TransactionService = true [Database] DefaultIdleInTxSessionTimeout = '1m0s' diff --git a/core/web/resolver/testdata/config-multi-chain-effective.toml b/core/web/resolver/testdata/config-multi-chain-effective.toml index c743601ced8..732c54bf06f 100644 --- a/core/web/resolver/testdata/config-multi-chain-effective.toml +++ b/core/web/resolver/testdata/config-multi-chain-effective.toml @@ -6,6 +6,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/core/web/router.go b/core/web/router.go index 28bd4f2170c..0ddb7251ab9 100644 --- a/core/web/router.go +++ b/core/web/router.go @@ -279,6 +279,8 @@ func v2Routes(app chainlink.Application, r *gin.RouterGroup) { txs := TransactionsController{app} authv2.GET("/transactions/evm", paginatedRequest(txs.Index)) + evmTxs := NewEVMTransactionController(app) + authv2.POST("/transactions/evm", auth.RequiresAdminRole(evmTxs.Create)) authv2.GET("/transactions/evm/:TxHash", txs.Show) authv2.GET("/transactions", paginatedRequest(txs.Index)) authv2.GET("/transactions/:TxHash", txs.Show) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5b93c7061e8..f7b0941014e 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -51,6 +51,7 @@ ShutdownGracePeriod is the maximum time allowed to shut down gracefully. If exce FeedsManager = true # Default LogPoller = false # Default UICSAKeys = false # Default +TransactionService = false # Default ``` @@ -72,6 +73,12 @@ UICSAKeys = false # Default ``` UICSAKeys enables CSA Keys in the UI. +### TransactionService +```toml +TransactionService = false # Default +``` +TransactionService enables `POST /v2/transactions/evm` endpoint. + ## Database ```toml [Database] diff --git a/testdata/scripts/node/validate/default.txtar b/testdata/scripts/node/validate/default.txtar index 01e96ac944d..c51fe33b28f 100644 --- a/testdata/scripts/node/validate/default.txtar +++ b/testdata/scripts/node/validate/default.txtar @@ -18,6 +18,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar index 1f6901e9ffd..1265c5e7d69 100644 --- a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar +++ b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar @@ -62,6 +62,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar index 4c1a1c75fc3..1f2b5867e9f 100644 --- a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar +++ b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar @@ -62,6 +62,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/testdata/scripts/node/validate/disk-based-logging.txtar b/testdata/scripts/node/validate/disk-based-logging.txtar index 536b7d8ac08..1c485b95d94 100644 --- a/testdata/scripts/node/validate/disk-based-logging.txtar +++ b/testdata/scripts/node/validate/disk-based-logging.txtar @@ -62,6 +62,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/testdata/scripts/node/validate/invalid.txtar b/testdata/scripts/node/validate/invalid.txtar index 89f59574fcc..0aaac8d2f5e 100644 --- a/testdata/scripts/node/validate/invalid.txtar +++ b/testdata/scripts/node/validate/invalid.txtar @@ -52,6 +52,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/testdata/scripts/node/validate/valid.txtar b/testdata/scripts/node/validate/valid.txtar index 2d32b39a644..d31891e337e 100644 --- a/testdata/scripts/node/validate/valid.txtar +++ b/testdata/scripts/node/validate/valid.txtar @@ -59,6 +59,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s' diff --git a/testdata/scripts/node/validate/warnings.txtar b/testdata/scripts/node/validate/warnings.txtar index e478203e00e..46a1f4a16f5 100644 --- a/testdata/scripts/node/validate/warnings.txtar +++ b/testdata/scripts/node/validate/warnings.txtar @@ -55,6 +55,7 @@ ShutdownGracePeriod = '5s' FeedsManager = true LogPoller = false UICSAKeys = false +TransactionService = false [Database] DefaultIdleInTxSessionTimeout = '1h0m0s'