diff --git a/common/txmgr/address_state.go b/common/txmgr/address_state.go index a9e5ebf0aac..2c82fa50ce7 100644 --- a/common/txmgr/address_state.go +++ b/common/txmgr/address_state.go @@ -1,6 +1,7 @@ package txmgr import ( + "fmt" "sync" "time" @@ -127,16 +128,95 @@ func newAddressState[ return &as } -// countTransactionsByState returns the number of transactions that are in the given state +// countTransactionsByState returns the number of transactions that are in the given state. func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) countTransactionsByState(txState txmgrtypes.TxState) int { - return 0 + as.RLock() + defer as.RUnlock() + + switch txState { + case TxUnstarted: + return as.unstartedTxs.Len() + case TxInProgress: + if as.inprogressTx != nil { + return 1 + } + return 0 + case TxUnconfirmed: + return len(as.unconfirmedTxs) + case TxConfirmedMissingReceipt: + return len(as.confirmedMissingReceiptTxs) + case TxConfirmed: + return len(as.confirmedTxs) + case TxFatalError: + return len(as.fatalErroredTxs) + default: + panic("countTransactionByState: unknown transaction state") + } } -// findTxWithIdempotencyKey returns the transaction with the given idempotency key. If no transaction is found, nil is returned. +// findTxWithIdempotencyKey returns the transaction with the given idempotency key. +// If no transaction is found, nil is returned. func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) findTxWithIdempotencyKey(key string) *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] { + as.RLock() + defer as.RUnlock() + + return as.idempotencyKeyToTx[key] +} + +// addInProgressTxAttempt saves the in-progress transaction attempt. +// The transaction attempt should have a valid ID assigned by the caller. +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) addInProgressTxAttempt( + txAttempt txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], +) error { + as.Lock() + defer as.Unlock() + + if txAttempt.ID == 0 { + return fmt.Errorf("save_in_progress_tx_attempt: TxAttempt ID must be set") + } + + tx, ok := as.allTxs[txAttempt.TxID] + if !ok { + return ErrTxnNotFound + } + + // add the new attempt to the transaction + tx.TxAttempts = append(tx.TxAttempts, txAttempt) + as.attemptHashToTxAttempt[txAttempt.Hash] = &txAttempt + return nil } +// updateInProgressTxAttempt saves the in-progress transaction attempt. +// The transaction attempt should have a valid ID assigned by the caller. +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) updateInProgressTxAttempt( + txAttempt txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], +) error { + as.Lock() + defer as.Unlock() + + if txAttempt.ID == 0 { + return fmt.Errorf("save_in_progress_tx_attempt: TxAttempt ID must be set") + } + + tx, ok := as.allTxs[txAttempt.TxID] + if !ok { + return ErrTxnNotFound + } + + // update the existing attempt if it exists + for i := 0; i < len(tx.TxAttempts); i++ { + if tx.TxAttempts[i].ID == txAttempt.ID { + tx.TxAttempts[i].State = txmgrtypes.TxAttemptInProgress + tx.TxAttempts[i].BroadcastBeforeBlockNum = txAttempt.BroadcastBeforeBlockNum + as.attemptHashToTxAttempt[txAttempt.Hash] = &tx.TxAttempts[i] + return nil + } + } + + return fmt.Errorf("update_in_progress_tx_attempt: tried to update but no tx attempt found with ID %v", txAttempt.ID) +} + // applyToTxsByState calls the given function for each transaction in the given states. // If txIDs are provided, only the transactions with those IDs are considered. // If no txIDs are provided, all transactions in the given states are considered. @@ -146,6 +226,33 @@ func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) applyT fn func(*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]), txIDs ...int64, ) { + as.Lock() + defer as.Unlock() + + // if txStates is empty then apply the filter to only the as.allTransactions map + if len(txStates) == 0 { + as._applyToTxs(as.allTxs, fn, txIDs...) + return + } + + for _, txState := range txStates { + switch txState { + case TxInProgress: + if as.inprogressTx != nil { + fn(as.inprogressTx) + } + case TxUnconfirmed: + as._applyToTxs(as.unconfirmedTxs, fn, txIDs...) + case TxConfirmedMissingReceipt: + as._applyToTxs(as.confirmedMissingReceiptTxs, fn, txIDs...) + case TxConfirmed: + as._applyToTxs(as.confirmedTxs, fn, txIDs...) + case TxFatalError: + as._applyToTxs(as.fatalErroredTxs, fn, txIDs...) + default: + panic("applyToTxsByState: unknown transaction state") + } + } } // findTxAttempts returns all attempts for the given transactions that match the given filters. @@ -159,7 +266,49 @@ func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) findTx txAttemptFilter func(*txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool, txIDs ...int64, ) []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] { - return nil + as.RLock() + defer as.RUnlock() + + // if txStates is empty then apply the filter to only the as.allTransactions map + if len(txStates) == 0 { + return as._findTxAttempts(as.allTxs, txFilter, txAttemptFilter, txIDs...) + } + + var txAttempts []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + for _, txState := range txStates { + switch txState { + case TxInProgress: + if as.inprogressTx != nil && txFilter(as.inprogressTx) { + for i := 0; i < len(as.inprogressTx.TxAttempts); i++ { + txAttempt := as.inprogressTx.TxAttempts[i] + if txAttemptFilter(&txAttempt) { + txAttempts = append(txAttempts, txAttempt) + } + } + } + case TxUnconfirmed: + txAttempts = append(txAttempts, as._findTxAttempts(as.unconfirmedTxs, txFilter, txAttemptFilter, txIDs...)...) + case TxConfirmedMissingReceipt: + txAttempts = append(txAttempts, as._findTxAttempts(as.confirmedMissingReceiptTxs, txFilter, txAttemptFilter, txIDs...)...) + case TxConfirmed: + txAttempts = append(txAttempts, as._findTxAttempts(as.confirmedTxs, txFilter, txAttemptFilter, txIDs...)...) + case TxFatalError: + txAttempts = append(txAttempts, as._findTxAttempts(as.fatalErroredTxs, txFilter, txAttemptFilter, txIDs...)...) + default: + panic("findTxAttempts: unknown transaction state") + } + } + + return txAttempts +} + +// findTxByID returns the transaction with the given ID. +// If no transaction is found, nil is returned. +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) findTxByID(txID int64) *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] { + as.RLock() + defer as.RUnlock() + + return as.allTxs[txID] } // findTxs returns all transactions that match the given filters. @@ -171,7 +320,43 @@ func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) findTx filter func(*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool, txIDs ...int64, ) []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] { - return nil + as.RLock() + defer as.RUnlock() + + // if txStates is empty then apply the filter to only the as.allTransactions map + if len(txStates) == 0 { + return as._findTxs(as.allTxs, filter, txIDs...) + } + + var txs []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + for _, txState := range txStates { + switch txState { + case TxUnstarted: + filter2 := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if tx.State != TxUnstarted { + return false + } + return filter(tx) + } + txs = append(txs, as._findTxs(as.allTxs, filter2, txIDs...)...) + case TxInProgress: + if as.inprogressTx != nil && filter(as.inprogressTx) { + txs = append(txs, *as.inprogressTx) + } + case TxUnconfirmed: + txs = append(txs, as._findTxs(as.unconfirmedTxs, filter, txIDs...)...) + case TxConfirmedMissingReceipt: + txs = append(txs, as._findTxs(as.confirmedMissingReceiptTxs, filter, txIDs...)...) + case TxConfirmed: + txs = append(txs, as._findTxs(as.confirmedTxs, filter, txIDs...)...) + case TxFatalError: + txs = append(txs, as._findTxs(as.fatalErroredTxs, filter, txIDs...)...) + default: + panic("findTxs: unknown transaction state") + } + } + + return txs } // pruneUnstartedTxQueue removes the transactions with the given IDs from the unstarted transaction queue. @@ -248,3 +433,149 @@ func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) moveIn func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) moveConfirmedToUnconfirmed(attempt txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) error { return nil } + +// close releases all resources held by the address state. +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) close() { + clear(as.idempotencyKeyToTx) + + as.unstartedTxs.Close() + as.unstartedTxs = nil + as.inprogressTx = nil + + clear(as.unconfirmedTxs) + clear(as.confirmedMissingReceiptTxs) + clear(as.confirmedTxs) + clear(as.allTxs) + clear(as.fatalErroredTxs) + + as.idempotencyKeyToTx = nil + as.unconfirmedTxs = nil + as.confirmedMissingReceiptTxs = nil + as.confirmedTxs = nil + as.allTxs = nil + as.fatalErroredTxs = nil +} + +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) abandon() { + as.Lock() + defer as.Unlock() + + for as.unstartedTxs.Len() > 0 { + tx := as.unstartedTxs.RemoveNextTx() + as._abandonTx(tx) + } + + if as.inprogressTx != nil { + tx := as.inprogressTx + as._abandonTx(tx) + as.inprogressTx = nil + } + for _, tx := range as.unconfirmedTxs { + as._abandonTx(tx) + } + + clear(as.unconfirmedTxs) +} + +// This method is not concurrent safe and should only be called from within a lock +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) _abandonTx(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) { + if tx == nil { + return + } + + tx.State = TxFatalError + tx.Sequence = nil + tx.Error = null.NewString("abandoned", true) + + as.fatalErroredTxs[tx.ID] = tx +} + +// This method is not concurrent safe and should only be called from within a lock +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) _applyToTxs( + txIDsToTx map[int64]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], + fn func(*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]), + txIDs ...int64, +) { + // if txIDs is not empty then only apply the filter to those transactions + if len(txIDs) > 0 { + for _, txID := range txIDs { + tx := txIDsToTx[txID] + if tx != nil { + fn(tx) + } + } + return + } + + // if txIDs is empty then apply the filter to all transactions + for _, tx := range txIDsToTx { + fn(tx) + } +} + +// This method is not concurrent safe and should only be called from within a lock +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) _findTxAttempts( + txIDsToTx map[int64]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], + txFilter func(*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool, + txAttemptFilter func(*txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool, + txIDs ...int64, +) []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] { + var txAttempts []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + // if txIDs is not empty then only apply the filter to those transactions + if len(txIDs) > 0 { + for _, txID := range txIDs { + tx := txIDsToTx[txID] + if tx != nil && txFilter(tx) { + for i := 0; i < len(tx.TxAttempts); i++ { + txAttempt := tx.TxAttempts[i] + if txAttemptFilter(&txAttempt) { + txAttempts = append(txAttempts, txAttempt) + } + } + } + } + return txAttempts + } + + // if txIDs is empty then apply the filter to all transactions + for _, tx := range txIDsToTx { + if txFilter(tx) { + for i := 0; i < len(tx.TxAttempts); i++ { + txAttempt := tx.TxAttempts[i] + if txAttemptFilter(&txAttempt) { + txAttempts = append(txAttempts, txAttempt) + } + } + } + } + + return txAttempts +} + +// This method is not concurrent safe and should only be called from within a lock +func (as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) _findTxs( + txIDsToTx map[int64]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], + filter func(*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool, + txIDs ...int64, +) []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] { + var txs []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + // if txIDs is not empty then only apply the filter to those transactions + if len(txIDs) > 0 { + for _, txID := range txIDs { + tx := txIDsToTx[txID] + if tx != nil && filter(tx) { + txs = append(txs, *tx) + } + } + return txs + } + + // if txIDs is empty then apply the filter to all transactions + for _, tx := range txIDsToTx { + if filter(tx) { + txs = append(txs, *tx) + } + } + + return txs +} diff --git a/common/txmgr/inmemory_store.go b/common/txmgr/inmemory_store.go index bd4e9a2f3a6..2148125d97d 100644 --- a/common/txmgr/inmemory_store.go +++ b/common/txmgr/inmemory_store.go @@ -1,16 +1,22 @@ package txmgr import ( + "cmp" "context" + "encoding/json" "errors" "fmt" "math/big" + "slices" + "strconv" + "sync" "time" "github.com/google/uuid" "gopkg.in/guregu/null.v4" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/common/chains/label" feetypes "github.com/smartcontractkit/chainlink/v2/common/fee/types" txmgrtypes "github.com/smartcontractkit/chainlink/v2/common/txmgr/types" "github.com/smartcontractkit/chainlink/v2/common/types" @@ -47,7 +53,8 @@ type inMemoryStore[ keyStore txmgrtypes.KeyStore[ADDR, CHAIN_ID, SEQ] persistentTxStore txmgrtypes.TxStore[ADDR, CHAIN_ID, TX_HASH, BLOCK_HASH, R, SEQ, FEE] - addressStates map[ADDR]*addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE] + addressStatesLock sync.RWMutex + addressStates map[ADDR]*addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE] } // NewInMemoryStore returns a new inMemoryStore @@ -79,11 +86,20 @@ func NewInMemoryStore[ ms.maxUnstarted = 10000 } + addressesToTxs := map[ADDR][]txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + // populate all enabled addresses + enabledAddresses, err := keyStore.EnabledAddressesForChain(ctx, chainID) + if err != nil { + return nil, fmt.Errorf("new_in_memory_store: %w", err) + } + for _, addr := range enabledAddresses { + addressesToTxs[addr] = []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + } + txs, err := persistentTxStore.GetAllTransactions(ctx, chainID) if err != nil { return nil, fmt.Errorf("address_state: initialization: %w", err) } - addressesToTxs := map[ADDR][]txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} for _, tx := range txs { at, exists := addressesToTxs[tx.FromAddress] if !exists { @@ -113,11 +129,43 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) Creat // FindTxWithIdempotencyKey returns a transaction with the given idempotency key func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxWithIdempotencyKey(ctx context.Context, idempotencyKey string, chainID CHAIN_ID) (*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + // Check if the transaction is in the pending queue of all address states + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + if tx := as.findTxWithIdempotencyKey(idempotencyKey); tx != nil { + return ms.deepCopyTx(*tx), nil + } + } + return nil, nil } // CheckTxQueueCapacity checks if the queue capacity has been reached for a given address func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) CheckTxQueueCapacity(ctx context.Context, fromAddress ADDR, maxQueuedTransactions uint64, chainID CHAIN_ID) error { + if maxQueuedTransactions == 0 { + return nil + } + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[fromAddress] + if !ok { + return nil + } + + count := uint64(as.countTransactionsByState(TxUnstarted)) + if count >= maxQueuedTransactions { + return fmt.Errorf("cannot create transaction; too many unstarted transactions in the queue (%v/%v). %s", count, maxQueuedTransactions, label.MaxQueuedTransactionsWarning) + } + return nil } @@ -125,21 +173,43 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) Check // It is used to initialize the in-memory sequence map in the broadcaster // TODO(jtw): this is until we have a abstracted Sequencer Component which can be used instead func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindLatestSequence(ctx context.Context, fromAddress ADDR, chainID CHAIN_ID) (seq SEQ, err error) { - return seq, nil + // Query the persistent store + return ms.persistentTxStore.FindLatestSequence(ctx, fromAddress, chainID) } // CountUnconfirmedTransactions returns the number of unconfirmed transactions for a given address. // Unconfirmed transactions are transactions that have been broadcast but not confirmed on-chain. -// NOTE(jtw): used to calculate total inflight transactions +// NOTE: used to calculate total inflight transactions func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) CountUnconfirmedTransactions(ctx context.Context, fromAddress ADDR, chainID CHAIN_ID) (uint32, error) { - return 0, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[fromAddress] + if !ok { + return 0, nil + } + + return uint32(as.countTransactionsByState(TxUnconfirmed)), nil } // CountUnstartedTransactions returns the number of unstarted transactions for a given address. // Unstarted transactions are transactions that have not been broadcast yet. -// NOTE(jtw): used to calculate total inflight transactions func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) CountUnstartedTransactions(ctx context.Context, fromAddress ADDR, chainID CHAIN_ID) (uint32, error) { - return 0, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[fromAddress] + if !ok { + return 0, nil + } + + return uint32(as.countTransactionsByState(TxUnstarted)), nil } // UpdateTxUnstartedToInProgress updates a transaction from unstarted to in_progress. @@ -188,15 +258,71 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) Updat // Close closes the inMemoryStore func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) Close() { + // Close the event recorder + ms.persistentTxStore.Close() + + // Clear all address states + ms.addressStatesLock.Lock() + for _, as := range ms.addressStates { + as.close() + } + clear(ms.addressStates) + ms.addressStatesLock.Unlock() } // Abandon removes all transactions for a given address func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) Abandon(ctx context.Context, chainID CHAIN_ID, addr ADDR) error { + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + // Mark all persisted transactions as abandoned + if err := ms.persistentTxStore.Abandon(ctx, chainID, addr); err != nil { + return err + } + + // check that the address exists in the unstarted transactions + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[addr] + if !ok { + as = newAddressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE](ms.lggr, chainID, addr, ms.maxUnstarted, nil) + ms.addressStates[addr] = as + } + as.abandon() + return nil } // SetBroadcastBeforeBlockNum sets the broadcast_before_block_num for a given chain ID func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) SetBroadcastBeforeBlockNum(ctx context.Context, blockNum int64, chainID CHAIN_ID) error { + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + // Persist to persistent storage + if err := ms.persistentTxStore.SetBroadcastBeforeBlockNum(ctx, blockNum, chainID); err != nil { + return fmt.Errorf("set_broadcast_before_block_num: %w", err) + } + + fn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) { + if tx.TxAttempts == nil || len(tx.TxAttempts) == 0 { + return + } + + for i := 0; i < len(tx.TxAttempts); i++ { + attempt := tx.TxAttempts[i] + if attempt.State == txmgrtypes.TxAttemptBroadcast && attempt.BroadcastBeforeBlockNum == nil { + tx.TxAttempts[i].BroadcastBeforeBlockNum = &blockNum + } + } + } + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + as.applyToTxsByState(nil, fn) + } + return nil } @@ -205,11 +331,67 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindT []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error, ) { - return nil, nil + return ms.persistentTxStore.FindTxAttemptsConfirmedMissingReceipt(ctx, chainID) + + // TODO(BCI-3115) + /* + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + txFilter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return tx.TxAttempts != nil && len(tx.TxAttempts) > 0 + } + txAttemptFilter := func(attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return true + } + states := []txmgrtypes.TxState{TxConfirmedMissingReceipt} + attempts := []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + attempts = append(attempts, as.findTxAttempts(states, txFilter, txAttemptFilter)...) + } + // sort by tx_id ASC, gas_price DESC, gas_tip_cap DESC + slices.SortFunc(attempts, func(a, b txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) int { + aSequence, bSequence := a.Tx.Sequence, b.Tx.Sequence + if aSequence == nil || bSequence == nil { + return 0 + } + + return cmp.Compare((*aSequence).Int64(), (*bSequence).Int64()) + }) + + // deep copy the attempts + var eAttempts []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + for _, attempt := range attempts { + eAttempts = append(eAttempts, ms.deepCopyTxAttempt(attempt.Tx, attempt)) + } + + return eAttempts, nil + */ } // UpdateBroadcastAts updates the broadcast_at time for a given set of attempts -func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) UpdateBroadcastAts(ctx context.Context, now time.Time, txIDs []int64) error { +func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) UpdateBroadcastAts(ctx context.Context, inTime time.Time, txIDs []int64) error { + // Persist to persistent storage + if err := ms.persistentTxStore.UpdateBroadcastAts(ctx, inTime, txIDs); err != nil { + return err + } + + // Update in memory store + fn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) { + if tx.BroadcastAt != nil && tx.BroadcastAt.Before(inTime) { + tx.BroadcastAt = &inTime + } + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + as.applyToTxsByState(nil, fn, txIDs...) + } + return nil } @@ -223,17 +405,134 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindT attempts []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], err error, ) { - return nil, nil + return ms.persistentTxStore.FindTxAttemptsRequiringReceiptFetch(ctx, chainID) + + // TODO(BCI-3115) + /* + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + txFilterFn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return len(tx.TxAttempts) > 0 + } + txAttemptFilterFn := func(attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return attempt.State != txmgrtypes.TxAttemptInsufficientFunds + } + states := []txmgrtypes.TxState{TxUnconfirmed, TxConfirmedMissingReceipt} + attempts = []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + attempts = append(attempts, as.findTxAttempts(states, txFilterFn, txAttemptFilterFn)...) + } + // sort by sequence ASC, gas_price DESC, gas_tip_cap DESC + slices.SortFunc(attempts, func(a, b txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) int { + aSequence, bSequence := a.Tx.Sequence, b.Tx.Sequence + if aSequence == nil || bSequence == nil { + return 0 + } + + return cmp.Compare((*aSequence).Int64(), (*bSequence).Int64()) + }) + + // deep copy the attempts + var eAttempts []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + for _, attempt := range attempts { + eAttempts = append(eAttempts, ms.deepCopyTxAttempt(attempt.Tx, attempt)) + } + + return eAttempts, nil + */ } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxesPendingCallback(ctx context.Context, blockNum int64, chainID CHAIN_ID) ( []txmgrtypes.ReceiptPlus[R], error, ) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filterFn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if len(tx.TxAttempts) == 0 { + return false + } + + for i := 0; i < len(tx.TxAttempts); i++ { + if len(tx.TxAttempts[i].Receipts) == 0 || !tx.PipelineTaskRunID.Valid || !tx.SignalCallback || tx.CallbackCompleted { + continue + } + + receipt := tx.TxAttempts[i].Receipts[0] + minConfirmations := int64(tx.MinConfirmations.Uint32) + if receipt.GetBlockNumber() != nil && + receipt.GetBlockNumber().Int64() <= (blockNum-minConfirmations) { + return true + } + } + + return false + + } + states := []txmgrtypes.TxState{TxConfirmed} + txs := []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + txs = append(txs, as.findTxs(states, filterFn)...) + } + + receiptsPlus := make([]txmgrtypes.ReceiptPlus[R], len(txs)) + meta := map[string]interface{}{} + for i, tx := range txs { + if err := json.Unmarshal(json.RawMessage(*tx.Meta), &meta); err != nil { + return nil, err + } + failOnRevert := false + if v, ok := meta["FailOnRevert"].(bool); ok { + failOnRevert = v + } + + for j := 0; j < len(tx.TxAttempts); j++ { + if len(tx.TxAttempts[j].Receipts) == 0 { + continue + } + receiptsPlus[i] = txmgrtypes.ReceiptPlus[R]{ + ID: tx.PipelineTaskRunID.UUID, + Receipt: tx.TxAttempts[j].Receipts[0].(R), + FailOnRevert: failOnRevert, + } + } + clear(meta) + } + + return receiptsPlus, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) UpdateTxCallbackCompleted(ctx context.Context, pipelineTaskRunRid uuid.UUID, chainId CHAIN_ID) error { + if ms.chainID.String() != chainId.String() { + panic("invalid chain ID") + } + + // Persist to persistent storage + if err := ms.persistentTxStore.UpdateTxCallbackCompleted(ctx, pipelineTaskRunRid, chainId); err != nil { + return err + } + + // Update in memory store + fn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) { + if tx.PipelineTaskRunID.UUID == pipelineTaskRunRid { + tx.CallbackCompleted = true + } + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + as.applyToTxsByState(nil, fn) + } + return nil } @@ -245,18 +544,146 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindT []*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error, ) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filterFn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if tx.Meta == nil { + return false + } + meta := map[string]interface{}{} + if err := json.Unmarshal(json.RawMessage(*tx.Meta), &meta); err != nil { + return false + } + return isMetaValueEqual(meta[metaField], metaValue) + } + txs := []*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + for _, tx := range as.findTxs(states, filterFn) { + etx := ms.deepCopyTx(tx) + txs = append(txs, etx) + } + } + + return txs, nil } + func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxesWithMetaFieldByStates(ctx context.Context, metaField string, states []txmgrtypes.TxState, chainID *big.Int) ([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filterFn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if tx.Meta == nil { + return false + } + meta := map[string]interface{}{} + if err := json.Unmarshal(json.RawMessage(*tx.Meta), &meta); err != nil { + return false + } + if _, ok := meta[metaField]; ok { + return true + } + + return false + } + + txs := []*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + for _, tx := range as.findTxs(states, filterFn) { + etx := ms.deepCopyTx(tx) + txs = append(txs, etx) + } + } + + return txs, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxesWithMetaFieldByReceiptBlockNum(ctx context.Context, metaField string, blockNum int64, chainID *big.Int) ([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filterFn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if tx.Meta == nil { + return false + } + meta := map[string]interface{}{} + if err := json.Unmarshal(json.RawMessage(*tx.Meta), &meta); err != nil { + return false + } + if _, ok := meta[metaField]; !ok { + return false + } + if len(tx.TxAttempts) == 0 { + return false + } + + for _, attempt := range tx.TxAttempts { + if len(attempt.Receipts) == 0 { + continue + } + if attempt.Receipts[0].GetBlockNumber() == nil { + continue + } + return attempt.Receipts[0].GetBlockNumber().Int64() >= blockNum + } + + return false + } + + txs := []*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + for _, tx := range as.findTxs(nil, filterFn) { + etx := ms.deepCopyTx(tx) + txs = append(txs, etx) + } + } + + return txs, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxesWithAttemptsAndReceiptsByIdsAndState(ctx context.Context, ids []big.Int, states []txmgrtypes.TxState, chainID *big.Int) (tx []*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], err error) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filterFn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return true + } + + txIDs := make([]int64, len(ids)) + for i, id := range ids { + txIDs[i] = id.Int64() + } + + txsLock := sync.Mutex{} + txs := []*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + wg := sync.WaitGroup{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + wg.Add(1) + go func(as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) { + for _, tx := range as.findTxs(states, filterFn, txIDs...) { + etx := ms.deepCopyTx(tx) + txsLock.Lock() + txs = append(txs, etx) + txsLock.Unlock() + } + wg.Done() + }(as) + } + wg.Wait() + + return txs, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) PruneUnstartedTxQueue(ctx context.Context, queueSize uint32, subject uuid.UUID) ([]int64, error) { @@ -267,7 +694,18 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) ReapT return nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) CountTransactionsByState(_ context.Context, state txmgrtypes.TxState, chainID CHAIN_ID) (uint32, error) { - return 0, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + var total int + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + total += as.countTransactionsByState(state) + } + + return uint32(total), nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) DeleteInProgressAttempt(ctx context.Context, attempt txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) error { @@ -275,70 +713,622 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) Delet } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxsRequiringResubmissionDueToInsufficientFunds(_ context.Context, address ADDR, chainID CHAIN_ID) ([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[address] + if !ok { + return nil, nil + } + + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if len(tx.TxAttempts) == 0 { + return false + } + for _, attempt := range tx.TxAttempts { + if attempt.State == txmgrtypes.TxAttemptInsufficientFunds { + return true + } + } + return false + } + states := []txmgrtypes.TxState{TxUnconfirmed} + txs := as.findTxs(states, filter) + // sort by sequence ASC + slices.SortFunc(txs, func(a, b txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) int { + aSequence, bSequence := a.Sequence, b.Sequence + if aSequence == nil || bSequence == nil { + return 0 + } + + return cmp.Compare((*aSequence).Int64(), (*bSequence).Int64()) + }) + + etxs := make([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], len(txs)) + for i, tx := range txs { + etxs[i] = ms.deepCopyTx(tx) + } + + return etxs, nil } -func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxAttemptsRequiringResend(_ context.Context, olderThan time.Time, maxInFlightTransactions uint32, chainID CHAIN_ID, address ADDR) ([]txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil +func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxAttemptsRequiringResend(ctx context.Context, olderThan time.Time, maxInFlightTransactions uint32, chainID CHAIN_ID, address ADDR) ([]txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { + return ms.persistentTxStore.FindTxAttemptsRequiringResend(ctx, olderThan, maxInFlightTransactions, chainID, address) + + // TODO(BCI-3115) + /* + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[address] + if !ok { + return nil, nil + } + + txFilter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if len(tx.TxAttempts) == 0 { + return false + } + return tx.BroadcastAt.Before(olderThan) || tx.BroadcastAt.Equal(olderThan) + } + txAttemptFilter := func(attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return attempt.State != txmgrtypes.TxAttemptInProgress + } + states := []txmgrtypes.TxState{TxUnconfirmed, TxConfirmedMissingReceipt} + attempts := as.findTxAttempts(states, txFilter, txAttemptFilter) + // sort by sequence ASC, gas_price DESC, gas_tip_cap DESC + // TODO(BCI-3115) + slices.SortFunc(attempts, func(a, b txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) int { + aSequence, bSequence := a.Tx.Sequence, b.Tx.Sequence + if aSequence == nil || bSequence == nil { + return 0 + } + + return cmp.Compare((*aSequence).Int64(), (*bSequence).Int64()) + }) + // LIMIT by maxInFlightTransactions + if maxInFlightTransactions > 0 && len(attempts) > int(maxInFlightTransactions) { + attempts = attempts[:maxInFlightTransactions] + } + + // deep copy the attempts + var eAttempts []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + for _, attempt := range attempts { + eAttempts = append(eAttempts, ms.deepCopyTxAttempt(attempt.Tx, attempt)) + } + + return eAttempts, nil + */ } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxWithSequence(_ context.Context, fromAddress ADDR, seq SEQ) (*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[fromAddress] + if !ok { + return nil, nil + } + + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if tx.Sequence == nil { + return false + } + + return (*tx.Sequence).String() == seq.String() + } + states := []txmgrtypes.TxState{TxConfirmed, TxConfirmedMissingReceipt, TxUnconfirmed} + txs := as.findTxs(states, filter) + if len(txs) == 0 { + return nil, nil + } + + return ms.deepCopyTx(txs[0]), nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTransactionsConfirmedInBlockRange(_ context.Context, highBlockNumber, lowBlockNumber int64, chainID CHAIN_ID) ([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if len(tx.TxAttempts) == 0 { + return false + } + for _, attempt := range tx.TxAttempts { + if attempt.State != txmgrtypes.TxAttemptBroadcast { + continue + } + if len(attempt.Receipts) == 0 { + continue + } + if attempt.Receipts[0].GetBlockNumber() == nil { + continue + } + blockNum := attempt.Receipts[0].GetBlockNumber().Int64() + if blockNum >= lowBlockNumber && blockNum <= highBlockNumber { + return true + } + } + + return false + } + states := []txmgrtypes.TxState{TxConfirmed, TxConfirmedMissingReceipt} + txs := []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + ts := as.findTxs(states, filter) + txs = append(txs, ts...) + } + // sort by sequence ASC + slices.SortFunc(txs, func(a, b txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) int { + aSequence, bSequence := a.Sequence, b.Sequence + if aSequence == nil || bSequence == nil { + return 0 + } + + return cmp.Compare((*aSequence).Int64(), (*bSequence).Int64()) + }) + + etxs := make([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], len(txs)) + for i, tx := range txs { + etxs[i] = ms.deepCopyTx(tx) + } + + return etxs, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindEarliestUnconfirmedBroadcastTime(ctx context.Context, chainID CHAIN_ID) (null.Time, error) { - return null.Time{}, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return tx.InitialBroadcastAt != nil + } + states := []txmgrtypes.TxState{TxUnconfirmed} + txs := []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + etxs := as.findTxs(states, filter) + txs = append(txs, etxs...) + } + + var minInitialBroadcastAt time.Time + for _, tx := range txs { + if tx.InitialBroadcastAt == nil { + continue + } + if minInitialBroadcastAt.IsZero() || tx.InitialBroadcastAt.Before(minInitialBroadcastAt) { + minInitialBroadcastAt = *tx.InitialBroadcastAt + } + } + if minInitialBroadcastAt.IsZero() { + return null.Time{}, nil + } + + return null.TimeFrom(minInitialBroadcastAt), nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindEarliestUnconfirmedTxAttemptBlock(ctx context.Context, chainID CHAIN_ID) (null.Int, error) { - return null.Int{}, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if len(tx.TxAttempts) == 0 { + return false + } + + for _, attempt := range tx.TxAttempts { + if attempt.BroadcastBeforeBlockNum != nil { + return true + } + } + + return false + } + states := []txmgrtypes.TxState{TxUnconfirmed} + txs := []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + etxs := as.findTxs(states, filter) + txs = append(txs, etxs...) + } + + var minBroadcastBeforeBlockNum int64 + for _, tx := range txs { + if minBroadcastBeforeBlockNum == 0 || *tx.TxAttempts[0].BroadcastBeforeBlockNum < minBroadcastBeforeBlockNum { + minBroadcastBeforeBlockNum = *tx.TxAttempts[0].BroadcastBeforeBlockNum + } + } + if minBroadcastBeforeBlockNum == 0 { + return null.Int{}, nil + } + + return null.IntFrom(minBroadcastBeforeBlockNum), nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) GetInProgressTxAttempts(ctx context.Context, address ADDR, chainID CHAIN_ID) ([]txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[address] + if !ok { + return nil, fmt.Errorf("get_in_progress_tx_attempts: %w", ErrAddressNotFound) + } + + txFilter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return tx.TxAttempts != nil && len(tx.TxAttempts) > 0 + } + txAttemptFilter := func(attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return attempt.State == txmgrtypes.TxAttemptInProgress + } + states := []txmgrtypes.TxState{TxConfirmed, TxConfirmedMissingReceipt, TxUnconfirmed} + attempts := as.findTxAttempts(states, txFilter, txAttemptFilter) + + // deep copy the attempts + var eAttempts []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + for _, attempt := range attempts { + eAttempts = append(eAttempts, ms.deepCopyTxAttempt(attempt.Tx, attempt)) + } + + return eAttempts, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) GetNonFatalTransactions(ctx context.Context, chainID CHAIN_ID) ([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return tx.State != TxFatalError + } + txs := []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + etxs := as.findTxs(nil, filter) + txs = append(txs, etxs...) + } + + etxs := make([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], len(txs)) + for i, tx := range txs { + etxs[i] = ms.deepCopyTx(tx) + } + + return etxs, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) GetTxByID(_ context.Context, id int64) (*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return tx.ID == id + } + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + txs := as.findTxs(nil, filter, id) + if len(txs) > 0 { + return ms.deepCopyTx(txs[0]), nil + } + } + return nil, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) HasInProgressTransaction(_ context.Context, account ADDR, chainID CHAIN_ID) (bool, error) { - return false, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[account] + if !ok { + return false, fmt.Errorf("has_in_progress_transaction: %w", ErrAddressNotFound) + } + + n := as.countTransactionsByState(TxInProgress) + + return n > 0, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) LoadTxAttempts(_ context.Context, etx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) error { + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[etx.FromAddress] + if !ok { + return nil + } + + txAttempts := []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]{} + if tx := as.findTxByID(etx.ID); tx != nil { + for _, txAttempt := range tx.TxAttempts { + txAttempts = append(txAttempts, ms.deepCopyTxAttempt(*etx, txAttempt)) + } + } + etx.TxAttempts = txAttempts + return nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) PreloadTxes(_ context.Context, attempts []txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) error { + if len(attempts) == 0 { + return nil + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[attempts[0].Tx.FromAddress] + if !ok { + return nil + } + + txIDs := make([]int64, len(attempts)) + for i, attempt := range attempts { + txIDs[i] = attempt.TxID + } + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return true + } + txs := as.findTxs(nil, filter, txIDs...) + for i, attempt := range attempts { + for _, tx := range txs { + if tx.ID == attempt.TxID { + attempts[i].Tx = *ms.deepCopyTx(tx) + } + } + } + return nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) SaveConfirmedMissingReceiptAttempt(ctx context.Context, timeout time.Duration, attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], broadcastAt time.Time) error { return nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) SaveInProgressAttempt(ctx context.Context, attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) error { - return nil + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + + if attempt.State != txmgrtypes.TxAttemptInProgress { + return fmt.Errorf("SaveInProgressAttempt failed: attempt state must be in_progress") + } + + var tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + var as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE] + for _, vas := range ms.addressStates { + fn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return true + } + txs := vas.findTxs(nil, fn, attempt.TxID) + if len(txs) != 0 { + tx = &txs[0] + as = vas + break + } + } + if tx == nil { + return fmt.Errorf("save_in_progress_attempt: %w: with attempt hash %q", ErrTxnNotFound, attempt.Hash) + } + + // Check if the attempt already exists by checking if id is zero + // this will be used by memory store + var txAttemptExists bool + if attempt.ID != 0 { + txAttemptExists = true + } + + // Persist to persistent storage + if err := ms.persistentTxStore.SaveInProgressAttempt(ctx, attempt); err != nil { + return err + } + + // Update in memory store + if txAttemptExists { + return as.updateInProgressTxAttempt(*attempt) + } + + return as.addInProgressTxAttempt(*attempt) } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) SaveInsufficientFundsAttempt(ctx context.Context, timeout time.Duration, attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], broadcastAt time.Time) error { + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + + if !(attempt.State == txmgrtypes.TxAttemptInProgress || attempt.State == txmgrtypes.TxAttemptInsufficientFunds) { + return fmt.Errorf("expected state to be in_progress or insufficient_funds") + } + + var tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + var as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE] + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { return true } + for _, vas := range ms.addressStates { + txs := vas.findTxs(nil, filter, attempt.TxID) + if len(txs) > 0 { + tx = &txs[0] + as = vas + break + } + } + if tx == nil { + return nil + } + + // Persist to persistent storage + if err := ms.persistentTxStore.SaveInsufficientFundsAttempt(ctx, timeout, attempt, broadcastAt); err != nil { + return err + } + + // Update in memory store + fn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) { + if tx.ID != attempt.TxID { + return + } + if tx.TxAttempts == nil || len(tx.TxAttempts) == 0 { + return + } + if tx.BroadcastAt != nil && tx.BroadcastAt.Before(broadcastAt) { + tx.BroadcastAt = &broadcastAt + } + + for i := 0; i < len(tx.TxAttempts); i++ { + if tx.TxAttempts[i].ID == attempt.ID { + tx.TxAttempts[i].State = txmgrtypes.TxAttemptInsufficientFunds + } + } + } + as.applyToTxsByState(nil, fn, attempt.TxID) + return nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) SaveSentAttempt(ctx context.Context, timeout time.Duration, attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], broadcastAt time.Time) error { + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + + if attempt.State != txmgrtypes.TxAttemptInProgress { + return fmt.Errorf("expected state to be in_progress") + } + + var tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + var as *addressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE] + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { return true } + for _, vas := range ms.addressStates { + txs := vas.findTxs(nil, filter, attempt.TxID) + if len(txs) != 0 { + tx = &txs[0] + as = vas + break + } + } + if tx == nil { + return fmt.Errorf("save_sent_attempt: %w", ErrTxnNotFound) + } + + // Persist to persistent storage + if err := ms.persistentTxStore.SaveSentAttempt(ctx, timeout, attempt, broadcastAt); err != nil { + return err + } + + // Update in memory store + fn := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) { + if len(tx.TxAttempts) == 0 { + return + } + if tx.BroadcastAt != nil && tx.BroadcastAt.Before(broadcastAt) { + tx.BroadcastAt = &broadcastAt + } + + for i := 0; i < len(tx.TxAttempts); i++ { + if tx.TxAttempts[i].ID == attempt.ID { + tx.TxAttempts[i].State = txmgrtypes.TxAttemptBroadcast + return + } + } + } + as.applyToTxsByState(nil, fn, attempt.TxID) + return nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) UpdateTxForRebroadcast(ctx context.Context, etx txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], etxAttempt txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) error { return nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) IsTxFinalized(ctx context.Context, blockHeight int64, txID int64, chainID CHAIN_ID) (bool, error) { + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + + txFilter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if tx.ID != txID { + return false + } + + for _, attempt := range tx.TxAttempts { + if len(attempt.Receipts) == 0 { + continue + } + // there can only be one receipt per attempt + if attempt.Receipts[0].GetBlockNumber() == nil { + continue + } + return attempt.Receipts[0].GetBlockNumber().Int64() <= (blockHeight - int64(tx.MinConfirmations.Uint32)) + } + + return false + } + txAttemptFilter := func(attempt *txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + return len(attempt.Receipts) > 0 + } + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + for _, as := range ms.addressStates { + txas := as.findTxAttempts(nil, txFilter, txAttemptFilter, txID) + if len(txas) > 0 { + return true, nil + } + } + return false, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxsRequiringGasBump(ctx context.Context, address ADDR, blockNum, gasBumpThreshold, depth int64, chainID CHAIN_ID) ([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { - return nil, nil + if ms.chainID.String() != chainID.String() { + panic("invalid chain ID") + } + if gasBumpThreshold == 0 { + return nil, nil + } + + ms.addressStatesLock.RLock() + defer ms.addressStatesLock.RUnlock() + as, ok := ms.addressStates[address] + if !ok { + return nil, nil + } + + filter := func(tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) bool { + if len(tx.TxAttempts) == 0 { + return true + } + for _, attempt := range tx.TxAttempts { + if attempt.BroadcastBeforeBlockNum == nil || *attempt.BroadcastBeforeBlockNum > blockNum-gasBumpThreshold || attempt.State != txmgrtypes.TxAttemptBroadcast { + return false + } + } + + return true + } + states := []txmgrtypes.TxState{TxUnconfirmed} + txs := as.findTxs(states, filter) + + // sort by sequence ASC + slices.SortFunc(txs, func(a, b txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) int { + aSequence, bSequence := a.Sequence, b.Sequence + if aSequence == nil || bSequence == nil { + return 0 + } + + return cmp.Compare((*aSequence).Int64(), (*bSequence).Int64()) + }) + + if depth > 0 { + // LIMIT by depth + if len(txs) > int(depth) { + txs = txs[:depth] + } + } + + etxs := make([]*txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], len(txs)) + for i, tx := range txs { + etxs[i] = ms.deepCopyTx(tx) + } + + return etxs, nil } func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) MarkAllConfirmedMissingReceipt(ctx context.Context, chainID CHAIN_ID) error { return nil @@ -410,3 +1400,60 @@ func (ms *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) deepC return copyAttempt } + +func isMetaValueEqual(v interface{}, metaValue string) bool { + switch v := v.(type) { + case string: + return v == metaValue + case int: + o, err := strconv.ParseInt(metaValue, 10, 64) + if err != nil { + return false + } + return v == int(o) + case uint32: + o, err := strconv.ParseUint(metaValue, 10, 32) + if err != nil { + return false + } + return v == uint32(o) + case uint64: + o, err := strconv.ParseUint(metaValue, 10, 64) + if err != nil { + return false + } + return v == o + case int32: + o, err := strconv.ParseInt(metaValue, 10, 32) + if err != nil { + return false + } + return v == int32(o) + case int64: + o, err := strconv.ParseInt(metaValue, 10, 64) + if err != nil { + return false + } + return v == o + case float32: + o, err := strconv.ParseFloat(metaValue, 32) + if err != nil { + return false + } + return v == float32(o) + case float64: + o, err := strconv.ParseFloat(metaValue, 64) + if err != nil { + return false + } + return v == o + case bool: + o, err := strconv.ParseBool(metaValue) + if err != nil { + return false + } + return v == o + } + + return false +} diff --git a/common/txmgr/test_helpers.go b/common/txmgr/test_helpers.go index fa9af9a506a..9b9ddb7801a 100644 --- a/common/txmgr/test_helpers.go +++ b/common/txmgr/test_helpers.go @@ -2,7 +2,7 @@ package txmgr import ( "context" - "fmt" + "sort" "time" txmgrtypes "github.com/smartcontractkit/chainlink/v2/common/txmgr/types" @@ -54,7 +54,8 @@ func (b *Txm[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) XXXTestAba func (b *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) XXXTestInsertTx(fromAddr ADDR, tx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) error { as, ok := b.addressStates[fromAddr] if !ok { - return fmt.Errorf("address not found: %s", fromAddr) + as = newAddressState[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE](b.lggr, b.chainID, fromAddr, 10, nil) + b.addressStates[fromAddr] = as } as.allTxs[tx.ID] = tx @@ -93,6 +94,14 @@ func (b *inMemoryStore[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) XXXTes for _, as := range b.addressStates { txs = append(txs, as.findTxs(txStates, filter, txIDs...)...) } + // sort by created_at asc and then by id asc + sort.Slice(txs, func(i, j int) bool { + if txs[i].CreatedAt.Equal(txs[j].CreatedAt) { + return txs[i].ID < txs[j].ID + } + + return txs[i].CreatedAt.Before(txs[j].CreatedAt) + }) return txs } diff --git a/common/txmgr/types/mocks/tx_store.go b/common/txmgr/types/mocks/tx_store.go index 814207d3986..0c141d44f2f 100644 --- a/common/txmgr/types/mocks/tx_store.go +++ b/common/txmgr/types/mocks/tx_store.go @@ -700,6 +700,36 @@ func (_m *TxStore[ADDR, CHAIN_ID, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) FindTxsRequ return r0, r1 } +// GetAllTransactions provides a mock function with given fields: ctx, chainID +func (_m *TxStore[ADDR, CHAIN_ID, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) GetAllTransactions(ctx context.Context, chainID CHAIN_ID) ([]txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { + ret := _m.Called(ctx, chainID) + + if len(ret) == 0 { + panic("no return value specified for GetAllTransactions") + } + + var r0 []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, CHAIN_ID) ([]txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error)); ok { + return rf(ctx, chainID) + } + if rf, ok := ret.Get(0).(func(context.Context, CHAIN_ID) []txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]); ok { + r0 = rf(ctx, chainID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE]) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, CHAIN_ID) error); ok { + r1 = rf(ctx, chainID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetInProgressTxAttempts provides a mock function with given fields: ctx, address, chainID func (_m *TxStore[ADDR, CHAIN_ID, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) GetInProgressTxAttempts(ctx context.Context, address ADDR, chainID CHAIN_ID) ([]txmgrtypes.TxAttempt[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], error) { ret := _m.Called(ctx, address, chainID) diff --git a/core/chains/evm/txmgr/evm_inmemory_store_test.go b/core/chains/evm/txmgr/evm_inmemory_store_test.go index a102ee1c996..9b9acbc15dd 100644 --- a/core/chains/evm/txmgr/evm_inmemory_store_test.go +++ b/core/chains/evm/txmgr/evm_inmemory_store_test.go @@ -1,14 +1,1976 @@ package txmgr_test import ( + "encoding/json" + "math/big" "testing" + "time" + "github.com/ethereum/go-ethereum/common" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/guregu/null.v4" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" + clnull "github.com/smartcontractkit/chainlink-common/pkg/utils/null" + commontxmgr "github.com/smartcontractkit/chainlink/v2/common/txmgr" + txmgrtypes "github.com/smartcontractkit/chainlink/v2/common/txmgr/types" + + evmassets "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" + evmgas "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" evmtxmgr "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txmgr" + evmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/types" + evmutils "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils/evmtest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils/pgtest" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" ) +func TestInMemoryStore_FindTxesPendingCallback(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + head := evmtypes.Head{ + Hash: evmutils.NewHash(), + Number: 10, + Parent: &evmtypes.Head{ + Hash: evmutils.NewHash(), + Number: 9, + Parent: &evmtypes.Head{ + Number: 8, + Hash: evmutils.NewHash(), + Parent: nil, + }, + }, + } + minConfirmations := int64(2) + + pgtest.MustExec(t, db, `SET CONSTRAINTS pipeline_runs_pipeline_spec_id_fkey DEFERRED`) + // insert the transaction into the persistent store + // Suspended run waiting for callback + run1 := cltest.MustInsertPipelineRun(t, db) + tr1 := cltest.MustInsertUnfinishedPipelineTaskRun(t, db, run1.ID) + pgtest.MustExec(t, db, `UPDATE pipeline_runs SET state = 'suspended' WHERE id = $1`, run1.ID) + inTx_0 := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 3, 1, fromAddress) + pgtest.MustExec(t, db, `UPDATE evm.txes SET meta='{"FailOnRevert": true}'`) + attempt1 := inTx_0.TxAttempts[0] + r_0 := mustInsertEthReceipt(t, persistentStore, head.Number-minConfirmations, head.Hash, attempt1.Hash) + pgtest.MustExec(t, db, `UPDATE evm.txes SET pipeline_task_run_id = $1, min_confirmations = $2, signal_callback = TRUE WHERE id = $3`, &tr1.ID, minConfirmations, inTx_0.ID) + failOnRevert := null.BoolFrom(true) + b, err := json.Marshal(evmtxmgr.TxMeta{FailOnRevert: failOnRevert}) + require.NoError(t, err) + meta := sqlutil.JSON(b) + inTx_0.Meta = &meta + inTx_0.TxAttempts[0].Receipts = append(inTx_0.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&r_0)) + inTx_0.MinConfirmations = clnull.Uint32From(uint32(minConfirmations)) + inTx_0.PipelineTaskRunID = uuid.NullUUID{UUID: tr1.ID, Valid: true} + inTx_0.SignalCallback = true + + // Callback to pipeline service completed. Should be ignored + run2 := cltest.MustInsertPipelineRunWithStatus(t, db, 0, pipeline.RunStatusCompleted) + tr2 := cltest.MustInsertUnfinishedPipelineTaskRun(t, db, run2.ID) + inTx_1 := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 4, 1, fromAddress) + pgtest.MustExec(t, db, `UPDATE evm.txes SET meta='{"FailOnRevert": false}'`) + attempt2 := inTx_1.TxAttempts[0] + r_1 := mustInsertEthReceipt(t, persistentStore, head.Number-minConfirmations, head.Hash, attempt2.Hash) + pgtest.MustExec(t, db, `UPDATE evm.txes SET pipeline_task_run_id = $1, min_confirmations = $2, signal_callback = TRUE, callback_completed = TRUE WHERE id = $3`, &tr2.ID, minConfirmations, inTx_1.ID) + failOnRevert = null.BoolFrom(false) + b, err = json.Marshal(evmtxmgr.TxMeta{FailOnRevert: failOnRevert}) + require.NoError(t, err) + meta = sqlutil.JSON(b) + inTx_1.Meta = &meta + inTx_1.TxAttempts[0].Receipts = append(inTx_1.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&r_1)) + inTx_1.MinConfirmations = clnull.Uint32From(uint32(minConfirmations)) + inTx_1.PipelineTaskRunID = uuid.NullUUID{UUID: tr2.ID, Valid: true} + inTx_1.SignalCallback = true + inTx_1.CallbackCompleted = true + + // Suspended run younger than minConfirmations. Should be ignored + run3 := cltest.MustInsertPipelineRun(t, db) + tr3 := cltest.MustInsertUnfinishedPipelineTaskRun(t, db, run3.ID) + pgtest.MustExec(t, db, `UPDATE pipeline_runs SET state = 'suspended' WHERE id = $1`, run3.ID) + inTx_2 := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 5, 1, fromAddress) + pgtest.MustExec(t, db, `UPDATE evm.txes SET meta='{"FailOnRevert": false}'`) + attempt3 := inTx_2.TxAttempts[0] + r_2 := mustInsertEthReceipt(t, persistentStore, head.Number, head.Hash, attempt3.Hash) + pgtest.MustExec(t, db, `UPDATE evm.txes SET pipeline_task_run_id = $1, min_confirmations = $2, signal_callback = TRUE WHERE id = $3`, &tr3.ID, minConfirmations, inTx_2.ID) + failOnRevert = null.BoolFrom(false) + b, err = json.Marshal(evmtxmgr.TxMeta{FailOnRevert: failOnRevert}) + require.NoError(t, err) + meta = sqlutil.JSON(b) + inTx_2.Meta = &meta + inTx_2.TxAttempts[0].Receipts = append(inTx_2.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&r_2)) + inTx_2.MinConfirmations = clnull.Uint32From(uint32(minConfirmations)) + inTx_2.PipelineTaskRunID = uuid.NullUUID{UUID: tr3.ID, Valid: true} + inTx_2.SignalCallback = true + + // Tx not marked for callback. Should be ignore + inTx_3 := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 6, 1, fromAddress) + attempt4 := inTx_3.TxAttempts[0] + r_3 := mustInsertEthReceipt(t, persistentStore, head.Number, head.Hash, attempt4.Hash) + pgtest.MustExec(t, db, `UPDATE evm.txes SET min_confirmations = $1 WHERE id = $2`, minConfirmations, inTx_3.ID) + inTx_3.TxAttempts[0].Receipts = append(inTx_3.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&r_3)) + inTx_3.MinConfirmations = clnull.Uint32From(uint32(minConfirmations)) + + // Unconfirmed Tx without receipts. Should be ignored + inTx_4 := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 7, 1, fromAddress) + pgtest.MustExec(t, db, `UPDATE evm.txes SET min_confirmations = $1 WHERE id = $2`, minConfirmations, inTx_4.ID) + inTx_4.MinConfirmations = clnull.Uint32From(uint32(minConfirmations)) + + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_0)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_1)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_2)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_3)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_4)) + + tcs := []struct { + name string + inHeadNumber int64 + inChainID *big.Int + + hasErr bool + hasReceipts bool + }{ + {"successfully finds receipts", head.Number, chainID, false, true}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actReceipts, actErr := inMemoryStore.FindTxesPendingCallback(ctx, tc.inHeadNumber, tc.inChainID) + expReceipts, expErr := persistentStore.FindTxesPendingCallback(ctx, tc.inHeadNumber, tc.inChainID) + require.Equal(t, expErr, actErr) + if tc.hasErr { + require.NotNil(t, expErr) + require.NotNil(t, actErr) + } else { + require.Nil(t, expErr) + require.Nil(t, actErr) + } + if tc.hasReceipts { + require.NotEqual(t, 0, len(expReceipts)) + assert.NotEqual(t, 0, len(actReceipts)) + require.Equal(t, len(expReceipts), len(actReceipts)) + for i := 0; i < len(expReceipts); i++ { + assert.Equal(t, expReceipts[i].ID, actReceipts[i].ID) + assert.Equal(t, expReceipts[i].FailOnRevert, actReceipts[i].FailOnRevert) + assertChainReceiptEqual(t, expReceipts[i].Receipt, actReceipts[i].Receipt) + } + } else { + require.Equal(t, 0, len(expReceipts)) + require.Equal(t, 0, len(actReceipts)) + } + }) + } +} + +func TestInMemoryStore_FindTxAttemptsRequiringResend(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // insert the transaction into the persistent store + inTx_1 := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, 1, fromAddress, time.Unix(1616509200, 0)) + inTx_3 := mustInsertUnconfirmedEthTxWithBroadcastDynamicFeeAttempt(t, persistentStore, 3, fromAddress, time.Unix(1616509400, 0)) + inTx_0 := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, 0, fromAddress, time.Unix(1616509100, 0)) + inTx_2 := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, 2, fromAddress, time.Unix(1616509300, 0)) + // modify the attempts + attempt0_2 := newBroadcastLegacyEthTxAttempt(t, inTx_0.ID) + attempt0_2.TxFee = evmgas.EvmFee{Legacy: evmassets.NewWeiI(10)} + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &attempt0_2)) + + attempt2_2 := newInProgressLegacyEthTxAttempt(t, inTx_2.ID) + attempt2_2.TxFee = evmgas.EvmFee{Legacy: evmassets.NewWeiI(10)} + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &attempt2_2)) + + attempt3_2 := cltest.NewDynamicFeeEthTxAttempt(t, inTx_3.ID) + attempt3_2.TxFee.DynamicTipCap = evmassets.NewWeiI(10) + attempt3_2.TxFee.DynamicFeeCap = evmassets.NewWeiI(20) + attempt3_2.State = txmgrtypes.TxAttemptBroadcast + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &attempt3_2)) + attempt3_4 := cltest.NewDynamicFeeEthTxAttempt(t, inTx_3.ID) + attempt3_4.TxFee.DynamicTipCap = evmassets.NewWeiI(30) + attempt3_4.TxFee.DynamicFeeCap = evmassets.NewWeiI(40) + attempt3_4.State = txmgrtypes.TxAttemptBroadcast + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &attempt3_4)) + attempt3_3 := cltest.NewDynamicFeeEthTxAttempt(t, inTx_3.ID) + attempt3_3.TxFee.DynamicTipCap = evmassets.NewWeiI(20) + attempt3_3.TxFee.DynamicFeeCap = evmassets.NewWeiI(30) + attempt3_3.State = txmgrtypes.TxAttemptBroadcast + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &attempt3_3)) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_0)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_1)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_2)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_3)) + + tcs := []struct { + name string + inOlderThan time.Time + inMaxInFlightTransactions uint32 + inChainID *big.Int + inFromAddress common.Address + + hasErr bool + hasTxAttempts bool + }{ + {"finds nothing if transactions from a different key", time.Now(), 10, chainID, evmutils.RandomAddress(), false, false}, + {"returns the highest price attempt for each transaction that was last broadcast before or on the given time", time.Unix(1616509200, 0), 0, chainID, fromAddress, false, true}, + {"returns the highest price attempt for EIP-1559 transactions", time.Unix(1616509400, 0), 0, chainID, fromAddress, false, true}, + {"applies limit", time.Unix(1616509200, 0), 1, chainID, fromAddress, false, true}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actTxAttempts, actErr := inMemoryStore.FindTxAttemptsRequiringResend(ctx, tc.inOlderThan, tc.inMaxInFlightTransactions, tc.inChainID, tc.inFromAddress) + expTxAttempts, expErr := persistentStore.FindTxAttemptsRequiringResend(ctx, tc.inOlderThan, tc.inMaxInFlightTransactions, tc.inChainID, tc.inFromAddress) + require.Equal(t, expErr, actErr) + if tc.hasErr { + require.NotNil(t, expErr) + require.NotNil(t, actErr) + } else { + require.Nil(t, expErr) + require.Nil(t, actErr) + } + if tc.hasTxAttempts { + require.NotEqual(t, 0, len(expTxAttempts)) + assert.NotEqual(t, 0, len(actTxAttempts)) + require.Equal(t, len(expTxAttempts), len(actTxAttempts)) + for i := 0; i < len(expTxAttempts); i++ { + assertTxAttemptEqual(t, expTxAttempts[i], actTxAttempts[i]) + } + } else { + require.Equal(t, 0, len(expTxAttempts)) + require.Equal(t, 0, len(actTxAttempts)) + } + }) + } +} + +func TestInMemoryStore_FindTxesWithMetaFieldByReceiptBlockNum(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // initialize the Meta field which is sqlutil.JSON + subID := uint64(123) + b, err := json.Marshal(evmtxmgr.TxMeta{SubID: &subID}) + require.NoError(t, err) + meta := sqlutil.JSON(b) + timeNow := time.Now() + nonce := evmtypes.Nonce(123) + blockNum := int64(3) + broadcastBeforeBlockNum := int64(3) + // initialize transactions + inTx_0 := cltest.NewEthTx(fromAddress) + inTx_0.BroadcastAt = &timeNow + inTx_0.InitialBroadcastAt = &timeNow + inTx_0.Sequence = &nonce + inTx_0.State = commontxmgr.TxConfirmed + inTx_0.MinConfirmations.SetValid(6) + inTx_0.Meta = &meta + // insert the transaction into the persistent store + require.NoError(t, persistentStore.InsertTx(ctx, &inTx_0)) + attempt := cltest.NewLegacyEthTxAttempt(t, inTx_0.ID) + attempt.BroadcastBeforeBlockNum = &broadcastBeforeBlockNum + attempt.State = txmgrtypes.TxAttemptBroadcast + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &attempt)) + inTx_0.TxAttempts = append(inTx_0.TxAttempts, attempt) + // insert the transaction receipt into the persistent store + rec_0 := mustInsertEthReceipt(t, persistentStore, 3, evmutils.NewHash(), inTx_0.TxAttempts[0].Hash) + inTx_0.TxAttempts[0].Receipts = append(inTx_0.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&rec_0)) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_0)) + + tcs := []struct { + name string + inMetaField string + inBlockNum int64 + inChainID *big.Int + + hasErr bool + hasTxs bool + }{ + {"successfully finds tx", "SubId", blockNum, chainID, false, true}, + {"unknown meta_field: finds no txs", "unknown", blockNum, chainID, false, false}, + {"incorrect meta_field: finds no txs", "MaxLink", blockNum, chainID, false, false}, + {"incorrect blockNum: finds no txs", "SubId", 12, chainID, false, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actTxs, actErr := inMemoryStore.FindTxesWithMetaFieldByReceiptBlockNum(ctx, tc.inMetaField, tc.inBlockNum, tc.inChainID) + expTxs, expErr := persistentStore.FindTxesWithMetaFieldByReceiptBlockNum(ctx, tc.inMetaField, tc.inBlockNum, tc.inChainID) + require.Equal(t, expErr, actErr) + if tc.hasErr { + require.NotNil(t, expErr) + require.NotNil(t, actErr) + } else { + require.Nil(t, expErr) + require.Nil(t, actErr) + } + if tc.hasTxs { + require.NotEqual(t, 0, len(expTxs)) + assert.NotEqual(t, 0, len(actTxs)) + require.Equal(t, len(expTxs), len(actTxs)) + for i := 0; i < len(expTxs); i++ { + assertTxEqual(t, *expTxs[i], *actTxs[i]) + } + } else { + require.Equal(t, 0, len(expTxs)) + require.Equal(t, 0, len(actTxs)) + } + }) + } +} + +func TestInMemoryStore_FindTxesWithMetaFieldByStates(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // initialize the Meta field which is sqlutil.JSON + subID := uint64(123) + b, err := json.Marshal(evmtxmgr.TxMeta{SubID: &subID}) + require.NoError(t, err) + meta := sqlutil.JSON(b) + // initialize transactions + inTx_0 := cltest.NewEthTx(fromAddress) + inTx_0.Meta = &meta + // insert the transaction into the persistent store + require.NoError(t, persistentStore.InsertTx(ctx, &inTx_0)) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_0)) + + tcs := []struct { + name string + inMetaField string + inStates []txmgrtypes.TxState + inChainID *big.Int + + hasErr bool + hasTxs bool + }{ + {"successfully finds tx", "SubId", []txmgrtypes.TxState{commontxmgr.TxUnstarted}, chainID, false, true}, + {"incorrect state: finds no txs", "SubId", []txmgrtypes.TxState{commontxmgr.TxConfirmed}, chainID, false, false}, + {"unknown meta_field: finds no txs", "unknown", []txmgrtypes.TxState{commontxmgr.TxUnstarted}, chainID, false, false}, + {"incorrect meta_field: finds no txs", "MaxLink", []txmgrtypes.TxState{commontxmgr.TxUnstarted}, chainID, false, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actTxs, actErr := inMemoryStore.FindTxesWithMetaFieldByStates(ctx, tc.inMetaField, tc.inStates, tc.inChainID) + expTxs, expErr := persistentStore.FindTxesWithMetaFieldByStates(ctx, tc.inMetaField, tc.inStates, tc.inChainID) + require.Equal(t, expErr, actErr) + if !tc.hasErr { + require.Nil(t, expErr) + require.Nil(t, actErr) + } + if tc.hasTxs { + require.NotEqual(t, 0, len(expTxs)) + assert.NotEqual(t, 0, len(actTxs)) + require.Equal(t, len(expTxs), len(actTxs)) + for i := 0; i < len(expTxs); i++ { + assertTxEqual(t, *expTxs[i], *actTxs[i]) + } + } else { + require.Equal(t, 0, len(expTxs)) + require.Equal(t, 0, len(actTxs)) + } + }) + } +} + +func TestInMemoryStore_FindTxesByMetaFieldAndStates(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // initialize the Meta field which is sqlutil.JSON + subID := uint64(123) + b, err := json.Marshal(evmtxmgr.TxMeta{SubID: &subID}) + require.NoError(t, err) + meta := sqlutil.JSON(b) + // initialize transactions + inTx_0 := cltest.NewEthTx(fromAddress) + inTx_0.Meta = &meta + // insert the transaction into the persistent store + require.NoError(t, persistentStore.InsertTx(ctx, &inTx_0)) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_0)) + + tcs := []struct { + name string + inMetaField string + inMetaValue string + inStates []txmgrtypes.TxState + inChainID *big.Int + + hasErr bool + hasTxs bool + }{ + {"successfully finds tx", "SubId", "123", []txmgrtypes.TxState{commontxmgr.TxUnstarted}, chainID, false, true}, + {"incorrect state: finds no txs", "SubId", "123", []txmgrtypes.TxState{commontxmgr.TxConfirmed}, chainID, false, false}, + {"incorrect meta_value: finds no txs", "SubId", "incorrect", []txmgrtypes.TxState{commontxmgr.TxUnstarted}, chainID, false, false}, + {"unknown meta_field: finds no txs", "unknown", "123", []txmgrtypes.TxState{commontxmgr.TxUnstarted}, chainID, false, false}, + {"incorrect meta_field: finds no txs", "JobID", "123", []txmgrtypes.TxState{commontxmgr.TxUnstarted}, chainID, false, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actTxs, actErr := inMemoryStore.FindTxesByMetaFieldAndStates(ctx, tc.inMetaField, tc.inMetaValue, tc.inStates, tc.inChainID) + expTxs, expErr := persistentStore.FindTxesByMetaFieldAndStates(ctx, tc.inMetaField, tc.inMetaValue, tc.inStates, tc.inChainID) + require.Equal(t, expErr, actErr) + if !tc.hasErr { + require.Nil(t, expErr) + require.Nil(t, actErr) + } + if tc.hasTxs { + require.NotEqual(t, 0, len(expTxs)) + assert.NotEqual(t, 0, len(actTxs)) + require.Equal(t, len(expTxs), len(actTxs)) + for i := 0; i < len(expTxs); i++ { + assertTxEqual(t, *expTxs[i], *actTxs[i]) + } + } else { + require.Equal(t, 0, len(expTxs)) + require.Equal(t, 0, len(actTxs)) + } + }) + } +} + +func TestInMemoryStore_FindTxWithIdempotencyKey(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + idempotencyKey := "777" + inTx := cltest.NewEthTx(fromAddress) + inTx.IdempotencyKey = &idempotencyKey + // insert the transaction into the persistent store + require.NoError(t, persistentStore.InsertTx(ctx, &inTx)) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + tcs := []struct { + name string + inIdempotencyKey string + inChainID *big.Int + + hasErr bool + hasTx bool + }{ + {"no idempotency key", "", chainID, false, false}, + {"wrong idempotency key", "wrong", chainID, false, false}, + {"finds tx with idempotency key", idempotencyKey, chainID, false, true}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actTx, actErr := inMemoryStore.FindTxWithIdempotencyKey(ctx, tc.inIdempotencyKey, tc.inChainID) + expTx, expErr := persistentStore.FindTxWithIdempotencyKey(ctx, tc.inIdempotencyKey, tc.inChainID) + require.Equal(t, expErr, actErr) + if !tc.hasErr { + require.Nil(t, actErr) + require.Nil(t, expErr) + } + if tc.hasTx { + require.NotNil(t, actTx) + require.NotNil(t, expTx) + assertTxEqual(t, *expTx, *actTx) + } else { + require.Nil(t, actTx) + require.Nil(t, expTx) + } + }) + } +} + +func TestInMemoryStore_CheckTxQueueCapacity(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // insert the transaction into the persistent store + // insert the transaction into the in-memory store + tx1 := cltest.NewEthTx(fromAddress) + require.NoError(t, persistentStore.InsertTx(ctx, &tx1)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &tx1)) + tx2 := cltest.NewEthTx(fromAddress) + require.NoError(t, persistentStore.InsertTx(ctx, &tx2)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &tx2)) + + tcs := []struct { + name string + inFromAddress common.Address + inMaxQueuedTxs uint64 + inChainID *big.Int + + hasErr bool + }{ + {"capacity reached", fromAddress, 2, chainID, true}, + {"above capacity", fromAddress, 1, chainID, true}, + {"below capacity", fromAddress, 3, chainID, false}, + {"wrong address", common.Address{}, 2, chainID, false}, + {"max queued txs is 0", fromAddress, 0, chainID, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actErr := inMemoryStore.CheckTxQueueCapacity(ctx, tc.inFromAddress, tc.inMaxQueuedTxs, tc.inChainID) + expErr := persistentStore.CheckTxQueueCapacity(ctx, tc.inFromAddress, tc.inMaxQueuedTxs, tc.inChainID) + if tc.hasErr { + require.NotNil(t, expErr) + require.NotNil(t, actErr) + } else { + require.NoError(t, expErr) + require.NoError(t, actErr) + } + }) + } +} + +func TestInMemoryStore_CountUnstartedTransactions(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // insert the transaction into the persistent store + // insert the transaction into the in-memory store + tx1 := cltest.NewEthTx(fromAddress) + require.NoError(t, persistentStore.InsertTx(ctx, &tx1)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &tx1)) + tx2 := cltest.NewEthTx(fromAddress) + require.NoError(t, persistentStore.InsertTx(ctx, &tx2)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &tx2)) + + tcs := []struct { + name string + inFromAddress common.Address + inChainID *big.Int + + expUnstartedCount uint32 + hasErr bool + }{ + {"return correct total transactions", fromAddress, chainID, 2, false}, + {"invalid address", common.Address{}, chainID, 0, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actMemoryCount, actErr := inMemoryStore.CountUnstartedTransactions(ctx, tc.inFromAddress, tc.inChainID) + actPersistentCount, expErr := persistentStore.CountUnstartedTransactions(ctx, tc.inFromAddress, tc.inChainID) + if tc.hasErr { + require.NotNil(t, expErr) + require.NotNil(t, actErr) + } else { + require.NoError(t, expErr) + require.NoError(t, actErr) + } + assert.Equal(t, tc.expUnstartedCount, actMemoryCount) + assert.Equal(t, tc.expUnstartedCount, actPersistentCount) + }) + } +} + +func TestInMemoryStore_CountUnconfirmedTransactions(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // initialize unconfirmed transactions + inNonces := []int64{1, 2, 3} + for _, inNonce := range inNonces { + // insert the transaction into the persistent store + inTx := cltest.MustInsertUnconfirmedEthTx(t, persistentStore, inNonce, fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + } + + tcs := []struct { + name string + inFromAddress common.Address + inChainID *big.Int + + expUnconfirmedCount uint32 + hasErr bool + }{ + {"return correct total transactions", fromAddress, chainID, 3, false}, + {"invalid address", common.Address{}, chainID, 0, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actMemoryCount, actErr := inMemoryStore.CountUnconfirmedTransactions(ctx, tc.inFromAddress, tc.inChainID) + actPersistentCount, expErr := persistentStore.CountUnconfirmedTransactions(ctx, tc.inFromAddress, tc.inChainID) + if tc.hasErr { + require.NotNil(t, expErr) + require.NotNil(t, actErr) + } else { + require.NoError(t, expErr) + require.NoError(t, actErr) + } + assert.Equal(t, tc.expUnconfirmedCount, actMemoryCount) + assert.Equal(t, tc.expUnconfirmedCount, actPersistentCount) + }) + } +} + +func TestInMemoryStore_FindTxAttemptsConfirmedMissingReceipt(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // initialize transactions + inTxDatas := []struct { + nonce int64 + broadcastBeforeBlockNum int64 + broadcastAt time.Time + }{ + {0, 1, time.Unix(1616509300, 0)}, + {1, 1, time.Unix(1616509400, 0)}, + {2, 1, time.Unix(1616509500, 0)}, + } + for _, inTxData := range inTxDatas { + // insert the transaction into the persistent store + inTx := mustInsertConfirmedMissingReceiptEthTxWithLegacyAttempt( + t, persistentStore, inTxData.nonce, inTxData.broadcastBeforeBlockNum, + inTxData.broadcastAt, fromAddress, + ) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + } + + tcs := []struct { + name string + inChainID *big.Int + + expTxAttemptsCount int + hasError bool + }{ + {"finds tx attempts confirmed missing receipt", chainID, 3, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actTxAttempts, actErr := inMemoryStore.FindTxAttemptsConfirmedMissingReceipt(ctx, tc.inChainID) + expTxAttempts, expErr := persistentStore.FindTxAttemptsConfirmedMissingReceipt(ctx, tc.inChainID) + if tc.hasError { + require.NotNil(t, actErr) + require.NotNil(t, expErr) + } else { + require.NoError(t, actErr) + require.NoError(t, expErr) + require.Equal(t, tc.expTxAttemptsCount, len(expTxAttempts)) + require.Equal(t, tc.expTxAttemptsCount, len(actTxAttempts)) + for i := 0; i < len(expTxAttempts); i++ { + assertTxAttemptEqual(t, expTxAttempts[i], actTxAttempts[i]) + } + } + }) + } +} + +func TestInMemoryStore_FindTxAttemptsRequiringReceiptFetch(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + // initialize transactions + inTxDatas := []struct { + nonce int64 + broadcastBeforeBlockNum int64 + broadcastAt time.Time + }{ + {0, 1, time.Unix(1616509300, 0)}, + {1, 1, time.Unix(1616509400, 0)}, + {2, 1, time.Unix(1616509500, 0)}, + } + for _, inTxData := range inTxDatas { + // insert the transaction into the persistent store + inTx := mustInsertConfirmedMissingReceiptEthTxWithLegacyAttempt( + t, persistentStore, inTxData.nonce, inTxData.broadcastBeforeBlockNum, + inTxData.broadcastAt, fromAddress, + ) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + } + + tcs := []struct { + name string + inChainID *big.Int + + expTxAttemptsCount int + hasError bool + }{ + {"finds tx attempts requiring receipt fetch", chainID, 3, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + expTxAttempts, expErr := persistentStore.FindTxAttemptsRequiringReceiptFetch(ctx, tc.inChainID) + actTxAttempts, actErr := inMemoryStore.FindTxAttemptsRequiringReceiptFetch(ctx, tc.inChainID) + if tc.hasError { + require.NotNil(t, actErr) + require.NotNil(t, expErr) + } else { + require.NoError(t, actErr) + require.NoError(t, expErr) + require.Equal(t, tc.expTxAttemptsCount, len(expTxAttempts)) + require.Equal(t, tc.expTxAttemptsCount, len(actTxAttempts)) + for i := 0; i < len(expTxAttempts); i++ { + assertTxAttemptEqual(t, expTxAttempts[i], actTxAttempts[i]) + } + } + }) + } +} + +func TestInMemoryStore_GetInProgressTxAttempts(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("gets 0 in progress transaction", func(t *testing.T) { + expTxAttempts, expErr := persistentStore.GetInProgressTxAttempts(ctx, fromAddress, chainID) + actTxAttempts, actErr := inMemoryStore.GetInProgressTxAttempts(ctx, fromAddress, chainID) + require.NoError(t, actErr) + require.NoError(t, expErr) + assert.Equal(t, len(expTxAttempts), len(actTxAttempts)) + }) + + t.Run("gets 1 in progress transaction", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := mustInsertUnconfirmedEthTxWithAttemptState(t, persistentStore, int64(7), fromAddress, txmgrtypes.TxAttemptInProgress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + expTxAttempts, expErr := persistentStore.GetInProgressTxAttempts(ctx, fromAddress, chainID) + actTxAttempts, actErr := inMemoryStore.GetInProgressTxAttempts(ctx, fromAddress, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.Equal(t, len(expTxAttempts), len(actTxAttempts)) + for i := 0; i < len(expTxAttempts); i++ { + assertTxAttemptEqual(t, expTxAttempts[i], actTxAttempts[i]) + } + }) +} + +func TestInMemoryStore_HasInProgressTransaction(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no in progress transaction", func(t *testing.T) { + expExists, expErr := persistentStore.HasInProgressTransaction(ctx, fromAddress, chainID) + actExists, actErr := inMemoryStore.HasInProgressTransaction(ctx, fromAddress, chainID) + require.NoError(t, actErr) + require.NoError(t, expErr) + assert.Equal(t, expExists, actExists) + }) + + t.Run("has an in progress transaction", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := mustInsertInProgressEthTxWithAttempt(t, persistentStore, 7, fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + expExists, expErr := persistentStore.HasInProgressTransaction(ctx, fromAddress, chainID) + actExists, actErr := inMemoryStore.HasInProgressTransaction(ctx, fromAddress, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.Equal(t, expExists, actExists) + }) +} + +func TestInMemoryStore_GetTxByID(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no transaction", func(t *testing.T) { + expTx, expErr := persistentStore.GetTxByID(ctx, 0) + actTx, actErr := inMemoryStore.GetTxByID(ctx, 0) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Nil(t, expTx) + assert.Nil(t, actTx) + }) + + t.Run("successfully get transaction by ID", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := mustInsertInProgressEthTxWithAttempt(t, persistentStore, 7, fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + expTx, expErr := persistentStore.GetTxByID(ctx, inTx.ID) + actTx, actErr := inMemoryStore.GetTxByID(ctx, inTx.ID) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.NotNil(t, expTx) + require.NotNil(t, actTx) + assertTxEqual(t, *expTx, *actTx) + }) +} + +func TestInMemoryStore_FindTxWithSequence(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no results", func(t *testing.T) { + expTx, expErr := persistentStore.FindTxWithSequence(ctx, fromAddress, evmtypes.Nonce(666)) + actTx, actErr := inMemoryStore.FindTxWithSequence(ctx, fromAddress, evmtypes.Nonce(666)) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Nil(t, expTx) + assert.Nil(t, actTx) + }) + + t.Run("successfully get transaction by ID", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 666, 1, fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + expTx, expErr := persistentStore.FindTxWithSequence(ctx, fromAddress, evmtypes.Nonce(666)) + actTx, actErr := inMemoryStore.FindTxWithSequence(ctx, fromAddress, evmtypes.Nonce(666)) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.NotNil(t, expTx) + require.NotNil(t, actTx) + assertTxEqual(t, *expTx, *actTx) + }) + + t.Run("incorrect from address", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 777, 7, fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + wrongFromAddress := common.Address{} + expTx, expErr := persistentStore.FindTxWithSequence(ctx, wrongFromAddress, evmtypes.Nonce(777)) + actTx, actErr := inMemoryStore.FindTxWithSequence(ctx, wrongFromAddress, evmtypes.Nonce(777)) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.Nil(t, expTx) + require.Nil(t, actTx) + }) +} + +func TestInMemoryStore_CountTransactionsByState(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no results", func(t *testing.T) { + expCount, expErr := persistentStore.CountTransactionsByState(ctx, commontxmgr.TxUnconfirmed, chainID) + actCount, actErr := inMemoryStore.CountTransactionsByState(ctx, commontxmgr.TxUnconfirmed, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Equal(t, expCount, actCount) + }) + t.Run("3 unconfirmed transactions", func(t *testing.T) { + for i := int64(0); i < 3; i++ { + // insert the transaction into the persistent store + inTx := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, i, fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + } + + expCount, expErr := persistentStore.CountTransactionsByState(ctx, commontxmgr.TxUnconfirmed, chainID) + actCount, actErr := inMemoryStore.CountTransactionsByState(ctx, commontxmgr.TxUnconfirmed, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Equal(t, expCount, actCount) + }) +} + +func TestInMemoryStore_FindTxsRequiringResubmissionDueToInsufficientEth(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + _, otherAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no results", func(t *testing.T) { + expTxs, expErr := persistentStore.FindTxsRequiringResubmissionDueToInsufficientFunds(ctx, fromAddress, chainID) + actTxs, actErr := inMemoryStore.FindTxsRequiringResubmissionDueToInsufficientFunds(ctx, fromAddress, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Equal(t, len(expTxs), len(actTxs)) + }) + + // Insert order is mixed up to test sorting + // insert the transaction into the persistent store + inTx_2 := mustInsertUnconfirmedEthTxWithInsufficientEthAttempt(t, persistentStore, 1, fromAddress) + inTx_3 := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, 2, fromAddress) + attempt3_2 := cltest.NewLegacyEthTxAttempt(t, inTx_3.ID) + attempt3_2.State = txmgrtypes.TxAttemptInsufficientFunds + attempt3_2.TxFee.Legacy = evmassets.NewWeiI(100) + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &attempt3_2)) + inTx_1 := mustInsertUnconfirmedEthTxWithInsufficientEthAttempt(t, persistentStore, 0, fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_2)) + inTx_3.TxAttempts = append([]evmtxmgr.TxAttempt{attempt3_2}, inTx_3.TxAttempts...) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_3)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_1)) + + // These should never be returned + // insert the transaction into the persistent store + otx_1 := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, 3, fromAddress) + otx_2 := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 4, 100, fromAddress) + otx_3 := mustInsertUnconfirmedEthTxWithInsufficientEthAttempt(t, persistentStore, 0, otherAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &otx_1)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &otx_2)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(otherAddress, &otx_3)) + + t.Run("return all eth_txes with at least one attempt that is in insufficient_eth state", func(t *testing.T) { + expTxs, expErr := persistentStore.FindTxsRequiringResubmissionDueToInsufficientFunds(ctx, fromAddress, chainID) + actTxs, actErr := inMemoryStore.FindTxsRequiringResubmissionDueToInsufficientFunds(ctx, fromAddress, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + + assert.Equal(t, len(expTxs), len(actTxs)) + for i := 0; i < len(expTxs); i++ { + assertTxEqual(t, *expTxs[i], *actTxs[i]) + } + }) + + t.Run("does not return txes with different fromAddress", func(t *testing.T) { + anotherFromAddress := common.Address{} + expTxs, expErr := persistentStore.FindTxsRequiringResubmissionDueToInsufficientFunds(ctx, anotherFromAddress, chainID) + actTxs, actErr := inMemoryStore.FindTxsRequiringResubmissionDueToInsufficientFunds(ctx, anotherFromAddress, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Equal(t, len(expTxs), len(actTxs)) + }) +} + +func TestInMemoryStore_GetNonFatalTransactions(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no results", func(t *testing.T) { + expTxs, expErr := persistentStore.GetNonFatalTransactions(ctx, chainID) + actTxs, actErr := inMemoryStore.GetNonFatalTransactions(ctx, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Equal(t, len(expTxs), len(actTxs)) + }) + + t.Run("get in progress, unstarted, and unconfirmed transactions", func(t *testing.T) { + // insert the transaction into the persistent store + inTx_0 := mustInsertInProgressEthTxWithAttempt(t, persistentStore, 123, fromAddress) + inTx_1 := mustCreateUnstartedGeneratedTx(t, persistentStore, fromAddress, chainID) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_0)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_1)) + + expTxs, expErr := persistentStore.GetNonFatalTransactions(ctx, chainID) + actTxs, actErr := inMemoryStore.GetNonFatalTransactions(ctx, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.Equal(t, len(expTxs), len(actTxs)) + + for i := 0; i < len(expTxs); i++ { + assertTxEqual(t, *expTxs[i], *actTxs[i]) + } + }) +} + +func TestInMemoryStore_FindTransactionsConfirmedInBlockRange(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no results", func(t *testing.T) { + expTxs, expErr := persistentStore.FindTransactionsConfirmedInBlockRange(ctx, 10, 8, chainID) + actTxs, actErr := inMemoryStore.FindTransactionsConfirmedInBlockRange(ctx, 10, 8, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Equal(t, len(expTxs), len(actTxs)) + }) + + t.Run("find all transactions confirmed in range", func(t *testing.T) { + // insert the transaction into the persistent store + inTx_0 := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 700, 8, fromAddress) + rec_0 := mustInsertEthReceipt(t, persistentStore, 8, evmutils.NewHash(), inTx_0.TxAttempts[0].Hash) + inTx_1 := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 777, 9, fromAddress) + rec_1 := mustInsertEthReceipt(t, persistentStore, 9, evmutils.NewHash(), inTx_1.TxAttempts[0].Hash) + // insert the transaction into the in-memory store + inTx_0.TxAttempts[0].Receipts = append(inTx_0.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&rec_0)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_0)) + inTx_1.TxAttempts[0].Receipts = append(inTx_1.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&rec_1)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_1)) + + expTxs, expErr := persistentStore.FindTransactionsConfirmedInBlockRange(ctx, 10, 8, chainID) + actTxs, actErr := inMemoryStore.FindTransactionsConfirmedInBlockRange(ctx, 10, 8, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.Equal(t, len(expTxs), len(actTxs)) + for i := 0; i < len(expTxs); i++ { + assertTxEqual(t, *expTxs[i], *actTxs[i]) + } + }) +} + +func TestInMemoryStore_FindEarliestUnconfirmedBroadcastTime(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no results", func(t *testing.T) { + expBroadcastAt, expErr := persistentStore.FindEarliestUnconfirmedBroadcastTime(ctx, chainID) + actBroadcastAt, actErr := inMemoryStore.FindEarliestUnconfirmedBroadcastTime(ctx, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.False(t, expBroadcastAt.Valid) + assert.False(t, actBroadcastAt.Valid) + }) + t.Run("find broadcast at time", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := cltest.MustInsertUnconfirmedEthTx(t, persistentStore, 123, fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + expBroadcastAt, expErr := persistentStore.FindEarliestUnconfirmedBroadcastTime(ctx, chainID) + actBroadcastAt, actErr := inMemoryStore.FindEarliestUnconfirmedBroadcastTime(ctx, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.True(t, expBroadcastAt.Valid) + require.True(t, actBroadcastAt.Valid) + assert.Equal(t, expBroadcastAt.Time.Unix(), actBroadcastAt.Time.Unix()) + }) +} + +func TestInMemoryStore_FindEarliestUnconfirmedTxAttemptBlock(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("no results", func(t *testing.T) { + expBlock, expErr := persistentStore.FindEarliestUnconfirmedTxAttemptBlock(ctx, chainID) + actBlock, actErr := inMemoryStore.FindEarliestUnconfirmedTxAttemptBlock(ctx, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.False(t, expBlock.Valid) + assert.False(t, actBlock.Valid) + }) + + t.Run("find earliest unconfirmed tx block", func(t *testing.T) { + broadcastBeforeBlockNum := int64(2) + // insert the transaction into the persistent store + inTx := cltest.MustInsertUnconfirmedEthTx(t, persistentStore, 123, fromAddress) + attempt := cltest.NewLegacyEthTxAttempt(t, inTx.ID) + attempt.BroadcastBeforeBlockNum = &broadcastBeforeBlockNum + attempt.State = txmgrtypes.TxAttemptBroadcast + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &attempt)) + inTx.TxAttempts = append(inTx.TxAttempts, attempt) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + expBlock, expErr := persistentStore.FindEarliestUnconfirmedTxAttemptBlock(ctx, chainID) + actBlock, actErr := inMemoryStore.FindEarliestUnconfirmedTxAttemptBlock(ctx, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.True(t, expBlock.Valid) + assert.True(t, actBlock.Valid) + assert.Equal(t, expBlock.Int64, actBlock.Int64) + }) +} + +func TestInMemoryStore_LoadTxAttempts(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("load tx attempt", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := mustInsertConfirmedMissingReceiptEthTxWithLegacyAttempt(t, persistentStore, 1, 7, time.Now(), fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + expTx := evmtxmgr.Tx{ID: inTx.ID, TxAttempts: []evmtxmgr.TxAttempt{}, FromAddress: fromAddress} // empty tx attempts for test + expErr := persistentStore.LoadTxAttempts(ctx, &expTx) + require.Equal(t, 1, len(expTx.TxAttempts)) + expAttempt := expTx.TxAttempts[0] + + actTx := evmtxmgr.Tx{ID: inTx.ID, TxAttempts: []evmtxmgr.TxAttempt{}, FromAddress: fromAddress} // empty tx attempts for test + actErr := inMemoryStore.LoadTxAttempts(ctx, &actTx) + require.Equal(t, 1, len(actTx.TxAttempts)) + actAttempt := actTx.TxAttempts[0] + + require.NoError(t, expErr) + require.NoError(t, actErr) + assertTxAttemptEqual(t, expAttempt, actAttempt) + }) +} + +func TestInMemoryStore_PreloadTxes(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("load transaction", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, int64(7), fromAddress) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + expAttempts := []evmtxmgr.TxAttempt{{ID: 0, TxID: inTx.ID}} + expErr := persistentStore.PreloadTxes(ctx, expAttempts) + require.Equal(t, 1, len(expAttempts)) + expAttempt := expAttempts[0] + + actAttempts := []evmtxmgr.TxAttempt{{ID: 0, TxID: inTx.ID}} + actErr := inMemoryStore.PreloadTxes(ctx, actAttempts) + require.Equal(t, 1, len(actAttempts)) + actAttempt := actAttempts[0] + + require.NoError(t, expErr) + require.NoError(t, actErr) + assertTxAttemptEqual(t, expAttempt, actAttempt) + }) +} + +func TestInMemoryStore_IsTxFinalized(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("tx not past finality depth", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 111, 1, fromAddress) + rec := mustInsertEthReceipt(t, persistentStore, 1, evmutils.NewHash(), inTx.TxAttempts[0].Hash) + // insert the transaction into the in-memory store + inTx.TxAttempts[0].Receipts = append(inTx.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&rec)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + blockHeight := int64(2) + expIsFinalized, expErr := persistentStore.IsTxFinalized(ctx, blockHeight, inTx.ID, chainID) + actIsFinalized, actErr := inMemoryStore.IsTxFinalized(ctx, blockHeight, inTx.ID, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Equal(t, expIsFinalized, actIsFinalized) + }) + + t.Run("tx is past finality depth", func(t *testing.T) { + // insert the transaction into the persistent store + inTx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, persistentStore, 122, 2, fromAddress) + rec := mustInsertEthReceipt(t, persistentStore, 2, evmutils.NewHash(), inTx.TxAttempts[0].Hash) + // insert the transaction into the in-memory store + inTx.TxAttempts[0].Receipts = append(inTx.TxAttempts[0].Receipts, evmtxmgr.DbReceiptToEvmReceipt(&rec)) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + blockHeight := int64(10) + expIsFinalized, expErr := persistentStore.IsTxFinalized(ctx, blockHeight, inTx.ID, chainID) + actIsFinalized, actErr := inMemoryStore.IsTxFinalized(ctx, blockHeight, inTx.ID, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + assert.Equal(t, expIsFinalized, actIsFinalized) + }) +} + +func TestInMemoryStore_FindTxsRequiringGasBump(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("gets transactions requiring gas bumping", func(t *testing.T) { + currentBlockNum := int64(10) + + // insert the transaction into the persistent store + inTx_0 := mustInsertUnconfirmedEthTxWithAttemptState(t, persistentStore, 1, fromAddress, txmgrtypes.TxAttemptBroadcast) + require.NoError(t, persistentStore.SetBroadcastBeforeBlockNum(ctx, currentBlockNum, chainID)) + inTx_1 := mustInsertUnconfirmedEthTxWithAttemptState(t, persistentStore, 2, fromAddress, txmgrtypes.TxAttemptBroadcast) + require.NoError(t, persistentStore.SetBroadcastBeforeBlockNum(ctx, currentBlockNum+1, chainID)) + // insert the transaction into the in-memory store + inTx_0.TxAttempts[0].BroadcastBeforeBlockNum = ¤tBlockNum + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_0)) + tempCurrentBlockNum := currentBlockNum + 1 + inTx_1.TxAttempts[0].BroadcastBeforeBlockNum = &tempCurrentBlockNum + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx_1)) + + newBlock := int64(12) + gasBumpThreshold := int64(2) + expTxs, expErr := persistentStore.FindTxsRequiringGasBump(ctx, fromAddress, newBlock, gasBumpThreshold, 0, chainID) + actTxs, actErr := inMemoryStore.FindTxsRequiringGasBump(ctx, fromAddress, newBlock, gasBumpThreshold, 0, chainID) + require.NoError(t, expErr) + require.NoError(t, actErr) + require.Equal(t, len(expTxs), len(actTxs)) + for i := 0; i < len(expTxs); i++ { + assertTxEqual(t, *expTxs[i], *actTxs[i]) + } + }) +} + +func TestInMemoryStore_SaveInProgressAttempt(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("saves new in_progress attempt if attempt is new", func(t *testing.T) { + // Insert a transaction into persistent store + inTx := cltest.MustInsertUnconfirmedEthTx(t, persistentStore, 1, fromAddress) + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + // generate new attempt + inTxAttempt := cltest.NewLegacyEthTxAttempt(t, inTx.ID) + require.Equal(t, int64(0), inTxAttempt.ID) + + err := inMemoryStore.SaveInProgressAttempt(ctx, &inTxAttempt) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + + // Check that the in-memory store has the new attempt + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.NotNil(t, actTxs) + actTx := actTxs[0] + require.Equal(t, len(expTx.TxAttempts), len(actTx.TxAttempts)) + + assertTxEqual(t, expTx, actTx) + }) + t.Run("updates old attempt to in_progress when insufficient_funds", func(t *testing.T) { + // Insert a transaction into persistent store + inTx := mustInsertUnconfirmedEthTxWithInsufficientEthAttempt(t, persistentStore, 23, fromAddress) + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + // use old attempt + inTxAttempt := inTx.TxAttempts[0] + require.Equal(t, txmgrtypes.TxAttemptInsufficientFunds, inTxAttempt.State) + require.NotEqual(t, int64(0), inTxAttempt.ID) + + inTxAttempt.BroadcastBeforeBlockNum = nil + inTxAttempt.State = txmgrtypes.TxAttemptInProgress + err := inMemoryStore.SaveInProgressAttempt(ctx, &inTxAttempt) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + + // Check that the in-memory store has the new attempt + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.NotNil(t, actTxs) + actTx := actTxs[0] + require.Equal(t, len(expTx.TxAttempts), len(actTx.TxAttempts)) + + assertTxEqual(t, expTx, actTx) + }) + t.Run("handles errors the same way as the persistent store", func(t *testing.T) { + // Insert a transaction into persistent store + inTx := cltest.MustInsertUnconfirmedEthTx(t, persistentStore, 55, fromAddress) + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + // generate new attempt + inTxAttempt := cltest.NewLegacyEthTxAttempt(t, inTx.ID) + require.Equal(t, int64(0), inTxAttempt.ID) + + t.Run("wrong tx id", func(t *testing.T) { + inTxAttempt.TxID = 999 + actErr := inMemoryStore.SaveInProgressAttempt(ctx, &inTxAttempt) + expErr := persistentStore.SaveInProgressAttempt(ctx, &inTxAttempt) + assert.Error(t, actErr) + assert.Error(t, expErr) + inTxAttempt.TxID = inTx.ID // reset + }) + + t.Run("wrong state", func(t *testing.T) { + inTxAttempt.State = txmgrtypes.TxAttemptBroadcast + actErr := inMemoryStore.SaveInProgressAttempt(ctx, &inTxAttempt) + expErr := persistentStore.SaveInProgressAttempt(ctx, &inTxAttempt) + assert.Error(t, actErr) + assert.Error(t, expErr) + assert.Equal(t, expErr, actErr) + inTxAttempt.State = txmgrtypes.TxAttemptInProgress // reset + }) + }) +} + +func TestInMemoryStore_UpdateBroadcastAts(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("does not update when broadcast_at is Null", func(t *testing.T) { + // Insert a transaction into persistent store + inTx := mustInsertInProgressEthTxWithAttempt(t, persistentStore, 1, fromAddress) + require.Nil(t, inTx.BroadcastAt) + now := time.Now() + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + err := inMemoryStore.UpdateBroadcastAts( + ctx, + now, + []int64{inTx.ID}, + ) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.Equal(t, 1, len(actTxs)) + actTx := actTxs[0] + assertTxEqual(t, expTx, actTx) + assert.Nil(t, actTx.BroadcastAt) + }) + + t.Run("updates broadcast_at when not null", func(t *testing.T) { + // Insert a transaction into persistent store + time1 := time.Now() + inTx := cltest.NewEthTx(fromAddress) + inTx.Sequence = new(evmtypes.Nonce) + inTx.State = commontxmgr.TxUnconfirmed + inTx.BroadcastAt = &time1 + inTx.InitialBroadcastAt = &time1 + require.NoError(t, persistentStore.InsertTx(ctx, &inTx)) + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + time2 := time1.Add(1 * time.Hour) + err := inMemoryStore.UpdateBroadcastAts( + ctx, + time2, + []int64{inTx.ID}, + ) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.Equal(t, 1, len(actTxs)) + actTx := actTxs[0] + assertTxEqual(t, expTx, actTx) + assert.NotNil(t, actTx.BroadcastAt) + }) +} + +func TestInMemoryStore_SetBroadcastBeforeBlockNum(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("saves block num to unconfirmed evm.tx_attempts without one", func(t *testing.T) { + // Insert a transaction into persistent store + inTx := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, 1, fromAddress) + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + headNum := int64(9000) + err := inMemoryStore.SetBroadcastBeforeBlockNum(ctx, headNum, chainID) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + require.Equal(t, 1, len(expTx.TxAttempts)) + assert.Equal(t, headNum, *expTx.TxAttempts[0].BroadcastBeforeBlockNum) + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.Equal(t, 1, len(actTxs)) + actTx := actTxs[0] + assertTxEqual(t, expTx, actTx) + }) + + t.Run("does not change evm.tx_attempts that already have BroadcastBeforeBlockNum set", func(t *testing.T) { + n := int64(42) + // Insert a transaction into persistent store + inTx := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, persistentStore, 11, fromAddress) + inTxAttempt := newBroadcastLegacyEthTxAttempt(t, inTx.ID, 2) + inTxAttempt.BroadcastBeforeBlockNum = &n + require.NoError(t, persistentStore.InsertTxAttempt(ctx, &inTxAttempt)) + // Insert the transaction into the in-memory store + inTx.TxAttempts = append([]evmtxmgr.TxAttempt{inTxAttempt}, inTx.TxAttempts...) + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + headNum := int64(9000) + err := inMemoryStore.SetBroadcastBeforeBlockNum(ctx, headNum, chainID) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + require.Equal(t, 2, len(expTx.TxAttempts)) + assert.Equal(t, n, *expTx.TxAttempts[0].BroadcastBeforeBlockNum) + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.Equal(t, 1, len(actTxs)) + actTx := actTxs[0] + assertTxEqual(t, expTx, actTx) + }) +} + +func TestInMemoryStore_UpdateTxCallbackCompleted(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("sets tx callback as completed", func(t *testing.T) { + // Insert a transaction into persistent store + inTx := cltest.NewEthTx(fromAddress) + inTx.PipelineTaskRunID = uuid.NullUUID{UUID: uuid.New(), Valid: true} + require.NoError(t, persistentStore.InsertTx(ctx, &inTx)) + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + err := inMemoryStore.UpdateTxCallbackCompleted( + testutils.Context(t), + inTx.PipelineTaskRunID.UUID, + chainID, + ) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.Equal(t, 1, len(actTxs)) + actTx := actTxs[0] + assertTxEqual(t, expTx, actTx) + assert.True(t, actTx.CallbackCompleted) + + // wrong PipelineTaskRunID + wrongPipelineTaskRunID := uuid.NullUUID{UUID: uuid.New(), Valid: true} + actErr := inMemoryStore.UpdateTxCallbackCompleted(ctx, wrongPipelineTaskRunID.UUID, chainID) + expErr := persistentStore.UpdateTxCallbackCompleted(ctx, wrongPipelineTaskRunID.UUID, chainID) + assert.NoError(t, actErr) + assert.NoError(t, expErr) + }) +} + +func TestInMemoryStore_SaveInsufficientFundsAttempt(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + defaultDuration := time.Second * 5 + t.Run("updates attempt state and checks error returns", func(t *testing.T) { + // Insert a transaction into persistent store + inTx := mustInsertInProgressEthTxWithAttempt(t, persistentStore, 1, fromAddress) + now := time.Now() + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + err := inMemoryStore.SaveInsufficientFundsAttempt( + ctx, + defaultDuration, + &inTx.TxAttempts[0], + now, + ) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.Equal(t, 1, len(actTxs)) + actTx := actTxs[0] + assertTxEqual(t, expTx, actTx) + assert.Equal(t, txmgrtypes.TxAttemptInsufficientFunds, actTx.TxAttempts[0].State) + + // wrong tx id + inTx.TxAttempts[0].TxID = 123 + actErr := inMemoryStore.SaveInsufficientFundsAttempt(ctx, defaultDuration, &inTx.TxAttempts[0], now) + expErr := persistentStore.SaveInsufficientFundsAttempt(ctx, defaultDuration, &inTx.TxAttempts[0], now) + assert.NoError(t, actErr) + assert.NoError(t, expErr) + inTx.TxAttempts[0].TxID = inTx.ID // reset + + // wrong attempt state + inTx.TxAttempts[0].State = txmgrtypes.TxAttemptBroadcast + actErr = inMemoryStore.SaveInsufficientFundsAttempt(ctx, defaultDuration, &inTx.TxAttempts[0], now) + expErr = persistentStore.SaveInsufficientFundsAttempt(ctx, defaultDuration, &inTx.TxAttempts[0], now) + assert.Error(t, actErr) + assert.Error(t, expErr) + inTx.TxAttempts[0].State = txmgrtypes.TxAttemptInsufficientFunds // reset + }) +} + +func TestInMemoryStore_SaveSentAttempt(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + defaultDuration := time.Second * 5 + t.Run("updates attempt state to broadcast and checks error returns", func(t *testing.T) { + // Insert a transaction into persistent store + inTx := mustInsertInProgressEthTxWithAttempt(t, persistentStore, 1, fromAddress) + require.Nil(t, inTx.BroadcastAt) + now := time.Now() + // Insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + + err := inMemoryStore.SaveSentAttempt( + ctx, + defaultDuration, + &inTx.TxAttempts[0], + now, + ) + require.NoError(t, err) + + expTx, err := persistentStore.FindTxWithAttempts(ctx, inTx.ID) + require.NoError(t, err) + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn, inTx.ID) + require.Equal(t, 1, len(actTxs)) + actTx := actTxs[0] + assertTxEqual(t, expTx, actTx) + assert.Equal(t, txmgrtypes.TxAttemptBroadcast, actTx.TxAttempts[0].State) + + // wrong tx id + inTx.TxAttempts[0].TxID = 123 + actErr := inMemoryStore.SaveSentAttempt(ctx, defaultDuration, &inTx.TxAttempts[0], now) + expErr := persistentStore.SaveSentAttempt(ctx, defaultDuration, &inTx.TxAttempts[0], now) + assert.Error(t, actErr) + assert.Error(t, expErr) + inTx.TxAttempts[0].TxID = inTx.ID // reset + + // wrong attempt state + inTx.TxAttempts[0].State = txmgrtypes.TxAttemptBroadcast + actErr = inMemoryStore.SaveSentAttempt(ctx, defaultDuration, &inTx.TxAttempts[0], now) + expErr = persistentStore.SaveSentAttempt(ctx, defaultDuration, &inTx.TxAttempts[0], now) + assert.Error(t, actErr) + assert.Error(t, expErr) + inTx.TxAttempts[0].State = txmgrtypes.TxAttemptInProgress // reset + }) +} + +func TestInMemoryStore_Abandon(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + _, dbcfg, evmcfg := evmtxmgr.MakeTestConfigs(t) + persistentStore := cltest.NewTestTxStore(t, db) + kst := cltest.NewKeyStore(t, db, dbcfg) + _, fromAddress := cltest.MustInsertRandomKey(t, kst.Eth()) + + ethClient := evmtest.NewEthClientMockWithDefaultChain(t) + lggr := logger.TestSugared(t) + chainID := ethClient.ConfiguredChainID() + ctx := testutils.Context(t) + + inMemoryStore, err := commontxmgr.NewInMemoryStore(ctx, lggr, chainID, kst.Eth(), persistentStore, evmcfg.Transactions()) + require.NoError(t, err) + + t.Run("Abandon transactions successfully", func(t *testing.T) { + nTxs := 3 + for i := 0; i < nTxs; i++ { + inTx := cltest.NewEthTx(fromAddress) + // insert the transaction into the persistent store + require.NoError(t, persistentStore.InsertTx(ctx, &inTx)) + // insert the transaction into the in-memory store + require.NoError(t, inMemoryStore.XXXTestInsertTx(fromAddress, &inTx)) + } + + actErr := inMemoryStore.Abandon(ctx, chainID, fromAddress) + expErr := persistentStore.Abandon(ctx, chainID, fromAddress) + require.NoError(t, actErr) + require.NoError(t, expErr) + + expTxs, err := persistentStore.FindTxesByFromAddressAndState(ctx, fromAddress, "fatal_error") + require.NoError(t, err) + require.NotNil(t, expTxs) + require.Equal(t, nTxs, len(expTxs)) + + // Check the in-memory store + fn := func(tx *evmtxmgr.Tx) bool { return true } + actTxs := inMemoryStore.XXXTestFindTxs(nil, fn) + require.NotNil(t, actTxs) + require.Equal(t, nTxs, len(actTxs)) + + for i := 0; i < nTxs; i++ { + assertTxEqual(t, *expTxs[i], actTxs[i]) + } + }) +} + // assertTxEqual asserts that two transactions are equal func assertTxEqual(t *testing.T, exp, act evmtxmgr.Tx) { assert.Equal(t, exp.ID, act.ID) @@ -20,7 +1982,10 @@ func assertTxEqual(t *testing.T, exp, act evmtxmgr.Tx) { assert.Equal(t, exp.Value, act.Value) assert.Equal(t, exp.FeeLimit, act.FeeLimit) assert.Equal(t, exp.Error, act.Error) - assert.Equal(t, exp.BroadcastAt, act.BroadcastAt) + if exp.BroadcastAt != nil { + require.NotNil(t, act.BroadcastAt) + assert.Equal(t, exp.BroadcastAt.Unix(), act.BroadcastAt.Unix()) + } assert.Equal(t, exp.InitialBroadcastAt, act.InitialBroadcastAt) assert.Equal(t, exp.CreatedAt, act.CreatedAt) assert.Equal(t, exp.State, act.State) @@ -33,7 +1998,10 @@ func assertTxEqual(t *testing.T, exp, act evmtxmgr.Tx) { assert.Equal(t, exp.SignalCallback, act.SignalCallback) assert.Equal(t, exp.CallbackCompleted, act.CallbackCompleted) - require.Len(t, exp.TxAttempts, len(act.TxAttempts)) + if len(exp.TxAttempts) == 0 { + return + } + require.Equal(t, len(exp.TxAttempts), len(act.TxAttempts)) for i := 0; i < len(exp.TxAttempts); i++ { assertTxAttemptEqual(t, exp.TxAttempts[i], act.TxAttempts[i]) } @@ -42,7 +2010,6 @@ func assertTxEqual(t *testing.T, exp, act evmtxmgr.Tx) { func assertTxAttemptEqual(t *testing.T, exp, act evmtxmgr.TxAttempt) { assert.Equal(t, exp.ID, act.ID) assert.Equal(t, exp.TxID, act.TxID) - assert.Equal(t, exp.Tx, act.Tx) assert.Equal(t, exp.TxFee, act.TxFee) assert.Equal(t, exp.ChainSpecificFeeLimit, act.ChainSpecificFeeLimit) assert.Equal(t, exp.SignedRawTx, act.SignedRawTx) @@ -52,6 +2019,9 @@ func assertTxAttemptEqual(t *testing.T, exp, act evmtxmgr.TxAttempt) { assert.Equal(t, exp.State, act.State) assert.Equal(t, exp.TxType, act.TxType) + if len(exp.Receipts) == 0 { + return + } require.Equal(t, len(exp.Receipts), len(act.Receipts)) for i := 0; i < len(exp.Receipts); i++ { assertChainReceiptEqual(t, exp.Receipts[i], act.Receipts[i]) diff --git a/core/chains/evm/txmgr/mocks/evm_tx_store.go b/core/chains/evm/txmgr/mocks/evm_tx_store.go index 61c948c1ff4..e1505a045dc 100644 --- a/core/chains/evm/txmgr/mocks/evm_tx_store.go +++ b/core/chains/evm/txmgr/mocks/evm_tx_store.go @@ -821,6 +821,36 @@ func (_m *EvmTxStore) FindTxsRequiringResubmissionDueToInsufficientFunds(ctx con return r0, r1 } +// GetAllTransactions provides a mock function with given fields: ctx, chainID +func (_m *EvmTxStore) GetAllTransactions(ctx context.Context, chainID *big.Int) ([]types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], error) { + ret := _m.Called(ctx, chainID) + + if len(ret) == 0 { + panic("no return value specified for GetAllTransactions") + } + + var r0 []types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee] + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *big.Int) ([]types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], error)); ok { + return rf(ctx, chainID) + } + if rf, ok := ret.Get(0).(func(context.Context, *big.Int) []types.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee]); ok { + r0 = rf(ctx, chainID) + } 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(context.Context, *big.Int) error); ok { + r1 = rf(ctx, chainID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetInProgressTxAttempts provides a mock function with given fields: ctx, address, chainID func (_m *EvmTxStore) GetInProgressTxAttempts(ctx context.Context, address common.Address, chainID *big.Int) ([]types.TxAttempt[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], error) { ret := _m.Called(ctx, address, chainID)