diff --git a/.changeset/cool-owls-laugh.md b/.changeset/cool-owls-laugh.md new file mode 100644 index 00000000000..a3667ed20e0 --- /dev/null +++ b/.changeset/cool-owls-laugh.md @@ -0,0 +1,5 @@ +--- +"chainlink": minor +--- + +Bump to start the next version diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 210709443be..eed677add18 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -39,6 +39,10 @@ /core/services/ccip @smartcontractkit/ccip /core/services/ocr2/plugins/ccip @smartcontractkit/ccip +# CCIP +/core/services/ccip @smartcontractkit/ccip +/core/services/ocr2/plugins/ccip @smartcontractkit/ccip + # VRF-related services /core/services/vrf @smartcontractkit/dev-services @smartcontractkit/core /core/services/blockhashstore @smartcontractkit/dev-services @smartcontractkit/core diff --git a/.mockery.yaml b/.mockery.yaml index 711d70f59e9..c4ee5dd5a33 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -93,6 +93,12 @@ packages: BalanceMonitor: config: dir: "{{ .InterfaceDir }}/../mocks" + github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm: + interfaces: + Client: + TxStore: + AttemptBuilder: + Keystore: github.com/smartcontractkit/chainlink/v2/core/chains/evm/txmgr: interfaces: ChainConfig: diff --git a/CHANGELOG.md b/CHANGELOG.md index db1ba5e3340..f4326bd705d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,120 +1,5 @@ # Changelog Chainlink Core -## 2.19.0 - UNRELEASED - -### Minor Changes - -- [#15126](https://github.com/smartcontractkit/chainlink/pull/15126) [`bf9b08c331`](https://github.com/smartcontractkit/chainlink/commit/bf9b08c33119d14a34c4914a3a7cc94ea6cb5481) - update plugin and evm chainwriter to remove evmConfig dependency #updated - -- [#15148](https://github.com/smartcontractkit/chainlink/pull/15148) [`07a2cec532`](https://github.com/smartcontractkit/chainlink/commit/07a2cec532704aebe413a9fa29edc04bd9063677) - #added address book remove feature - -- [#14898](https://github.com/smartcontractkit/chainlink/pull/14898) [`a417740359`](https://github.com/smartcontractkit/chainlink/commit/a41774035961ce88be2fed7c6759758a7f00881d) - Bump to start the next version - -- [#15010](https://github.com/smartcontractkit/chainlink/pull/15010) [`c162226424`](https://github.com/smartcontractkit/chainlink/commit/c1622264243c2ee83ff7ba54989cfe789a8da53c) - #added support enable and disable feed manager - -- [#15104](https://github.com/smartcontractkit/chainlink/pull/15104) [`cf7db16483`](https://github.com/smartcontractkit/chainlink/commit/cf7db164832ea95354a3c953126c2fd32a59afd7) - Updated Solana TXM's in-memory storage to track statuses across the Solana transaction lifecycle. Added a method to translate Solana transaction statuses into states expected by the ChainWriter interface. Made the duration transactions are retained in storage after finality or error configurable using `TxRetentionTimeout`. #added - -- [#14880](https://github.com/smartcontractkit/chainlink/pull/14880) [`af8132361b`](https://github.com/smartcontractkit/chainlink/commit/af8132361b3fbd6858a792c4814d144582e6467b) - Implement secrets interpolation with dummy workflow registry syncer #internal - -- [#14993](https://github.com/smartcontractkit/chainlink/pull/14993) [`883d993dc6`](https://github.com/smartcontractkit/chainlink/commit/883d993dc6f58b561787c0e287ed121a08ce550d) - #changed UI(chainconfig): make admin address optional - -- [#14937](https://github.com/smartcontractkit/chainlink/pull/14937) [`3ad089f664`](https://github.com/smartcontractkit/chainlink/commit/3ad089f6644ed54cb21421e84a01b9628c15e205) - #added Enable and Disable Feeds Manager mutations - -- [#14935](https://github.com/smartcontractkit/chainlink/pull/14935) [`c042b627f1`](https://github.com/smartcontractkit/chainlink/commit/c042b627f1fb3a8e6009314c4c2e4daa3c7659c7) - #internal Fix Contract Reader data word index calculation and change ccip contract reader config for more optimal querying. - -- [#15210](https://github.com/smartcontractkit/chainlink/pull/15210) [`7ff9cf9916`](https://github.com/smartcontractkit/chainlink/commit/7ff9cf9916ae1648ccc3475c86e45c9df6ab8d1f) - #removed Remove unused deprecated key interfaces. - -- [#14791](https://github.com/smartcontractkit/chainlink/pull/14791) [`090063799c`](https://github.com/smartcontractkit/chainlink/commit/090063799cd2fd62646c383bf2d2e42d25225c40) - #updated - update ethkeys, ethtransactions to handle duplicate chain id in different network - introduce network arg input to Chain graphql query to allow better lookup based on network and chain id - -- [#14405](https://github.com/smartcontractkit/chainlink/pull/14405) [`03115e8038`](https://github.com/smartcontractkit/chainlink/commit/03115e80382dc02a7bce66dc16d8bae5bd7de132) - #internal Updated the TXM confirmation logic to use the mined transaction count to identify re-org'd or confirmed transactions. - - - Confirmer uses the mined transaction count to determine if transactions have been re-org'd or confirmed. - - Confirmer no longer sets transaction states to `confirmed_missing_receipt`. This state is maintained in queries for backwards compatibility. - - Finalizer now responsible for fetching and storing receipts for confirmed transactions. - - Finalizer now responsible for resuming pending task runs. - - Finalizer now responsible for marking old transactions without receipts broadcasted before the finalized head as fatal. - -- [#14912](https://github.com/smartcontractkit/chainlink/pull/14912) [`bb6a20a882`](https://github.com/smartcontractkit/chainlink/commit/bb6a20a8825d9f374b99238097b4c9f701336a0c) - Use correct OCR2OracleSpecID when setting up RequestedRoundTracker #bugfix - -- [#15029](https://github.com/smartcontractkit/chainlink/pull/15029) [`99241ec680`](https://github.com/smartcontractkit/chainlink/commit/99241ec68098d818b97b046a1f9b4f5c8f0c5366) - Added multiple blocks history estimator feature and config for Solana TXM. #added - -- [#15197](https://github.com/smartcontractkit/chainlink/pull/15197) [`3b3b86ceae`](https://github.com/smartcontractkit/chainlink/commit/3b3b86ceaed419fa3890be17c8699a3416081fb9) - Add don_id to Mercury Enhanced EA telemetry #added - -- [#15266](https://github.com/smartcontractkit/chainlink/pull/15266) [`a9d6a3f05c`](https://github.com/smartcontractkit/chainlink/commit/a9d6a3f05c2ca8f8efbd909179f06788c356429e) - #removed unused key files and move public_key test file. - -- [#14781](https://github.com/smartcontractkit/chainlink/pull/14781) [`bfd9a3ffe1`](https://github.com/smartcontractkit/chainlink/commit/bfd9a3ffe11484f3dc413bb77a709f2c4bb281b0) - Support multiple chains evm clients for TXM gas estimator to fetch L1 gas oracle - #added - -- [#15244](https://github.com/smartcontractkit/chainlink/pull/15244) [`827e003a0c`](https://github.com/smartcontractkit/chainlink/commit/827e003a0c8c0212c6de0cb330e1f27a9c583dab) - #bugfix Update Log.Level and MaxSize configs description in the docs - -- [#15296](https://github.com/smartcontractkit/chainlink/pull/15296) [`de9868df2c`](https://github.com/smartcontractkit/chainlink/commit/de9868df2c8869bcaf641b06e77dcaa720506263) - #updated chain config: allow chain id and account address to be manually provided when no selections are available - -- [#15160](https://github.com/smartcontractkit/chainlink/pull/15160) [`816dcf814a`](https://github.com/smartcontractkit/chainlink/commit/816dcf814aff1ab5ca043eb47849162a61bc184b) - Add CSA authentication support to Beholder #added - -- [#15237](https://github.com/smartcontractkit/chainlink/pull/15237) [`1f9c07de61`](https://github.com/smartcontractkit/chainlink/commit/1f9c07de618ecfa3f23b8259ce0558941b5c357f) - #added feat: support creating solana chain config in job distributor page - -- [#15299](https://github.com/smartcontractkit/chainlink/pull/15299) [`a2c3da471c`](https://github.com/smartcontractkit/chainlink/commit/a2c3da471cc344a14003f53c2c2f09c937c204c1) - #updated Update few incorrect occurences of the password for notreal@fakeemail.ch. - -### Patch Changes - -- [#14896](https://github.com/smartcontractkit/chainlink/pull/14896) [`38ef1799dc`](https://github.com/smartcontractkit/chainlink/commit/38ef1799dc8dade6882acbafda3f49e942483f6f) - Ensure RPCClient with enabled polling respects health check flag #internal - -- [#14978](https://github.com/smartcontractkit/chainlink/pull/14978) [`65351c3a6f`](https://github.com/smartcontractkit/chainlink/commit/65351c3a6fffcff03d249e64cae223daabd86e3c) - Set `NodePool.EnforceRepeatableRead = true` by default for all chains. This forces Core to stop using RPCs behind on the latest finalized block. #changed #nops - -- [#14875](https://github.com/smartcontractkit/chainlink/pull/14875) [`101fbbc75a`](https://github.com/smartcontractkit/chainlink/commit/101fbbc75a76893d2ebe0c24fa61773876007d76) - #updated ignoring tx type 126 on celo - -- [#14929](https://github.com/smartcontractkit/chainlink/pull/14929) [`da5910eda9`](https://github.com/smartcontractkit/chainlink/commit/da5910eda98882f8a1b9ccc52b99afa223fb3997) - Add config validation so it requires ws url when http polling disabled #bugfix - -- [#14964](https://github.com/smartcontractkit/chainlink/pull/14964) [`7a80f364f9`](https://github.com/smartcontractkit/chainlink/commit/7a80f364f902c46a958021777336d6f6de9203c3) - #internal Add tokenDestGasOverhead to CalculateMessageMaxGas - -- [#15115](https://github.com/smartcontractkit/chainlink/pull/15115) [`f8a6218727`](https://github.com/smartcontractkit/chainlink/commit/f8a62187273bac1f12eeee0fe33355fe1b4faebb) - Fixed outdated headtracker config doc. #internal - -- [#14955](https://github.com/smartcontractkit/chainlink/pull/14955) [`1b8819e518`](https://github.com/smartcontractkit/chainlink/commit/1b8819e5189459a557a981ab115813253cdda919) - #updated bump CTF to v1.50.13, more robust ethereum client compatibility pipeline - -- [#14950](https://github.com/smartcontractkit/chainlink/pull/14950) [`0cd0867140`](https://github.com/smartcontractkit/chainlink/commit/0cd086714086b2a788c54de9aea13d58316518dd) - #internal OracleFactory error handling change - -- [#14893](https://github.com/smartcontractkit/chainlink/pull/14893) [`e116f744b2`](https://github.com/smartcontractkit/chainlink/commit/e116f744b222b7d664a8501566093ca1c1c63aa9) - Relocation of the deployment directory into a direct subdir of the root. #internal - -- [#15024](https://github.com/smartcontractkit/chainlink/pull/15024) [`6b29cdb7f3`](https://github.com/smartcontractkit/chainlink/commit/6b29cdb7f349b370581822ec6f54713faa7ca49e) - Minor fixes to the CCIPChainReader enabling proper serialization #internal - -- [#14827](https://github.com/smartcontractkit/chainlink/pull/14827) [`a9f86b51bf`](https://github.com/smartcontractkit/chainlink/commit/a9f86b51bf8d3a8548d4870c91f3e82669f5f21a) - #added Handle Hex String in EA Telemetry - -- [#15137](https://github.com/smartcontractkit/chainlink/pull/15137) [`7bd35dcea0`](https://github.com/smartcontractkit/chainlink/commit/7bd35dcea04b02ceb2e46a0431eca61ab3b78889) - #internal Update ccip contract reader cfg for ccip message sent to use output codec wrapper modifier - -- [#14710](https://github.com/smartcontractkit/chainlink/pull/14710) [`12174c79c4`](https://github.com/smartcontractkit/chainlink/commit/12174c79c4d1746149d7c89e41e67f9dc6525c18) - Adds new custom calldata DA oracle #added - -- [#14891](https://github.com/smartcontractkit/chainlink/pull/14891) [`12e2eb251c`](https://github.com/smartcontractkit/chainlink/commit/12e2eb251c406b601b2f954ed25ccad369421403) - Fix flaky WS test server #internal - -- [#11809](https://github.com/smartcontractkit/chainlink/pull/11809) [`2e8e16820b`](https://github.com/smartcontractkit/chainlink/commit/2e8e16820b02a9ca83aa46e7ae2deac31eaf08aa) - Upgrade go-ethereum to v1.14.11 #internal - -- [#15279](https://github.com/smartcontractkit/chainlink/pull/15279) [`276d1f3255`](https://github.com/smartcontractkit/chainlink/commit/276d1f3255aeafb1767ee84f697fc4746890a9a6) - Integration tests for USDC token transfer #internal - -- [#14984](https://github.com/smartcontractkit/chainlink/pull/14984) [`1738332823`](https://github.com/smartcontractkit/chainlink/commit/173833282344857b5e7d95863d8dab211db71ad7) - use last mined nonce instead of pending nonce to recover from occasional nonce gap issues within nonce tracker. #internal - -- [#11809](https://github.com/smartcontractkit/chainlink/pull/11809) [`2e8e16820b`](https://github.com/smartcontractkit/chainlink/commit/2e8e16820b02a9ca83aa46e7ae2deac31eaf08aa) - Fix HeadTracker tests caused by simulated client update #internal - -- [#15130](https://github.com/smartcontractkit/chainlink/pull/15130) [`4f0491483c`](https://github.com/smartcontractkit/chainlink/commit/4f0491483c089ee2fd5e7875b0d58b65b304b111) - CCIP token transfer integration tests #internal - -- [#15150](https://github.com/smartcontractkit/chainlink/pull/15150) [`68bff71f20`](https://github.com/smartcontractkit/chainlink/commit/68bff71f201c8d40219aeadea08a49a19f1ba6f1) - #updated Operator UI to support StreamSpec job definition - -- [#14433](https://github.com/smartcontractkit/chainlink/pull/14433) [`340a6bfdf5`](https://github.com/smartcontractkit/chainlink/commit/340a6bfdf54745dd1c6d9f322d9c9515c97060bb) - Merge raw configs correctly #bugfix - -- [#15060](https://github.com/smartcontractkit/chainlink/pull/15060) [`239b3a95d1`](https://github.com/smartcontractkit/chainlink/commit/239b3a95d14dd872a7192de461c33fdc143fb163) - Modify release changelog generation format #internal - -- [#15019](https://github.com/smartcontractkit/chainlink/pull/15019) [`039777ffb8`](https://github.com/smartcontractkit/chainlink/commit/039777ffb8994e0af3a133dd02d9852f4d0a0ae1) - #updated celo alfajores config - -- [#15157](https://github.com/smartcontractkit/chainlink/pull/15157) [`12986aa461`](https://github.com/smartcontractkit/chainlink/commit/12986aa461a86a593dbce422725abf7e05f05332) - Enable AutoPurge feature on all affected chains #nops - -- [#14800](https://github.com/smartcontractkit/chainlink/pull/14800) [`7bac85fdb3`](https://github.com/smartcontractkit/chainlink/commit/7bac85fdb37ed15eb42357b0a0ce06e4febd7494) - #bugfix Memory leak fix on promwrapper - -- [#14867](https://github.com/smartcontractkit/chainlink/pull/14867) [`2d28b4eb9d`](https://github.com/smartcontractkit/chainlink/commit/2d28b4eb9d8635215d953b4f5d256791a9941035) - #updated Use labelers from `chainlink-common` - -- [#15284](https://github.com/smartcontractkit/chainlink/pull/15284) [`7cd17d687a`](https://github.com/smartcontractkit/chainlink/commit/7cd17d687a11fcfe36ad78d6279dc642b6186eaa) - Bump Solana to include MultiNode integration. #added - -- [#15372](https://github.com/smartcontractkit/chainlink/pull/15372) [`d3fa330b1b`](https://github.com/smartcontractkit/chainlink/commit/d3fa330b1b3c275c48ea7ec962b4b8457f940fcd) - fix reported evm node states - ## 2.18.0 - 2024-11-01 ### Minor Changes @@ -206,10 +91,12 @@ - [#14693](https://github.com/smartcontractkit/chainlink/pull/14693) [`03df8989e8`](https://github.com/smartcontractkit/chainlink/commit/03df8989e8e7549afb05bb49c765c0c07db8669e) Thanks [@ChrisAmora](https://github.com/ChrisAmora)! - #updated Consume Feeds Manager WSRPC protos from Chainlink Protos Repository. - [#14694](https://github.com/smartcontractkit/chainlink/pull/14694) [`e9b3397f46`](https://github.com/smartcontractkit/chainlink/commit/e9b3397f465e26209c8c8ccfed66aa9595f8246b) Thanks [@mateusz-sekara](https://github.com/mateusz-sekara)! - Adjustments for usdc reader tests #internal - [#14624](https://github.com/smartcontractkit/chainlink/pull/14624) [`be774f00a9`](https://github.com/smartcontractkit/chainlink/commit/be774f00a961d9a7361d9ae5b10c97996f7ab164) Thanks [@mateusz-sekara](https://github.com/mateusz-sekara)! - Registering USDC/CCTP events in the ChainReader during oracle creation #internal -- [#14534](https://github.com/smartcontractkit/chainlink/pull/14534) [`de268e98b8`](https://github.com/smartcontractkit/chainlink/commit/de268e98b8d68a284e1260297925b91c5d2411bc) Thanks [@huangzhen1997](https://github.com/huangzhen1997)! - - register polling subscription to avoid subscription leaking when rpc client gets closed. +- [#14534](https://github.com/smartcontractkit/chainlink/pull/14534) [`de268e98b8`](https://github.com/smartcontractkit/chainlink/commit/de268e98b8d68a284e1260297925b91c5d2411bc) Thanks [@huangzhen1997](https://github.com/huangzhen1997)! - - register polling subscription to avoid subscription leaking when rpc client gets closed. + - add a temporary special treatment for SubscribeNewHead before we replace it with SubscribeToHeads. Add a goroutine that forwards new head from poller to caller channel. - - fix a deadlock in poller, by using a new lock for subs slice in rpc client. #bugfix + - fix a deadlock in poller, by using a new lock for subs slice in rpc client. + #bugfix + - [#14656](https://github.com/smartcontractkit/chainlink/pull/14656) [`004a0de233`](https://github.com/smartcontractkit/chainlink/commit/004a0de2337b0312558ae7c045e7fc2fb4a05916) Thanks [@dimkouv](https://github.com/dimkouv)! - #added graceful shutdown for ccip oracles - [#14720](https://github.com/smartcontractkit/chainlink/pull/14720) [`4f8c55eb01`](https://github.com/smartcontractkit/chainlink/commit/4f8c55eb01b5823f43f49761344e92dc37ec0114) Thanks [@vyzaldysanchez](https://github.com/vyzaldysanchez)! - #updated Refactors store_db - [#14530](https://github.com/smartcontractkit/chainlink/pull/14530) [`2c16f46311`](https://github.com/smartcontractkit/chainlink/commit/2c16f4631184a6e3da7f2f3957173500e2c4837b) Thanks [@Madalosso](https://github.com/Madalosso)! - #updated default config values for FinalityTagEnabled to match CCIP configs diff --git a/common/txmgr/confirmer.go b/common/txmgr/confirmer.go index 7c5ba798cf2..7412d8c4f9c 100644 --- a/common/txmgr/confirmer.go +++ b/common/txmgr/confirmer.go @@ -282,6 +282,23 @@ func (ec *Confirmer[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) pro return err } ec.lggr.Debugw("Finished RebroadcastWhereNecessary", "headNum", head.BlockNumber(), "time", time.Since(mark), "id", "confirmer") + mark = time.Now() + + if err := ec.EnsureConfirmedTransactionsInLongestChain(ctx, head); err != nil { + return fmt.Errorf("EnsureConfirmedTransactionsInLongestChain failed: %w", err) + } + + ec.lggr.Debugw("Finished EnsureConfirmedTransactionsInLongestChain", "headNum", head.BlockNumber(), "time", time.Since(mark), "id", "confirmer") + + if ec.resumeCallback != nil { + mark = time.Now() + if err := ec.ResumePendingTaskRuns(ctx, head.BlockNumber(), latestFinalizedHead.BlockNumber()); err != nil { + return fmt.Errorf("ResumePendingTaskRuns failed: %w", err) + } + + ec.lggr.Debugw("Finished ResumePendingTaskRuns", "headNum", head.BlockNumber(), "time", time.Since(mark), "id", "confirmer") + } + ec.lggr.Debugw("processHead finish", "headNum", head.BlockNumber(), "id", "confirmer") return nil @@ -800,6 +817,139 @@ func (ec *Confirmer[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) han } } +// EnsureConfirmedTransactionsInLongestChain finds all confirmed txes up to the earliest head +// of the given chain and ensures that every one has a receipt with a block hash that is +// in the given chain. +// +// If any of the confirmed transactions does not have a receipt in the chain, it has been +// re-org'd out and will be rebroadcast. +func (ec *Confirmer[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) EnsureConfirmedTransactionsInLongestChain(ctx context.Context, head types.Head[BLOCK_HASH]) error { + logArgs := []interface{}{ + "chainLength", head.ChainLength(), + } + + //Here, we rely on the finalized block provided in the chain instead of the one + //provided via a dedicated method to avoid the false warning of the chain being + //too short. When `FinalityTagBypass = true,` HeadTracker tracks `finality depth + //+ history depth` to prevent excessive CPU usage. Thus, the provided chain may + //be shorter than the chain from the latest to the latest finalized, marked with + //a tag. A proper fix of this issue and complete switch to finality tag support + //will be introduced in BCFR-620 + latestFinalized := head.LatestFinalizedHead() + if latestFinalized == nil || !latestFinalized.IsValid() { + if ec.nConsecutiveBlocksChainTooShort > logAfterNConsecutiveBlocksChainTooShort { + warnMsg := "Chain length supplied for re-org detection was shorter than the depth from the latest head to the finalized head. Re-org protection is not working properly. This could indicate a problem with the remote RPC endpoint, a compatibility issue with a particular blockchain, a bug with this particular blockchain, heads table being truncated too early, remote node out of sync, or something else. If this happens a lot please raise a bug with the Chainlink team including a log output sample and details of the chain and RPC endpoint you are using." + ec.lggr.Warnw(warnMsg, append(logArgs, "nConsecutiveBlocksChainTooShort", ec.nConsecutiveBlocksChainTooShort)...) + } else { + logMsg := "Chain length supplied for re-org detection was shorter than the depth from the latest head to the finalized head" + ec.lggr.Debugw(logMsg, append(logArgs, "nConsecutiveBlocksChainTooShort", ec.nConsecutiveBlocksChainTooShort)...) + } + ec.nConsecutiveBlocksChainTooShort++ + } else { + ec.nConsecutiveBlocksChainTooShort = 0 + } + etxs, err := ec.txStore.FindTransactionsConfirmedInBlockRange(ctx, head.BlockNumber(), head.EarliestHeadInChain().BlockNumber(), ec.chainID) + if err != nil { + return fmt.Errorf("findTransactionsConfirmedInBlockRange failed: %w", err) + } + + for _, etx := range etxs { + if !hasReceiptInLongestChain(*etx, head) { + if err := ec.markForRebroadcast(ctx, *etx, head); err != nil { + return fmt.Errorf("markForRebroadcast failed for etx %v: %w", etx.ID, err) + } + } + } + + // It is safe to process separate keys concurrently + // NOTE: This design will block one key if another takes a really long time to execute + var wg sync.WaitGroup + errors := []error{} + var errMu sync.Mutex + wg.Add(len(ec.enabledAddresses)) + for _, address := range ec.enabledAddresses { + go func(fromAddress ADDR) { + if err := ec.handleAnyInProgressAttempts(ctx, fromAddress, head.BlockNumber()); err != nil { + errMu.Lock() + errors = append(errors, err) + errMu.Unlock() + ec.lggr.Errorw("Error in handleAnyInProgressAttempts", "err", err, "fromAddress", fromAddress) + } + + wg.Done() + }(address) + } + + wg.Wait() + + return multierr.Combine(errors...) +} + +func hasReceiptInLongestChain[ + CHAIN_ID types.ID, + ADDR types.Hashable, + TX_HASH, BLOCK_HASH types.Hashable, + SEQ types.Sequence, + FEE feetypes.Fee, +](etx txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], head types.Head[BLOCK_HASH]) bool { + for { + for _, attempt := range etx.TxAttempts { + for _, receipt := range attempt.Receipts { + if receipt.GetBlockHash().String() == head.BlockHash().String() && receipt.GetBlockNumber().Int64() == head.BlockNumber() { + return true + } + } + } + + head = head.GetParent() + if head == nil { + return false + } + } +} + +func (ec *Confirmer[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) markForRebroadcast(ctx context.Context, etx txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], head types.Head[BLOCK_HASH]) error { + if len(etx.TxAttempts) == 0 { + return fmt.Errorf("invariant violation: expected tx %v to have at least one attempt", etx.ID) + } + + // Rebroadcast the one with the highest gas price + attempt := etx.TxAttempts[0] + var receipt txmgrtypes.ChainReceipt[TX_HASH, BLOCK_HASH] + if len(attempt.Receipts) > 0 { + receipt = attempt.Receipts[0] + } + + logValues := []interface{}{ + "txhash", attempt.Hash.String(), + "currentBlockNum", head.BlockNumber(), + "currentBlockHash", head.BlockHash().String(), + "txID", etx.ID, + "attemptID", attempt.ID, + "nReceipts", len(attempt.Receipts), + "id", "confirmer", + } + + // nil check on receipt interface + if receipt != nil { + logValues = append(logValues, + "replacementBlockHashAtConfirmedHeight", head.HashAtHeight(receipt.GetBlockNumber().Int64()), + "confirmedInBlockNum", receipt.GetBlockNumber(), + "confirmedInBlockHash", receipt.GetBlockHash(), + "confirmedInTxIndex", receipt.GetTransactionIndex(), + ) + } + + ec.lggr.Infow(fmt.Sprintf("Re-org detected. Rebroadcasting transaction %s which may have been re-org'd out of the main chain", attempt.Hash.String()), logValues...) + + // Put it back in progress and delete all receipts (they do not apply to the new chain) + if err := ec.txStore.UpdateTxForRebroadcast(ctx, etx, attempt); err != nil { + return fmt.Errorf("markForRebroadcast failed: %w", err) + } + + return nil +} + // ForceRebroadcast sends a transaction for every sequence in the given sequence range at the given gas price. // If an tx exists for this sequence, we re-send the existing tx with the supplied parameters. // If an tx doesn't exist for this sequence, we send a zero transaction. diff --git a/common/txmgr/txmgr.go b/common/txmgr/txmgr.go index 3776f62254c..b05cd2e77ff 100644 --- a/common/txmgr/txmgr.go +++ b/common/txmgr/txmgr.go @@ -65,6 +65,19 @@ type TxManager[ GetTransactionStatus(ctx context.Context, transactionID string) (state commontypes.TransactionStatus, err error) } +type TxmV2Wrapper[ + CHAIN_ID types.ID, + HEAD types.Head[BLOCK_HASH], + ADDR types.Hashable, + TX_HASH types.Hashable, + BLOCK_HASH types.Hashable, + SEQ types.Sequence, + FEE feetypes.Fee, +] interface { + services.Service + CreateTransaction(ctx context.Context, txRequest txmgrtypes.TxRequest[ADDR, TX_HASH]) (etx txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], err error) +} + type reset struct { // f is the function to execute between stopping/starting the // Broadcaster and Confirmer @@ -112,6 +125,7 @@ type Txm[ fwdMgr txmgrtypes.ForwarderManager[ADDR] txAttemptBuilder txmgrtypes.TxAttemptBuilder[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] newErrorClassifier NewErrorClassifier + txmv2wrapper TxmV2Wrapper[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] } func (b *Txm[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) RegisterResumeCallback(fn ResumeCallback) { @@ -147,6 +161,7 @@ func NewTxm[ tracker *Tracker[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE], finalizer txmgrtypes.Finalizer[BLOCK_HASH, HEAD], newErrorClassifierFunc NewErrorClassifier, + txmv2wrapper TxmV2Wrapper[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], ) *Txm[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE] { b := Txm[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]{ logger: logger.Sugared(lggr), @@ -169,6 +184,7 @@ func NewTxm[ tracker: tracker, newErrorClassifier: newErrorClassifierFunc, finalizer: finalizer, + txmv2wrapper: txmv2wrapper, } if txCfg.ResendAfterThreshold() <= 0 { @@ -207,6 +223,12 @@ func (b *Txm[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) Start(ctx return fmt.Errorf("Txm: Finalizer failed to start: %w", err) } + if b.txmv2wrapper != nil { + if err := ms.Start(ctx, b.txmv2wrapper); err != nil { + return fmt.Errorf("Txm: Txmv2 failed to start: %w", err) + } + } + b.logger.Info("Txm starting runLoop") b.wg.Add(1) go b.runLoop() @@ -460,6 +482,12 @@ func (b *Txm[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) runLoop() if err != nil && (!errors.Is(err, services.ErrAlreadyStopped) || !errors.Is(err, services.ErrCannotStopUnstarted)) { b.logger.Errorw(fmt.Sprintf("Failed to Close Finalizer: %v", err), "err", err) } + if b.txmv2wrapper != nil { + err = b.txmv2wrapper.Close() + if err != nil && (!errors.Is(err, services.ErrAlreadyStopped) || !errors.Is(err, services.ErrCannotStopUnstarted)) { + b.logger.Errorw(fmt.Sprintf("Failed to Close Finalizer: %v", err), "err", err) + } + } return case <-keysChanged: // This check prevents the weird edge-case where you can select @@ -513,11 +541,14 @@ func (b *Txm[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) Trigger(ad func (b *Txm[CHAIN_ID, HEAD, ADDR, TX_HASH, BLOCK_HASH, R, SEQ, FEE]) CreateTransaction(ctx context.Context, txRequest txmgrtypes.TxRequest[ADDR, TX_HASH]) (tx txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE], err error) { // Check for existing Tx with IdempotencyKey. If found, return the Tx and do nothing // Skipping CreateTransaction to avoid double send + if b.txmv2wrapper != nil && txRequest.Meta != nil && txRequest.Meta.DualBroadcast != nil && *txRequest.Meta.DualBroadcast { + return b.txmv2wrapper.CreateTransaction(ctx, txRequest) + } if txRequest.IdempotencyKey != nil { var existingTx *txmgrtypes.Tx[CHAIN_ID, ADDR, TX_HASH, BLOCK_HASH, SEQ, FEE] existingTx, err = b.txStore.FindTxWithIdempotencyKey(ctx, *txRequest.IdempotencyKey, b.chainID) if err != nil { - return tx, fmt.Errorf("Failed to search for transaction with IdempotencyKey: %w", err) + return tx, fmt.Errorf("failed to search for transaction with IdempotencyKey: %w", err) } if existingTx != nil { b.logger.Infow("Found a Tx with IdempotencyKey. Returning existing Tx without creating a new one.", "IdempotencyKey", *txRequest.IdempotencyKey) diff --git a/core/capabilities/ccip/ocrimpls/contract_transmitter_test.go b/core/capabilities/ccip/ocrimpls/contract_transmitter_test.go index 16546b26999..a06fdf2aa0a 100644 --- a/core/capabilities/ccip/ocrimpls/contract_transmitter_test.go +++ b/core/capabilities/ccip/ocrimpls/contract_transmitter_test.go @@ -476,7 +476,8 @@ func makeTestEvmTxm( lp, keyStore, estimator, - ht) + ht, + nil) require.NoError(t, err, "can't create tx manager") _, unsub := broadcaster.Subscribe(txm) @@ -603,8 +604,8 @@ func (d *TestDAOracleConfig) OracleType() *toml.DAOracleType { return &oracleType } -func (d *TestDAOracleConfig) OracleAddress() *evmtypes.EIP55Address { - a, err := evmtypes.NewEIP55Address("0x420000000000000000000000000000000000000F") +func (d *TestDAOracleConfig) OracleAddress() *types.EIP55Address { + a, err := types.NewEIP55Address("0x420000000000000000000000000000000000000F") if err != nil { panic(err) } diff --git a/core/chains/evm/config/chain_scoped.go b/core/chains/evm/config/chain_scoped.go index de89272b5e2..3a7ff43d8a6 100644 --- a/core/chains/evm/config/chain_scoped.go +++ b/core/chains/evm/config/chain_scoped.go @@ -52,6 +52,10 @@ func (e *EVMConfig) BalanceMonitor() BalanceMonitor { return &balanceMonitorConfig{c: e.C.BalanceMonitor} } +func (e *EVMConfig) TxmV2() TxmV2 { + return &txmv2Config{c: e.C.TxmV2} +} + func (e *EVMConfig) Transactions() Transactions { return &transactionsConfig{c: e.C.Transactions} } diff --git a/core/chains/evm/config/chain_scoped_txmv2.go b/core/chains/evm/config/chain_scoped_txmv2.go new file mode 100644 index 00000000000..e50148cfae4 --- /dev/null +++ b/core/chains/evm/config/chain_scoped_txmv2.go @@ -0,0 +1,25 @@ +package config + +import ( + "net/url" + "time" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/toml" +) + +type txmv2Config struct { + c toml.TxmV2 +} + +func (t *txmv2Config) Enabled() bool { + return *t.c.Enabled +} + +func (t *txmv2Config) BlockTime() *time.Duration { + d := t.c.BlockTime.Duration() + return &d +} + +func (t *txmv2Config) CustomURL() *url.URL { + return t.c.CustomURL.URL() +} diff --git a/core/chains/evm/config/chaintype/chaintype.go b/core/chains/evm/config/chaintype/chaintype.go index b2eff02834b..bc2eace8ca3 100644 --- a/core/chains/evm/config/chaintype/chaintype.go +++ b/core/chains/evm/config/chaintype/chaintype.go @@ -23,6 +23,7 @@ const ( ChainZkEvm ChainType = "zkevm" ChainZkSync ChainType = "zksync" ChainZircuit ChainType = "zircuit" + ChainDualBroadcast ChainType = "dualBroadcast" ) // IsL2 returns true if this chain is a Layer 2 chain. Notably: @@ -39,7 +40,7 @@ func (c ChainType) IsL2() bool { func (c ChainType) IsValid() bool { switch c { - case "", ChainArbitrum, ChainAstar, ChainCelo, ChainGnosis, ChainHedera, ChainKroma, ChainMantle, ChainMetis, ChainOptimismBedrock, ChainScroll, ChainWeMix, ChainXLayer, ChainZkEvm, ChainZkSync, ChainZircuit: + case "", ChainArbitrum, ChainAstar, ChainCelo, ChainGnosis, ChainHedera, ChainKroma, ChainMantle, ChainMetis, ChainOptimismBedrock, ChainScroll, ChainWeMix, ChainXLayer, ChainZkEvm, ChainZkSync, ChainZircuit, ChainDualBroadcast: return true } return false @@ -77,6 +78,8 @@ func FromSlug(slug string) ChainType { return ChainZkSync case "zircuit": return ChainZircuit + case "dualBroadcast": + return ChainDualBroadcast default: return ChainType(slug) } @@ -144,4 +147,5 @@ var ErrInvalid = fmt.Errorf("must be one of %s or omitted", strings.Join([]strin string(ChainZkEvm), string(ChainZkSync), string(ChainZircuit), + string(ChainDualBroadcast), }, ", ")) diff --git a/core/chains/evm/config/config.go b/core/chains/evm/config/config.go index f2a571f94b0..be61bfe02ee 100644 --- a/core/chains/evm/config/config.go +++ b/core/chains/evm/config/config.go @@ -18,6 +18,7 @@ import ( type EVM interface { HeadTracker() HeadTracker BalanceMonitor() BalanceMonitor + TxmV2() TxmV2 Transactions() Transactions GasEstimator() GasEstimator OCR() OCR @@ -102,6 +103,12 @@ type ClientErrors interface { TooManyResults() string } +type TxmV2 interface { + Enabled() bool + BlockTime() *time.Duration + CustomURL() *url.URL +} + type Transactions interface { ForwardersEnabled() bool ReaperInterval() time.Duration diff --git a/core/chains/evm/config/toml/config.go b/core/chains/evm/config/toml/config.go index 0505449943e..61fc0ea518b 100644 --- a/core/chains/evm/config/toml/config.go +++ b/core/chains/evm/config/toml/config.go @@ -300,8 +300,10 @@ func (c *EVMConfig) ValidateConfig() (err error) { is := c.ChainType.ChainType() if is != must { if must == "" { - err = multierr.Append(err, commonconfig.ErrInvalid{Name: "ChainType", Value: c.ChainType.ChainType(), - Msg: "must not be set with this chain id"}) + if c.ChainType.ChainType() != chaintype.ChainDualBroadcast { + err = multierr.Append(err, commonconfig.ErrInvalid{Name: "ChainType", Value: c.ChainType.ChainType(), + Msg: "must not be set with this chain id"}) + } } else { err = multierr.Append(err, commonconfig.ErrInvalid{Name: "ChainType", Value: c.ChainType.ChainType(), Msg: fmt.Sprintf("only %q can be used with this chain id", must)}) @@ -387,6 +389,7 @@ type Chain struct { FinalizedBlockOffset *uint32 NoNewFinalizedHeadsThreshold *commonconfig.Duration + TxmV2 TxmV2 `toml:",omitempty"` Transactions Transactions `toml:",omitempty"` BalanceMonitor BalanceMonitor `toml:",omitempty"` GasEstimator GasEstimator `toml:",omitempty"` @@ -471,6 +474,26 @@ func (c *Chain) ValidateConfig() (err error) { return } +type TxmV2 struct { + Enabled *bool `toml:",omitempty"` + BlockTime *commonconfig.Duration `toml:",omitempty"` + CustomURL *commonconfig.URL `toml:",omitempty"` +} + +func (t *TxmV2) setFrom(f *TxmV2) { + if v := f.Enabled; v != nil { + t.Enabled = f.Enabled + } + + if v := f.BlockTime; v != nil { + t.BlockTime = f.BlockTime + } + + if v := f.CustomURL; v != nil { + t.CustomURL = f.CustomURL + } +} + type Transactions struct { ForwardersEnabled *bool MaxInFlight *uint32 @@ -803,6 +826,17 @@ func (o *DAOracle) ValidateConfig() (err error) { return } +func (o *DAOracle) ValidateConfig() (err error) { + if o.OracleType != nil { + if *o.OracleType == DAOracleOPStack { + if o.OracleAddress == nil { + err = multierr.Append(err, commonconfig.ErrMissing{Name: "OracleAddress", Msg: "required for 'opstack' oracle types"}) + } + } + } + return +} + func (o DAOracleType) IsValid() bool { switch o { case "", DAOracleOPStack, DAOracleArbitrum, DAOracleZKSync, DAOracleCustomCalldata: diff --git a/core/chains/evm/config/toml/defaults.go b/core/chains/evm/config/toml/defaults.go index 0885d94e6df..5ce014921f4 100644 --- a/core/chains/evm/config/toml/defaults.go +++ b/core/chains/evm/config/toml/defaults.go @@ -241,6 +241,7 @@ func (c *Chain) SetFrom(f *Chain) { c.NoNewFinalizedHeadsThreshold = v } + c.TxmV2.setFrom(&f.TxmV2) c.Transactions.setFrom(&f.Transactions) c.BalanceMonitor.setFrom(&f.BalanceMonitor) c.GasEstimator.setFrom(&f.GasEstimator) diff --git a/core/chains/evm/config/toml/defaults/fallback.toml b/core/chains/evm/config/toml/defaults/fallback.toml index ab349ee4688..44228968fc6 100644 --- a/core/chains/evm/config/toml/defaults/fallback.toml +++ b/core/chains/evm/config/toml/defaults/fallback.toml @@ -18,6 +18,9 @@ FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0' LogBroadcasterEnabled = true +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 diff --git a/core/chains/evm/gas/rollups/l1_oracle.go b/core/chains/evm/gas/rollups/l1_oracle.go index ceecb80c608..d787c1cfe1a 100644 --- a/core/chains/evm/gas/rollups/l1_oracle.go +++ b/core/chains/evm/gas/rollups/l1_oracle.go @@ -65,10 +65,6 @@ func NewL1GasOracle(lggr logger.Logger, ethClient l1OracleClient, chainType chai // TODO(CCIP-3551) the actual usage of the clientsByChainID should update the check accordingly, potentially return errors instead of logging. Going forward all configs should specify a DAOracle config. This is a fall back to maintain backwards compat. if daOracle != nil { - if clientsByChainID == nil { - lggr.Debugf("clientsByChainID map is missing") - } - oracleType := daOracle.OracleType() if oracleType == nil { return nil, errors.New("required field OracleType is nil in non-nil DAOracle config") diff --git a/core/chains/evm/keystore/eth.go b/core/chains/evm/keystore/eth.go index ff71e0a4f18..9c0986d9c3d 100644 --- a/core/chains/evm/keystore/eth.go +++ b/core/chains/evm/keystore/eth.go @@ -13,5 +13,6 @@ type Eth interface { CheckEnabled(ctx context.Context, address common.Address, chainID *big.Int) error EnabledAddressesForChain(ctx context.Context, chainID *big.Int) (addresses []common.Address, err error) SignTx(ctx context.Context, fromAddress common.Address, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) + SignMessage(ctx context.Context, address common.Address, message []byte) ([]byte, error) SubscribeToKeyChanges(ctx context.Context) (ch chan struct{}, unsub func()) } diff --git a/core/chains/evm/keystore/mocks/eth.go b/core/chains/evm/keystore/mocks/eth.go index b481be1b5c8..bfc85fc672c 100644 --- a/core/chains/evm/keystore/mocks/eth.go +++ b/core/chains/evm/keystore/mocks/eth.go @@ -133,6 +133,66 @@ func (_c *Eth_EnabledAddressesForChain_Call) RunAndReturn(run func(context.Conte return _c } +// SignMessage provides a mock function with given fields: ctx, address, message +func (_m *Eth) SignMessage(ctx context.Context, address common.Address, message []byte) ([]byte, error) { + ret := _m.Called(ctx, address, message) + + if len(ret) == 0 { + panic("no return value specified for SignMessage") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, []byte) ([]byte, error)); ok { + return rf(ctx, address, message) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address, []byte) []byte); ok { + r0 = rf(ctx, address, message) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address, []byte) error); ok { + r1 = rf(ctx, address, message) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Eth_SignMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignMessage' +type Eth_SignMessage_Call struct { + *mock.Call +} + +// SignMessage is a helper method to define mock.On call +// - ctx context.Context +// - address common.Address +// - message []byte +func (_e *Eth_Expecter) SignMessage(ctx interface{}, address interface{}, message interface{}) *Eth_SignMessage_Call { + return &Eth_SignMessage_Call{Call: _e.mock.On("SignMessage", ctx, address, message)} +} + +func (_c *Eth_SignMessage_Call) Run(run func(ctx context.Context, address common.Address, message []byte)) *Eth_SignMessage_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address), args[2].([]byte)) + }) + return _c +} + +func (_c *Eth_SignMessage_Call) Return(_a0 []byte, _a1 error) *Eth_SignMessage_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Eth_SignMessage_Call) RunAndReturn(run func(context.Context, common.Address, []byte) ([]byte, error)) *Eth_SignMessage_Call { + _c.Call.Return(run) + return _c +} + // SignTx provides a mock function with given fields: ctx, fromAddress, tx, chainID func (_m *Eth) SignTx(ctx context.Context, fromAddress common.Address, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { ret := _m.Called(ctx, fromAddress, tx, chainID) diff --git a/core/chains/evm/txm/attempt_builder.go b/core/chains/evm/txm/attempt_builder.go new file mode 100644 index 00000000000..ceae96f1b28 --- /dev/null +++ b/core/chains/evm/txm/attempt_builder.go @@ -0,0 +1,154 @@ +package txm + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + evmtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +type AttemptBuilderKeystore interface { + SignTx(ctx context.Context, fromAddress common.Address, tx *evmtypes.Transaction, chainID *big.Int) (*evmtypes.Transaction, error) +} + +type attemptBuilder struct { + chainID *big.Int + priceMax *assets.Wei + gas.EvmFeeEstimator + keystore AttemptBuilderKeystore +} + +func NewAttemptBuilder(chainID *big.Int, priceMax *assets.Wei, estimator gas.EvmFeeEstimator, keystore AttemptBuilderKeystore) *attemptBuilder { + return &attemptBuilder{ + chainID: chainID, + priceMax: priceMax, + EvmFeeEstimator: estimator, + keystore: keystore, + } +} + +func (a *attemptBuilder) NewAttempt(ctx context.Context, lggr logger.Logger, tx *types.Transaction, dynamic bool) (*types.Attempt, error) { + fee, estimatedGasLimit, err := a.EvmFeeEstimator.GetFee(ctx, tx.Data, tx.SpecifiedGasLimit, a.priceMax, &tx.FromAddress, &tx.ToAddress) + if err != nil { + return nil, err + } + txType := evmtypes.LegacyTxType + if dynamic { + txType = evmtypes.DynamicFeeTxType + } + return a.newCustomAttempt(ctx, tx, fee, estimatedGasLimit, byte(txType), lggr) +} + +func (a *attemptBuilder) NewBumpAttempt(ctx context.Context, lggr logger.Logger, tx *types.Transaction, previousAttempt types.Attempt) (*types.Attempt, error) { + bumpedFee, bumpedFeeLimit, err := a.EvmFeeEstimator.BumpFee(ctx, previousAttempt.Fee, tx.SpecifiedGasLimit, a.priceMax, nil) + if err != nil { + return nil, err + } + return a.newCustomAttempt(ctx, tx, bumpedFee, bumpedFeeLimit, previousAttempt.Type, lggr) +} + +func (a *attemptBuilder) newCustomAttempt( + ctx context.Context, + tx *types.Transaction, + fee gas.EvmFee, + estimatedGasLimit uint64, + txType byte, + lggr logger.Logger, +) (attempt *types.Attempt, err error) { + switch txType { + case 0x0: + if fee.GasPrice == nil { + err = fmt.Errorf("tried to create attempt of type %v for txID: %v but estimator did not return legacy fee", txType, tx.ID) + logger.Sugared(lggr).AssumptionViolation(err.Error()) + return + } + return a.newLegacyAttempt(ctx, tx, fee.GasPrice, estimatedGasLimit) + case 0x2: + if !fee.ValidDynamic() { + err = fmt.Errorf("tried to create attempt of type %v for txID: %v but estimator did not return dynamic fee", txType, tx.ID) + logger.Sugared(lggr).AssumptionViolation(err.Error()) + return + } + return a.newDynamicFeeAttempt(ctx, tx, fee.DynamicFee, estimatedGasLimit) + default: + return nil, fmt.Errorf("cannot build attempt, unrecognized transaction type: %v", txType) + } +} + +func (a *attemptBuilder) newLegacyAttempt(ctx context.Context, tx *types.Transaction, gasPrice *assets.Wei, estimatedGasLimit uint64) (*types.Attempt, error) { + var data []byte + var toAddress common.Address + value := big.NewInt(0) + if !tx.IsPurgeable { + data = tx.Data + toAddress = tx.ToAddress + value = tx.Value + } + legacyTx := evmtypes.LegacyTx{ + Nonce: tx.Nonce, + To: &toAddress, + Value: value, + Gas: estimatedGasLimit, + GasPrice: gasPrice.ToInt(), + Data: data, + } + + signedTx, err := a.keystore.SignTx(ctx, tx.FromAddress, evmtypes.NewTx(&legacyTx), a.chainID) + if err != nil { + return nil, fmt.Errorf("failed to sign attempt for txID: %v, err: %w", tx.ID, err) + } + + attempt := &types.Attempt{ + TxID: tx.ID, + Fee: gas.EvmFee{GasPrice: gasPrice}, + Hash: signedTx.Hash(), + GasLimit: estimatedGasLimit, + SignedTransaction: signedTx, + } + + return attempt, nil +} + +func (a *attemptBuilder) newDynamicFeeAttempt(ctx context.Context, tx *types.Transaction, dynamicFee gas.DynamicFee, estimatedGasLimit uint64) (*types.Attempt, error) { + var data []byte + var toAddress common.Address + value := big.NewInt(0) + if !tx.IsPurgeable { + data = tx.Data + toAddress = tx.ToAddress + value = tx.Value + } + dynamicTx := evmtypes.DynamicFeeTx{ + Nonce: tx.Nonce, + To: &toAddress, + Value: value, + Gas: estimatedGasLimit, + GasFeeCap: dynamicFee.GasFeeCap.ToInt(), + GasTipCap: dynamicFee.GasTipCap.ToInt(), + Data: data, + } + + signedTx, err := a.keystore.SignTx(ctx, tx.FromAddress, evmtypes.NewTx(&dynamicTx), a.chainID) + if err != nil { + return nil, fmt.Errorf("failed to sign attempt for txID: %v, err: %w", tx.ID, err) + } + + attempt := &types.Attempt{ + TxID: tx.ID, + Fee: gas.EvmFee{DynamicFee: gas.DynamicFee{GasFeeCap: dynamicFee.GasFeeCap, GasTipCap: dynamicFee.GasTipCap}}, + Hash: signedTx.Hash(), + GasLimit: estimatedGasLimit, + Type: evmtypes.DynamicFeeTxType, + SignedTransaction: signedTx, + } + + return attempt, nil +} diff --git a/core/chains/evm/txm/clientwrappers/chain_client.go b/core/chains/evm/txm/clientwrappers/chain_client.go new file mode 100644 index 00000000000..7638cc53443 --- /dev/null +++ b/core/chains/evm/txm/clientwrappers/chain_client.go @@ -0,0 +1,31 @@ +package clientwrappers + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +type ChainClient struct { + c client.Client +} + +func NewChainClient(client client.Client) *ChainClient { + return &ChainClient{c: client} +} + +func (c *ChainClient) NonceAt(ctx context.Context, address common.Address, blockNumber *big.Int) (uint64, error) { + return c.c.NonceAt(ctx, address, blockNumber) +} + +func (c *ChainClient) PendingNonceAt(ctx context.Context, address common.Address) (uint64, error) { + return c.c.PendingNonceAt(ctx, address) +} + +func (c *ChainClient) SendTransaction(ctx context.Context, _ *types.Transaction, attempt *types.Attempt) error { + return c.c.SendTransaction(ctx, attempt.SignedTransaction) +} diff --git a/core/chains/evm/txm/clientwrappers/dual_broadcast_client.go b/core/chains/evm/txm/clientwrappers/dual_broadcast_client.go new file mode 100644 index 00000000000..0bbd2530765 --- /dev/null +++ b/core/chains/evm/txm/clientwrappers/dual_broadcast_client.go @@ -0,0 +1,130 @@ +package clientwrappers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +type DualBroadcastClientKeystore interface { + SignMessage(ctx context.Context, address common.Address, message []byte) ([]byte, error) +} + +type DualBroadcastClient struct { + c client.Client + keystore DualBroadcastClientKeystore + customURL *url.URL +} + +func NewDualBroadcastClient(c client.Client, keystore DualBroadcastClientKeystore, customURL *url.URL) *DualBroadcastClient { + return &DualBroadcastClient{ + c: c, + keystore: keystore, + customURL: customURL, + } +} + +func (d *DualBroadcastClient) NonceAt(ctx context.Context, address common.Address, blockNumber *big.Int) (uint64, error) { + return d.c.NonceAt(ctx, address, blockNumber) +} + +func (d *DualBroadcastClient) PendingNonceAt(ctx context.Context, address common.Address) (uint64, error) { + body := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","method":"eth_getTransactionCount","params":["%s","pending"]}`, address.String())) + response, err := d.signAndPostMessage(ctx, address, body, "") + if err != nil { + return 0, err + } + + nonce, err := hexutil.DecodeUint64(response) + if err != nil { + return 0, fmt.Errorf("failed to decode response %v into uint64: %w", response, err) + } + return nonce, nil +} + +func (d *DualBroadcastClient) SendTransaction(ctx context.Context, tx *types.Transaction, attempt *types.Attempt) error { + meta, err := tx.GetMeta() + if err != nil { + return err + } + //nolint:revive //linter nonsense + if meta != nil && meta.DualBroadcast != nil && *meta.DualBroadcast && !tx.IsPurgeable { + data, err := attempt.SignedTransaction.MarshalBinary() + if err != nil { + return err + } + params := "" + if meta.DualBroadcastParams != nil { + params = *meta.DualBroadcastParams + } + body := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","method":"eth_sendRawTransaction","params":["%s"]}`, hexutil.Encode(data))) + if _, err = d.signAndPostMessage(ctx, tx.FromAddress, body, params); err != nil { + return err + } + return nil + } else { + return d.c.SendTransaction(ctx, attempt.SignedTransaction) + } +} + +func (d *DualBroadcastClient) signAndPostMessage(ctx context.Context, address common.Address, body []byte, urlParams string) (result string, err error) { + bodyReader := bytes.NewReader(body) + postReq, err := http.NewRequestWithContext(ctx, http.MethodPost, d.customURL.String()+"?"+urlParams, bodyReader) + if err != nil { + return + } + + hashedBody := crypto.Keccak256Hash(body).Hex() + signedMessage, err := d.keystore.SignMessage(ctx, address, []byte(hashedBody)) + if err != nil { + return + } + + postReq.Header.Add("X-Flashbots-signature", address.String()+":"+hexutil.Encode(signedMessage)) + postReq.Header.Add("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(postReq) + if err != nil { + return result, fmt.Errorf("request %v failed: %w", postReq, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return result, fmt.Errorf("request %v failed with status: %d", postReq, resp.StatusCode) + } + + keyJSON, err := io.ReadAll(resp.Body) + if err != nil { + return + } + var response postResponse + err = json.Unmarshal(keyJSON, &response) + if err != nil { + return result, fmt.Errorf("failed to unmarshal response into struct: %w: %s", err, string(keyJSON)) + } + if response.Error.Message != "" { + return result, errors.New(response.Error.Message) + } + return response.Result, nil +} + +type postResponse struct { + Result string `json:"result,omitempty"` + Error postError +} + +type postError struct { + Message string `json:"message,omitempty"` +} diff --git a/core/chains/evm/txm/clientwrappers/geth_client.go b/core/chains/evm/txm/clientwrappers/geth_client.go new file mode 100644 index 00000000000..d97e5cfae35 --- /dev/null +++ b/core/chains/evm/txm/clientwrappers/geth_client.go @@ -0,0 +1,51 @@ +package clientwrappers + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" + evmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/types" +) + +type GethClient struct { + *ethclient.Client +} + +func NewGethClient(client *ethclient.Client) *GethClient { + return &GethClient{ + Client: client, + } +} + +func (g *GethClient) BatchCallContext(ctx context.Context, b []rpc.BatchElem) error { + return g.Client.Client().BatchCallContext(ctx, b) +} + +func (g *GethClient) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + return g.Client.Client().CallContext(ctx, result, method, args...) +} + +func (g *GethClient) CallContract(ctx context.Context, message ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { + var hex hexutil.Bytes + err := g.CallContext(ctx, &hex, "eth_call", client.ToBackwardCompatibleCallArg(message), client.ToBackwardCompatibleBlockNumArg(blockNumber)) + return hex, err +} + +func (g *GethClient) HeadByNumber(ctx context.Context, number *big.Int) (*evmtypes.Head, error) { + hexNumber := client.ToBlockNumArg(number) + args := []interface{}{hexNumber, false} + head := new(evmtypes.Head) + err := g.CallContext(ctx, head, "eth_getBlockByNumber", args...) + return head, err +} + +func (g *GethClient) SendTransaction(ctx context.Context, _ *types.Transaction, attempt *types.Attempt) error { + return g.Client.SendTransaction(ctx, attempt.SignedTransaction) +} diff --git a/core/chains/evm/txm/dummy_keystore.go b/core/chains/evm/txm/dummy_keystore.go new file mode 100644 index 00000000000..e17d1645acb --- /dev/null +++ b/core/chains/evm/txm/dummy_keystore.go @@ -0,0 +1,56 @@ +package txm + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" +) + +type DummyKeystore struct { + privateKeyMap map[common.Address]*ecdsa.PrivateKey +} + +func NewKeystore() *DummyKeystore { + return &DummyKeystore{privateKeyMap: make(map[common.Address]*ecdsa.PrivateKey)} +} + +func (k *DummyKeystore) Add(privateKeyString string, address common.Address) error { + privateKey, err := crypto.HexToECDSA(privateKeyString) + if err != nil { + return err + } + k.privateKeyMap[address] = privateKey + return nil +} + +func (k *DummyKeystore) SignTx(_ context.Context, fromAddress common.Address, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { + if key, exists := k.privateKeyMap[fromAddress]; exists { + return types.SignTx(tx, types.LatestSignerForChainID(chainID), key) + } + return nil, fmt.Errorf("private key for address: %v not found", fromAddress) +} + +func (k *DummyKeystore) SignMessage(ctx context.Context, address common.Address, data []byte) ([]byte, error) { + key, exists := k.privateKeyMap[address] + if !exists { + return nil, fmt.Errorf("private key for address: %v not found", address) + } + signature, err := crypto.Sign(accounts.TextHash(data), key) + if err != nil { + return nil, fmt.Errorf("failed to sign message for address: %v", address) + } + return signature, nil +} + +func (k *DummyKeystore) EnabledAddressesForChain(_ context.Context, _ *big.Int) (addresses []common.Address, err error) { + for address := range k.privateKeyMap { + addresses = append(addresses, address) + } + return +} diff --git a/core/chains/evm/txm/mocks/attempt_builder.go b/core/chains/evm/txm/mocks/attempt_builder.go new file mode 100644 index 00000000000..aad06745cf4 --- /dev/null +++ b/core/chains/evm/txm/mocks/attempt_builder.go @@ -0,0 +1,161 @@ +// Code generated by mockery v2.46.3. DO NOT EDIT. + +package mocks + +import ( + context "context" + + logger "github.com/smartcontractkit/chainlink-common/pkg/logger" + mock "github.com/stretchr/testify/mock" + + types "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +// AttemptBuilder is an autogenerated mock type for the AttemptBuilder type +type AttemptBuilder struct { + mock.Mock +} + +type AttemptBuilder_Expecter struct { + mock *mock.Mock +} + +func (_m *AttemptBuilder) EXPECT() *AttemptBuilder_Expecter { + return &AttemptBuilder_Expecter{mock: &_m.Mock} +} + +// NewAttempt provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *AttemptBuilder) NewAttempt(_a0 context.Context, _a1 logger.Logger, _a2 *types.Transaction, _a3 bool) (*types.Attempt, error) { + ret := _m.Called(_a0, _a1, _a2, _a3) + + if len(ret) == 0 { + panic("no return value specified for NewAttempt") + } + + var r0 *types.Attempt + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, logger.Logger, *types.Transaction, bool) (*types.Attempt, error)); ok { + return rf(_a0, _a1, _a2, _a3) + } + if rf, ok := ret.Get(0).(func(context.Context, logger.Logger, *types.Transaction, bool) *types.Attempt); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Attempt) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, logger.Logger, *types.Transaction, bool) error); ok { + r1 = rf(_a0, _a1, _a2, _a3) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AttemptBuilder_NewAttempt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewAttempt' +type AttemptBuilder_NewAttempt_Call struct { + *mock.Call +} + +// NewAttempt is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 logger.Logger +// - _a2 *types.Transaction +// - _a3 bool +func (_e *AttemptBuilder_Expecter) NewAttempt(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 interface{}) *AttemptBuilder_NewAttempt_Call { + return &AttemptBuilder_NewAttempt_Call{Call: _e.mock.On("NewAttempt", _a0, _a1, _a2, _a3)} +} + +func (_c *AttemptBuilder_NewAttempt_Call) Run(run func(_a0 context.Context, _a1 logger.Logger, _a2 *types.Transaction, _a3 bool)) *AttemptBuilder_NewAttempt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(logger.Logger), args[2].(*types.Transaction), args[3].(bool)) + }) + return _c +} + +func (_c *AttemptBuilder_NewAttempt_Call) Return(_a0 *types.Attempt, _a1 error) *AttemptBuilder_NewAttempt_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AttemptBuilder_NewAttempt_Call) RunAndReturn(run func(context.Context, logger.Logger, *types.Transaction, bool) (*types.Attempt, error)) *AttemptBuilder_NewAttempt_Call { + _c.Call.Return(run) + return _c +} + +// NewBumpAttempt provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *AttemptBuilder) NewBumpAttempt(_a0 context.Context, _a1 logger.Logger, _a2 *types.Transaction, _a3 types.Attempt) (*types.Attempt, error) { + ret := _m.Called(_a0, _a1, _a2, _a3) + + if len(ret) == 0 { + panic("no return value specified for NewBumpAttempt") + } + + var r0 *types.Attempt + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, logger.Logger, *types.Transaction, types.Attempt) (*types.Attempt, error)); ok { + return rf(_a0, _a1, _a2, _a3) + } + if rf, ok := ret.Get(0).(func(context.Context, logger.Logger, *types.Transaction, types.Attempt) *types.Attempt); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Attempt) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, logger.Logger, *types.Transaction, types.Attempt) error); ok { + r1 = rf(_a0, _a1, _a2, _a3) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AttemptBuilder_NewBumpAttempt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewBumpAttempt' +type AttemptBuilder_NewBumpAttempt_Call struct { + *mock.Call +} + +// NewBumpAttempt is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 logger.Logger +// - _a2 *types.Transaction +// - _a3 types.Attempt +func (_e *AttemptBuilder_Expecter) NewBumpAttempt(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 interface{}) *AttemptBuilder_NewBumpAttempt_Call { + return &AttemptBuilder_NewBumpAttempt_Call{Call: _e.mock.On("NewBumpAttempt", _a0, _a1, _a2, _a3)} +} + +func (_c *AttemptBuilder_NewBumpAttempt_Call) Run(run func(_a0 context.Context, _a1 logger.Logger, _a2 *types.Transaction, _a3 types.Attempt)) *AttemptBuilder_NewBumpAttempt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(logger.Logger), args[2].(*types.Transaction), args[3].(types.Attempt)) + }) + return _c +} + +func (_c *AttemptBuilder_NewBumpAttempt_Call) Return(_a0 *types.Attempt, _a1 error) *AttemptBuilder_NewBumpAttempt_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AttemptBuilder_NewBumpAttempt_Call) RunAndReturn(run func(context.Context, logger.Logger, *types.Transaction, types.Attempt) (*types.Attempt, error)) *AttemptBuilder_NewBumpAttempt_Call { + _c.Call.Return(run) + return _c +} + +// NewAttemptBuilder creates a new instance of AttemptBuilder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAttemptBuilder(t interface { + mock.TestingT + Cleanup(func()) +}) *AttemptBuilder { + mock := &AttemptBuilder{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/chains/evm/txm/mocks/client.go b/core/chains/evm/txm/mocks/client.go new file mode 100644 index 00000000000..03849ad7e82 --- /dev/null +++ b/core/chains/evm/txm/mocks/client.go @@ -0,0 +1,204 @@ +// Code generated by mockery v2.46.3. DO NOT EDIT. + +package mocks + +import ( + context "context" + big "math/big" + + common "github.com/ethereum/go-ethereum/common" + + mock "github.com/stretchr/testify/mock" + + types "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +type Client_Expecter struct { + mock *mock.Mock +} + +func (_m *Client) EXPECT() *Client_Expecter { + return &Client_Expecter{mock: &_m.Mock} +} + +// NonceAt provides a mock function with given fields: _a0, _a1, _a2 +func (_m *Client) NonceAt(_a0 context.Context, _a1 common.Address, _a2 *big.Int) (uint64, error) { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for NonceAt") + } + + var r0 uint64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, *big.Int) (uint64, error)); ok { + return rf(_a0, _a1, _a2) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address, *big.Int) uint64); ok { + r0 = rf(_a0, _a1, _a2) + } else { + r0 = ret.Get(0).(uint64) + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address, *big.Int) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Client_NonceAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NonceAt' +type Client_NonceAt_Call struct { + *mock.Call +} + +// NonceAt is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 common.Address +// - _a2 *big.Int +func (_e *Client_Expecter) NonceAt(_a0 interface{}, _a1 interface{}, _a2 interface{}) *Client_NonceAt_Call { + return &Client_NonceAt_Call{Call: _e.mock.On("NonceAt", _a0, _a1, _a2)} +} + +func (_c *Client_NonceAt_Call) Run(run func(_a0 context.Context, _a1 common.Address, _a2 *big.Int)) *Client_NonceAt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address), args[2].(*big.Int)) + }) + return _c +} + +func (_c *Client_NonceAt_Call) Return(_a0 uint64, _a1 error) *Client_NonceAt_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Client_NonceAt_Call) RunAndReturn(run func(context.Context, common.Address, *big.Int) (uint64, error)) *Client_NonceAt_Call { + _c.Call.Return(run) + return _c +} + +// PendingNonceAt provides a mock function with given fields: _a0, _a1 +func (_m *Client) PendingNonceAt(_a0 context.Context, _a1 common.Address) (uint64, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for PendingNonceAt") + } + + var r0 uint64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address) (uint64, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address) uint64); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Get(0).(uint64) + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Client_PendingNonceAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PendingNonceAt' +type Client_PendingNonceAt_Call struct { + *mock.Call +} + +// PendingNonceAt is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 common.Address +func (_e *Client_Expecter) PendingNonceAt(_a0 interface{}, _a1 interface{}) *Client_PendingNonceAt_Call { + return &Client_PendingNonceAt_Call{Call: _e.mock.On("PendingNonceAt", _a0, _a1)} +} + +func (_c *Client_PendingNonceAt_Call) Run(run func(_a0 context.Context, _a1 common.Address)) *Client_PendingNonceAt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address)) + }) + return _c +} + +func (_c *Client_PendingNonceAt_Call) Return(_a0 uint64, _a1 error) *Client_PendingNonceAt_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Client_PendingNonceAt_Call) RunAndReturn(run func(context.Context, common.Address) (uint64, error)) *Client_PendingNonceAt_Call { + _c.Call.Return(run) + return _c +} + +// SendTransaction provides a mock function with given fields: ctx, tx, attempt +func (_m *Client) SendTransaction(ctx context.Context, tx *types.Transaction, attempt *types.Attempt) error { + ret := _m.Called(ctx, tx, attempt) + + if len(ret) == 0 { + panic("no return value specified for SendTransaction") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *types.Transaction, *types.Attempt) error); ok { + r0 = rf(ctx, tx, attempt) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Client_SendTransaction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendTransaction' +type Client_SendTransaction_Call struct { + *mock.Call +} + +// SendTransaction is a helper method to define mock.On call +// - ctx context.Context +// - tx *types.Transaction +// - attempt *types.Attempt +func (_e *Client_Expecter) SendTransaction(ctx interface{}, tx interface{}, attempt interface{}) *Client_SendTransaction_Call { + return &Client_SendTransaction_Call{Call: _e.mock.On("SendTransaction", ctx, tx, attempt)} +} + +func (_c *Client_SendTransaction_Call) Run(run func(ctx context.Context, tx *types.Transaction, attempt *types.Attempt)) *Client_SendTransaction_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.Transaction), args[2].(*types.Attempt)) + }) + return _c +} + +func (_c *Client_SendTransaction_Call) Return(_a0 error) *Client_SendTransaction_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Client_SendTransaction_Call) RunAndReturn(run func(context.Context, *types.Transaction, *types.Attempt) error) *Client_SendTransaction_Call { + _c.Call.Return(run) + return _c +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/chains/evm/txm/mocks/keystore.go b/core/chains/evm/txm/mocks/keystore.go new file mode 100644 index 00000000000..c61ddb8db04 --- /dev/null +++ b/core/chains/evm/txm/mocks/keystore.go @@ -0,0 +1,98 @@ +// Code generated by mockery v2.46.3. DO NOT EDIT. + +package mocks + +import ( + context "context" + big "math/big" + + common "github.com/ethereum/go-ethereum/common" + + mock "github.com/stretchr/testify/mock" +) + +// Keystore is an autogenerated mock type for the Keystore type +type Keystore struct { + mock.Mock +} + +type Keystore_Expecter struct { + mock *mock.Mock +} + +func (_m *Keystore) EXPECT() *Keystore_Expecter { + return &Keystore_Expecter{mock: &_m.Mock} +} + +// EnabledAddressesForChain provides a mock function with given fields: ctx, chainID +func (_m *Keystore) EnabledAddressesForChain(ctx context.Context, chainID *big.Int) ([]common.Address, error) { + ret := _m.Called(ctx, chainID) + + if len(ret) == 0 { + panic("no return value specified for EnabledAddressesForChain") + } + + var r0 []common.Address + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *big.Int) ([]common.Address, error)); ok { + return rf(ctx, chainID) + } + if rf, ok := ret.Get(0).(func(context.Context, *big.Int) []common.Address); ok { + r0 = rf(ctx, chainID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]common.Address) + } + } + + 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 +} + +// Keystore_EnabledAddressesForChain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnabledAddressesForChain' +type Keystore_EnabledAddressesForChain_Call struct { + *mock.Call +} + +// EnabledAddressesForChain is a helper method to define mock.On call +// - ctx context.Context +// - chainID *big.Int +func (_e *Keystore_Expecter) EnabledAddressesForChain(ctx interface{}, chainID interface{}) *Keystore_EnabledAddressesForChain_Call { + return &Keystore_EnabledAddressesForChain_Call{Call: _e.mock.On("EnabledAddressesForChain", ctx, chainID)} +} + +func (_c *Keystore_EnabledAddressesForChain_Call) Run(run func(ctx context.Context, chainID *big.Int)) *Keystore_EnabledAddressesForChain_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*big.Int)) + }) + return _c +} + +func (_c *Keystore_EnabledAddressesForChain_Call) Return(addresses []common.Address, err error) *Keystore_EnabledAddressesForChain_Call { + _c.Call.Return(addresses, err) + return _c +} + +func (_c *Keystore_EnabledAddressesForChain_Call) RunAndReturn(run func(context.Context, *big.Int) ([]common.Address, error)) *Keystore_EnabledAddressesForChain_Call { + _c.Call.Return(run) + return _c +} + +// NewKeystore creates a new instance of Keystore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewKeystore(t interface { + mock.TestingT + Cleanup(func()) +}) *Keystore { + mock := &Keystore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/chains/evm/txm/mocks/tx_store.go b/core/chains/evm/txm/mocks/tx_store.go new file mode 100644 index 00000000000..866e095b1a1 --- /dev/null +++ b/core/chains/evm/txm/mocks/tx_store.go @@ -0,0 +1,647 @@ +// Code generated by mockery v2.46.3. DO NOT EDIT. + +package mocks + +import ( + context "context" + + common "github.com/ethereum/go-ethereum/common" + + mock "github.com/stretchr/testify/mock" + + types "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +// TxStore is an autogenerated mock type for the TxStore type +type TxStore struct { + mock.Mock +} + +type TxStore_Expecter struct { + mock *mock.Mock +} + +func (_m *TxStore) EXPECT() *TxStore_Expecter { + return &TxStore_Expecter{mock: &_m.Mock} +} + +// AbandonPendingTransactions provides a mock function with given fields: _a0, _a1 +func (_m *TxStore) AbandonPendingTransactions(_a0 context.Context, _a1 common.Address) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for AbandonPendingTransactions") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// TxStore_AbandonPendingTransactions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AbandonPendingTransactions' +type TxStore_AbandonPendingTransactions_Call struct { + *mock.Call +} + +// AbandonPendingTransactions is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 common.Address +func (_e *TxStore_Expecter) AbandonPendingTransactions(_a0 interface{}, _a1 interface{}) *TxStore_AbandonPendingTransactions_Call { + return &TxStore_AbandonPendingTransactions_Call{Call: _e.mock.On("AbandonPendingTransactions", _a0, _a1)} +} + +func (_c *TxStore_AbandonPendingTransactions_Call) Run(run func(_a0 context.Context, _a1 common.Address)) *TxStore_AbandonPendingTransactions_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address)) + }) + return _c +} + +func (_c *TxStore_AbandonPendingTransactions_Call) Return(_a0 error) *TxStore_AbandonPendingTransactions_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *TxStore_AbandonPendingTransactions_Call) RunAndReturn(run func(context.Context, common.Address) error) *TxStore_AbandonPendingTransactions_Call { + _c.Call.Return(run) + return _c +} + +// AppendAttemptToTransaction provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *TxStore) AppendAttemptToTransaction(_a0 context.Context, _a1 uint64, _a2 common.Address, _a3 *types.Attempt) error { + ret := _m.Called(_a0, _a1, _a2, _a3) + + if len(ret) == 0 { + panic("no return value specified for AppendAttemptToTransaction") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Address, *types.Attempt) error); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// TxStore_AppendAttemptToTransaction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppendAttemptToTransaction' +type TxStore_AppendAttemptToTransaction_Call struct { + *mock.Call +} + +// AppendAttemptToTransaction is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 uint64 +// - _a2 common.Address +// - _a3 *types.Attempt +func (_e *TxStore_Expecter) AppendAttemptToTransaction(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 interface{}) *TxStore_AppendAttemptToTransaction_Call { + return &TxStore_AppendAttemptToTransaction_Call{Call: _e.mock.On("AppendAttemptToTransaction", _a0, _a1, _a2, _a3)} +} + +func (_c *TxStore_AppendAttemptToTransaction_Call) Run(run func(_a0 context.Context, _a1 uint64, _a2 common.Address, _a3 *types.Attempt)) *TxStore_AppendAttemptToTransaction_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Address), args[3].(*types.Attempt)) + }) + return _c +} + +func (_c *TxStore_AppendAttemptToTransaction_Call) Return(_a0 error) *TxStore_AppendAttemptToTransaction_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *TxStore_AppendAttemptToTransaction_Call) RunAndReturn(run func(context.Context, uint64, common.Address, *types.Attempt) error) *TxStore_AppendAttemptToTransaction_Call { + _c.Call.Return(run) + return _c +} + +// CreateEmptyUnconfirmedTransaction provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *TxStore) CreateEmptyUnconfirmedTransaction(_a0 context.Context, _a1 common.Address, _a2 uint64, _a3 uint64) (*types.Transaction, error) { + ret := _m.Called(_a0, _a1, _a2, _a3) + + if len(ret) == 0 { + panic("no return value specified for CreateEmptyUnconfirmedTransaction") + } + + var r0 *types.Transaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, uint64, uint64) (*types.Transaction, error)); ok { + return rf(_a0, _a1, _a2, _a3) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address, uint64, uint64) *types.Transaction); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Transaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address, uint64, uint64) error); ok { + r1 = rf(_a0, _a1, _a2, _a3) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TxStore_CreateEmptyUnconfirmedTransaction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEmptyUnconfirmedTransaction' +type TxStore_CreateEmptyUnconfirmedTransaction_Call struct { + *mock.Call +} + +// CreateEmptyUnconfirmedTransaction is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 common.Address +// - _a2 uint64 +// - _a3 uint64 +func (_e *TxStore_Expecter) CreateEmptyUnconfirmedTransaction(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 interface{}) *TxStore_CreateEmptyUnconfirmedTransaction_Call { + return &TxStore_CreateEmptyUnconfirmedTransaction_Call{Call: _e.mock.On("CreateEmptyUnconfirmedTransaction", _a0, _a1, _a2, _a3)} +} + +func (_c *TxStore_CreateEmptyUnconfirmedTransaction_Call) Run(run func(_a0 context.Context, _a1 common.Address, _a2 uint64, _a3 uint64)) *TxStore_CreateEmptyUnconfirmedTransaction_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address), args[2].(uint64), args[3].(uint64)) + }) + return _c +} + +func (_c *TxStore_CreateEmptyUnconfirmedTransaction_Call) Return(_a0 *types.Transaction, _a1 error) *TxStore_CreateEmptyUnconfirmedTransaction_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *TxStore_CreateEmptyUnconfirmedTransaction_Call) RunAndReturn(run func(context.Context, common.Address, uint64, uint64) (*types.Transaction, error)) *TxStore_CreateEmptyUnconfirmedTransaction_Call { + _c.Call.Return(run) + return _c +} + +// CreateTransaction provides a mock function with given fields: _a0, _a1 +func (_m *TxStore) CreateTransaction(_a0 context.Context, _a1 *types.TxRequest) (*types.Transaction, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for CreateTransaction") + } + + var r0 *types.Transaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *types.TxRequest) (*types.Transaction, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *types.TxRequest) *types.Transaction); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Transaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *types.TxRequest) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TxStore_CreateTransaction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTransaction' +type TxStore_CreateTransaction_Call struct { + *mock.Call +} + +// CreateTransaction is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 *types.TxRequest +func (_e *TxStore_Expecter) CreateTransaction(_a0 interface{}, _a1 interface{}) *TxStore_CreateTransaction_Call { + return &TxStore_CreateTransaction_Call{Call: _e.mock.On("CreateTransaction", _a0, _a1)} +} + +func (_c *TxStore_CreateTransaction_Call) Run(run func(_a0 context.Context, _a1 *types.TxRequest)) *TxStore_CreateTransaction_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.TxRequest)) + }) + return _c +} + +func (_c *TxStore_CreateTransaction_Call) Return(_a0 *types.Transaction, _a1 error) *TxStore_CreateTransaction_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *TxStore_CreateTransaction_Call) RunAndReturn(run func(context.Context, *types.TxRequest) (*types.Transaction, error)) *TxStore_CreateTransaction_Call { + _c.Call.Return(run) + return _c +} + +// DeleteAttemptForUnconfirmedTx provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *TxStore) DeleteAttemptForUnconfirmedTx(_a0 context.Context, _a1 uint64, _a2 *types.Attempt, _a3 common.Address) error { + ret := _m.Called(_a0, _a1, _a2, _a3) + + if len(ret) == 0 { + panic("no return value specified for DeleteAttemptForUnconfirmedTx") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, *types.Attempt, common.Address) error); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// TxStore_DeleteAttemptForUnconfirmedTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttemptForUnconfirmedTx' +type TxStore_DeleteAttemptForUnconfirmedTx_Call struct { + *mock.Call +} + +// DeleteAttemptForUnconfirmedTx is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 uint64 +// - _a2 *types.Attempt +// - _a3 common.Address +func (_e *TxStore_Expecter) DeleteAttemptForUnconfirmedTx(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 interface{}) *TxStore_DeleteAttemptForUnconfirmedTx_Call { + return &TxStore_DeleteAttemptForUnconfirmedTx_Call{Call: _e.mock.On("DeleteAttemptForUnconfirmedTx", _a0, _a1, _a2, _a3)} +} + +func (_c *TxStore_DeleteAttemptForUnconfirmedTx_Call) Run(run func(_a0 context.Context, _a1 uint64, _a2 *types.Attempt, _a3 common.Address)) *TxStore_DeleteAttemptForUnconfirmedTx_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(*types.Attempt), args[3].(common.Address)) + }) + return _c +} + +func (_c *TxStore_DeleteAttemptForUnconfirmedTx_Call) Return(_a0 error) *TxStore_DeleteAttemptForUnconfirmedTx_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *TxStore_DeleteAttemptForUnconfirmedTx_Call) RunAndReturn(run func(context.Context, uint64, *types.Attempt, common.Address) error) *TxStore_DeleteAttemptForUnconfirmedTx_Call { + _c.Call.Return(run) + return _c +} + +// FetchUnconfirmedTransactionAtNonceWithCount provides a mock function with given fields: _a0, _a1, _a2 +func (_m *TxStore) FetchUnconfirmedTransactionAtNonceWithCount(_a0 context.Context, _a1 uint64, _a2 common.Address) (*types.Transaction, int, error) { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for FetchUnconfirmedTransactionAtNonceWithCount") + } + + var r0 *types.Transaction + var r1 int + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Address) (*types.Transaction, int, error)); ok { + return rf(_a0, _a1, _a2) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Address) *types.Transaction); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Transaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64, common.Address) int); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Get(1).(int) + } + + if rf, ok := ret.Get(2).(func(context.Context, uint64, common.Address) error); ok { + r2 = rf(_a0, _a1, _a2) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchUnconfirmedTransactionAtNonceWithCount' +type TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call struct { + *mock.Call +} + +// FetchUnconfirmedTransactionAtNonceWithCount is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 uint64 +// - _a2 common.Address +func (_e *TxStore_Expecter) FetchUnconfirmedTransactionAtNonceWithCount(_a0 interface{}, _a1 interface{}, _a2 interface{}) *TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call { + return &TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call{Call: _e.mock.On("FetchUnconfirmedTransactionAtNonceWithCount", _a0, _a1, _a2)} +} + +func (_c *TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call) Run(run func(_a0 context.Context, _a1 uint64, _a2 common.Address)) *TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Address)) + }) + return _c +} + +func (_c *TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call) Return(_a0 *types.Transaction, _a1 int, _a2 error) *TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call) RunAndReturn(run func(context.Context, uint64, common.Address) (*types.Transaction, int, error)) *TxStore_FetchUnconfirmedTransactionAtNonceWithCount_Call { + _c.Call.Return(run) + return _c +} + +// MarkTransactionsConfirmed provides a mock function with given fields: _a0, _a1, _a2 +func (_m *TxStore) MarkTransactionsConfirmed(_a0 context.Context, _a1 uint64, _a2 common.Address) ([]uint64, []uint64, error) { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for MarkTransactionsConfirmed") + } + + var r0 []uint64 + var r1 []uint64 + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Address) ([]uint64, []uint64, error)); ok { + return rf(_a0, _a1, _a2) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Address) []uint64); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]uint64) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64, common.Address) []uint64); ok { + r1 = rf(_a0, _a1, _a2) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]uint64) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, uint64, common.Address) error); ok { + r2 = rf(_a0, _a1, _a2) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// TxStore_MarkTransactionsConfirmed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkTransactionsConfirmed' +type TxStore_MarkTransactionsConfirmed_Call struct { + *mock.Call +} + +// MarkTransactionsConfirmed is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 uint64 +// - _a2 common.Address +func (_e *TxStore_Expecter) MarkTransactionsConfirmed(_a0 interface{}, _a1 interface{}, _a2 interface{}) *TxStore_MarkTransactionsConfirmed_Call { + return &TxStore_MarkTransactionsConfirmed_Call{Call: _e.mock.On("MarkTransactionsConfirmed", _a0, _a1, _a2)} +} + +func (_c *TxStore_MarkTransactionsConfirmed_Call) Run(run func(_a0 context.Context, _a1 uint64, _a2 common.Address)) *TxStore_MarkTransactionsConfirmed_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Address)) + }) + return _c +} + +func (_c *TxStore_MarkTransactionsConfirmed_Call) Return(_a0 []uint64, _a1 []uint64, _a2 error) *TxStore_MarkTransactionsConfirmed_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *TxStore_MarkTransactionsConfirmed_Call) RunAndReturn(run func(context.Context, uint64, common.Address) ([]uint64, []uint64, error)) *TxStore_MarkTransactionsConfirmed_Call { + _c.Call.Return(run) + return _c +} + +// MarkTxFatal provides a mock function with given fields: _a0, _a1, _a2 +func (_m *TxStore) MarkTxFatal(_a0 context.Context, _a1 *types.Transaction, _a2 common.Address) error { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for MarkTxFatal") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *types.Transaction, common.Address) error); ok { + r0 = rf(_a0, _a1, _a2) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// TxStore_MarkTxFatal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkTxFatal' +type TxStore_MarkTxFatal_Call struct { + *mock.Call +} + +// MarkTxFatal is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 *types.Transaction +// - _a2 common.Address +func (_e *TxStore_Expecter) MarkTxFatal(_a0 interface{}, _a1 interface{}, _a2 interface{}) *TxStore_MarkTxFatal_Call { + return &TxStore_MarkTxFatal_Call{Call: _e.mock.On("MarkTxFatal", _a0, _a1, _a2)} +} + +func (_c *TxStore_MarkTxFatal_Call) Run(run func(_a0 context.Context, _a1 *types.Transaction, _a2 common.Address)) *TxStore_MarkTxFatal_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.Transaction), args[2].(common.Address)) + }) + return _c +} + +func (_c *TxStore_MarkTxFatal_Call) Return(_a0 error) *TxStore_MarkTxFatal_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *TxStore_MarkTxFatal_Call) RunAndReturn(run func(context.Context, *types.Transaction, common.Address) error) *TxStore_MarkTxFatal_Call { + _c.Call.Return(run) + return _c +} + +// MarkUnconfirmedTransactionPurgeable provides a mock function with given fields: _a0, _a1, _a2 +func (_m *TxStore) MarkUnconfirmedTransactionPurgeable(_a0 context.Context, _a1 uint64, _a2 common.Address) error { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for MarkUnconfirmedTransactionPurgeable") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Address) error); ok { + r0 = rf(_a0, _a1, _a2) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// TxStore_MarkUnconfirmedTransactionPurgeable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkUnconfirmedTransactionPurgeable' +type TxStore_MarkUnconfirmedTransactionPurgeable_Call struct { + *mock.Call +} + +// MarkUnconfirmedTransactionPurgeable is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 uint64 +// - _a2 common.Address +func (_e *TxStore_Expecter) MarkUnconfirmedTransactionPurgeable(_a0 interface{}, _a1 interface{}, _a2 interface{}) *TxStore_MarkUnconfirmedTransactionPurgeable_Call { + return &TxStore_MarkUnconfirmedTransactionPurgeable_Call{Call: _e.mock.On("MarkUnconfirmedTransactionPurgeable", _a0, _a1, _a2)} +} + +func (_c *TxStore_MarkUnconfirmedTransactionPurgeable_Call) Run(run func(_a0 context.Context, _a1 uint64, _a2 common.Address)) *TxStore_MarkUnconfirmedTransactionPurgeable_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Address)) + }) + return _c +} + +func (_c *TxStore_MarkUnconfirmedTransactionPurgeable_Call) Return(_a0 error) *TxStore_MarkUnconfirmedTransactionPurgeable_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *TxStore_MarkUnconfirmedTransactionPurgeable_Call) RunAndReturn(run func(context.Context, uint64, common.Address) error) *TxStore_MarkUnconfirmedTransactionPurgeable_Call { + _c.Call.Return(run) + return _c +} + +// UpdateTransactionBroadcast provides a mock function with given fields: _a0, _a1, _a2, _a3, _a4 +func (_m *TxStore) UpdateTransactionBroadcast(_a0 context.Context, _a1 uint64, _a2 uint64, _a3 common.Hash, _a4 common.Address) error { + ret := _m.Called(_a0, _a1, _a2, _a3, _a4) + + if len(ret) == 0 { + panic("no return value specified for UpdateTransactionBroadcast") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, uint64, common.Hash, common.Address) error); ok { + r0 = rf(_a0, _a1, _a2, _a3, _a4) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// TxStore_UpdateTransactionBroadcast_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateTransactionBroadcast' +type TxStore_UpdateTransactionBroadcast_Call struct { + *mock.Call +} + +// UpdateTransactionBroadcast is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 uint64 +// - _a2 uint64 +// - _a3 common.Hash +// - _a4 common.Address +func (_e *TxStore_Expecter) UpdateTransactionBroadcast(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 interface{}, _a4 interface{}) *TxStore_UpdateTransactionBroadcast_Call { + return &TxStore_UpdateTransactionBroadcast_Call{Call: _e.mock.On("UpdateTransactionBroadcast", _a0, _a1, _a2, _a3, _a4)} +} + +func (_c *TxStore_UpdateTransactionBroadcast_Call) Run(run func(_a0 context.Context, _a1 uint64, _a2 uint64, _a3 common.Hash, _a4 common.Address)) *TxStore_UpdateTransactionBroadcast_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(uint64), args[3].(common.Hash), args[4].(common.Address)) + }) + return _c +} + +func (_c *TxStore_UpdateTransactionBroadcast_Call) Return(_a0 error) *TxStore_UpdateTransactionBroadcast_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *TxStore_UpdateTransactionBroadcast_Call) RunAndReturn(run func(context.Context, uint64, uint64, common.Hash, common.Address) error) *TxStore_UpdateTransactionBroadcast_Call { + _c.Call.Return(run) + return _c +} + +// UpdateUnstartedTransactionWithNonce provides a mock function with given fields: _a0, _a1, _a2 +func (_m *TxStore) UpdateUnstartedTransactionWithNonce(_a0 context.Context, _a1 common.Address, _a2 uint64) (*types.Transaction, error) { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for UpdateUnstartedTransactionWithNonce") + } + + var r0 *types.Transaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, uint64) (*types.Transaction, error)); ok { + return rf(_a0, _a1, _a2) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address, uint64) *types.Transaction); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Transaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address, uint64) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TxStore_UpdateUnstartedTransactionWithNonce_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUnstartedTransactionWithNonce' +type TxStore_UpdateUnstartedTransactionWithNonce_Call struct { + *mock.Call +} + +// UpdateUnstartedTransactionWithNonce is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 common.Address +// - _a2 uint64 +func (_e *TxStore_Expecter) UpdateUnstartedTransactionWithNonce(_a0 interface{}, _a1 interface{}, _a2 interface{}) *TxStore_UpdateUnstartedTransactionWithNonce_Call { + return &TxStore_UpdateUnstartedTransactionWithNonce_Call{Call: _e.mock.On("UpdateUnstartedTransactionWithNonce", _a0, _a1, _a2)} +} + +func (_c *TxStore_UpdateUnstartedTransactionWithNonce_Call) Run(run func(_a0 context.Context, _a1 common.Address, _a2 uint64)) *TxStore_UpdateUnstartedTransactionWithNonce_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address), args[2].(uint64)) + }) + return _c +} + +func (_c *TxStore_UpdateUnstartedTransactionWithNonce_Call) Return(_a0 *types.Transaction, _a1 error) *TxStore_UpdateUnstartedTransactionWithNonce_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *TxStore_UpdateUnstartedTransactionWithNonce_Call) RunAndReturn(run func(context.Context, common.Address, uint64) (*types.Transaction, error)) *TxStore_UpdateUnstartedTransactionWithNonce_Call { + _c.Call.Return(run) + return _c +} + +// NewTxStore creates a new instance of TxStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewTxStore(t interface { + mock.TestingT + Cleanup(func()) +}) *TxStore { + mock := &TxStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/chains/evm/txm/orchestrator.go b/core/chains/evm/txm/orchestrator.go new file mode 100644 index 00000000000..8915a534253 --- /dev/null +++ b/core/chains/evm/txm/orchestrator.go @@ -0,0 +1,365 @@ +package txm + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/google/uuid" + nullv4 "gopkg.in/guregu/null.v4" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" + commontypes "github.com/smartcontractkit/chainlink-common/pkg/types" + "github.com/smartcontractkit/chainlink-common/pkg/utils" + + "github.com/smartcontractkit/chainlink/v2/common/txmgr" + txmgrtypes "github.com/smartcontractkit/chainlink/v2/common/txmgr/types" + "github.com/smartcontractkit/chainlink/v2/common/types" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/forwarders" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" + txmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" + evmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/types" +) + +type OrchestratorTxStore interface { + Add(addresses ...common.Address) error + FetchUnconfirmedTransactionAtNonceWithCount(context.Context, uint64, common.Address) (*txmtypes.Transaction, int, error) + FindTxWithIdempotencyKey(context.Context, *string) (*txmtypes.Transaction, error) +} + +type OrchestratorKeystore interface { + EnabledAddressesForChain(ctx context.Context, chainID *big.Int) (addresses []common.Address, err error) +} + +type OrchestratorAttemptBuilder[ + BLOCK_HASH types.Hashable, + HEAD types.Head[BLOCK_HASH], +] interface { + services.Service + OnNewLongestChain(ctx context.Context, head HEAD) +} + +// Generics are necessary to keep TXMv2 backwards compatible +type Orchestrator[ + BLOCK_HASH types.Hashable, + HEAD types.Head[BLOCK_HASH], +] struct { + services.StateMachine + lggr logger.SugaredLogger + chainID *big.Int + txm *Txm + txStore OrchestratorTxStore + fwdMgr *forwarders.FwdMgr + keystore OrchestratorKeystore + attemptBuilder OrchestratorAttemptBuilder[BLOCK_HASH, HEAD] + resumeCallback txmgr.ResumeCallback +} + +func NewTxmOrchestrator[BLOCK_HASH types.Hashable, HEAD types.Head[BLOCK_HASH]]( + lggr logger.Logger, + chainID *big.Int, + txm *Txm, + txStore OrchestratorTxStore, + fwdMgr *forwarders.FwdMgr, + keystore OrchestratorKeystore, + attemptBuilder OrchestratorAttemptBuilder[BLOCK_HASH, HEAD], +) *Orchestrator[BLOCK_HASH, HEAD] { + return &Orchestrator[BLOCK_HASH, HEAD]{ + lggr: logger.Sugared(logger.Named(lggr, "Orchestrator")), + chainID: chainID, + txm: txm, + txStore: txStore, + keystore: keystore, + attemptBuilder: attemptBuilder, + fwdMgr: fwdMgr, + } +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) Start(ctx context.Context) error { + return o.StartOnce("Orchestrator", func() error { + var ms services.MultiStart + if err := ms.Start(ctx, o.attemptBuilder); err != nil { + // TODO: hacky fix for DualBroadcast + if !strings.Contains(err.Error(), "already been started once") { + return fmt.Errorf("Orchestrator: AttemptBuilder failed to start: %w", err) + } + } + addresses, err := o.keystore.EnabledAddressesForChain(ctx, o.chainID) + if err != nil { + return err + } + for _, address := range addresses { + err := o.txStore.Add(address) + if err != nil { + return err + } + } + if err := ms.Start(ctx, o.txm); err != nil { + return fmt.Errorf("Orchestrator: Txm failed to start: %w", err) + } + if o.fwdMgr != nil { + if err := ms.Start(ctx, o.fwdMgr); err != nil { + return fmt.Errorf("Orchestrator: ForwarderManager failed to start: %w", err) + } + } + return nil + }) +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) Close() (merr error) { + return o.StopOnce("Orchestrator", func() error { + if o.fwdMgr != nil { + if err := o.fwdMgr.Close(); err != nil { + merr = errors.Join(merr, fmt.Errorf("Orchestrator failed to stop ForwarderManager: %w", err)) + } + } + if err := o.attemptBuilder.Close(); err != nil { + // TODO: hacky fix for DualBroadcast + if !strings.Contains(err.Error(), "already been stopped") { + merr = errors.Join(merr, fmt.Errorf("Orchestrator failed to stop AttemptBuilder: %w", err)) + } + } + if err := o.txm.Close(); err != nil { + merr = errors.Join(merr, fmt.Errorf("Orchestrator failed to stop Txm: %w", err)) + } + return merr + }) +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) Trigger(addr common.Address) { + o.txm.Trigger(addr) +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) Name() string { + return o.lggr.Name() +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) HealthReport() map[string]error { + return map[string]error{o.Name(): o.Healthy()} +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) RegisterResumeCallback(fn txmgr.ResumeCallback) { + o.resumeCallback = fn +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) Reset(addr common.Address, abandon bool) error { + ok := o.IfStarted(func() { + if err := o.txm.Abandon(addr); err != nil { + o.lggr.Error(err) + } + }) + if !ok { + return errors.New("Orchestrator not started yet") + } + return nil +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) OnNewLongestChain(ctx context.Context, head HEAD) { + ok := o.IfStarted(func() { + o.attemptBuilder.OnNewLongestChain(ctx, head) + }) + if !ok { + o.lggr.Debugw("Not started; ignoring head", "head", head, "state", o.State()) + } +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) CreateTransaction(ctx context.Context, request txmgrtypes.TxRequest[common.Address, common.Hash]) (tx txmgrtypes.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], err error) { + var wrappedTx *txmtypes.Transaction + wrappedTx, err = o.txStore.FindTxWithIdempotencyKey(ctx, request.IdempotencyKey) + if err != nil { + return + } + + if wrappedTx != nil { + o.lggr.Infof("Found Tx with IdempotencyKey: %v. Returning existing Tx without creating a new one.", *wrappedTx.IdempotencyKey) + } else { + var pipelineTaskRunID uuid.NullUUID + if request.PipelineTaskRunID != nil { + pipelineTaskRunID.UUID = *request.PipelineTaskRunID + pipelineTaskRunID.Valid = true + } + + if o.fwdMgr != nil && (!utils.IsZero(request.ForwarderAddress)) { + fwdPayload, fwdErr := o.fwdMgr.ConvertPayload(request.ToAddress, request.EncodedPayload) + if fwdErr == nil { + // Handling meta not set at caller. + if request.Meta != nil { + request.Meta.FwdrDestAddress = &request.ToAddress + } else { + request.Meta = &txmgrtypes.TxMeta[common.Address, common.Hash]{ + FwdrDestAddress: &request.ToAddress, + } + } + request.ToAddress = request.ForwarderAddress + request.EncodedPayload = fwdPayload + } else { + o.lggr.Errorf("Failed to use forwarder set upstream: %v", fwdErr.Error()) + } + } + + var meta *sqlutil.JSON + if request.Meta != nil { + raw, mErr := json.Marshal(request.Meta) + if mErr != nil { + return tx, mErr + } + m := sqlutil.JSON(raw) + meta = &m + } + + wrappedTxRequest := &txmtypes.TxRequest{ + IdempotencyKey: request.IdempotencyKey, + ChainID: o.chainID, + FromAddress: request.FromAddress, + ToAddress: request.ToAddress, + Value: &request.Value, + Data: request.EncodedPayload, + SpecifiedGasLimit: request.FeeLimit, + Meta: meta, + ForwarderAddress: request.ForwarderAddress, + + PipelineTaskRunID: pipelineTaskRunID, + MinConfirmations: request.MinConfirmations, + SignalCallback: request.SignalCallback, + } + + wrappedTx, err = o.txm.CreateTransaction(ctx, wrappedTxRequest) + if err != nil { + return + } + o.txm.Trigger(request.FromAddress) + } + + if wrappedTx.ID > math.MaxInt64 { + return tx, fmt.Errorf("overflow for int64: %d", wrappedTx.ID) + } + + tx = txmgrtypes.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee]{ + //nolint:gosec // disable G115 + ID: int64(wrappedTx.ID), + IdempotencyKey: wrappedTx.IdempotencyKey, + FromAddress: wrappedTx.FromAddress, + ToAddress: wrappedTx.ToAddress, + EncodedPayload: wrappedTx.Data, + Value: *wrappedTx.Value, + FeeLimit: wrappedTx.SpecifiedGasLimit, + CreatedAt: wrappedTx.CreatedAt, + Meta: wrappedTx.Meta, + // Subject: wrappedTx.Subject, + + // TransmitChecker: wrappedTx.TransmitChecker, + ChainID: wrappedTx.ChainID, + + PipelineTaskRunID: wrappedTx.PipelineTaskRunID, + MinConfirmations: wrappedTx.MinConfirmations, + SignalCallback: wrappedTx.SignalCallback, + CallbackCompleted: wrappedTx.CallbackCompleted, + } + return +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) CountTransactionsByState(ctx context.Context, state txmgrtypes.TxState) (uint32, error) { + addresses, err := o.keystore.EnabledAddressesForChain(ctx, o.chainID) + if err != nil { + return 0, err + } + total := 0 + for _, address := range addresses { + _, count, err := o.txStore.FetchUnconfirmedTransactionAtNonceWithCount(ctx, 0, address) + if err != nil { + return 0, err + } + total += count + } + + //nolint:gosec // disable G115 + return uint32(total), nil +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) FindEarliestUnconfirmedBroadcastTime(ctx context.Context) (time nullv4.Time, err error) { + return +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) FindEarliestUnconfirmedTxAttemptBlock(ctx context.Context) (time nullv4.Int, err error) { + return +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) FindTxesByMetaFieldAndStates(ctx context.Context, metaField string, metaValue string, states []txmgrtypes.TxState, chainID *big.Int) (txs []*txmgrtypes.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], err error) { + return +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) FindTxesWithMetaFieldByStates(ctx context.Context, metaField string, states []txmgrtypes.TxState, chainID *big.Int) (txs []*txmgrtypes.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], err error) { + return +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) FindTxesWithMetaFieldByReceiptBlockNum(ctx context.Context, metaField string, blockNum int64, chainID *big.Int) (txs []*txmgrtypes.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], err error) { + return +} + +//nolint:revive // keep API backwards compatible +func (o *Orchestrator[BLOCK_HASH, HEAD]) FindTxesWithAttemptsAndReceiptsByIdsAndState(ctx context.Context, ids []int64, states []txmgrtypes.TxState, chainID *big.Int) (txs []*txmgrtypes.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], err error) { + return +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) GetForwarderForEOA(ctx context.Context, eoa common.Address) (forwarder common.Address, err error) { + if o.fwdMgr != nil { + forwarder, err = o.fwdMgr.ForwarderFor(ctx, eoa) + } + return +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) GetForwarderForEOAOCR2Feeds(ctx context.Context, eoa, ocr2AggregatorID common.Address) (forwarder common.Address, err error) { + if o.fwdMgr != nil { + forwarder, err = o.fwdMgr.ForwarderForOCR2Feeds(ctx, eoa, ocr2AggregatorID) + } + return +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) GetTransactionStatus(ctx context.Context, transactionID string) (status commontypes.TransactionStatus, err error) { + // Loads attempts and receipts in the transaction + tx, err := o.txStore.FindTxWithIdempotencyKey(ctx, &transactionID) + if err != nil || tx == nil { + return status, fmt.Errorf("failed to find transaction with IdempotencyKey %s: %w", transactionID, err) + } + + switch tx.State { + case txmtypes.TxUnconfirmed: + return commontypes.Pending, nil + case txmtypes.TxConfirmed: + // Return unconfirmed for confirmed transactions because they are not yet finalized + return commontypes.Unconfirmed, nil + case txmtypes.TxFinalized: + return commontypes.Finalized, nil + case txmtypes.TxFatalError: + return commontypes.Fatal, nil + default: + return commontypes.Unknown, nil + } +} + +func (o *Orchestrator[BLOCK_HASH, HEAD]) SendNativeToken(ctx context.Context, chainID *big.Int, from, to common.Address, value big.Int, gasLimit uint64) (tx txmgrtypes.Tx[*big.Int, common.Address, common.Hash, common.Hash, evmtypes.Nonce, gas.EvmFee], err error) { + txRequest := txmgrtypes.TxRequest[common.Address, common.Hash]{ + FromAddress: from, + ToAddress: to, + EncodedPayload: []byte{}, + Value: value, + FeeLimit: gasLimit, + //Strategy: NewSendEveryStrategy(), + } + + tx, err = o.CreateTransaction(ctx, txRequest) + if err != nil { + return + } + + // Trigger the Txm to check for new transaction + o.txm.Trigger(from) + return tx, err +} diff --git a/core/chains/evm/txm/storage/inmemory_store.go b/core/chains/evm/txm/storage/inmemory_store.go new file mode 100644 index 00000000000..857f3656ddd --- /dev/null +++ b/core/chains/evm/txm/storage/inmemory_store.go @@ -0,0 +1,324 @@ +package storage + +import ( + "errors" + "fmt" + "math/big" + "sort" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +const ( + maxQueuedTransactions = 250 + pruneSubset = 3 +) + +type InMemoryStore struct { + sync.RWMutex + lggr logger.Logger + txIDCount uint64 + address common.Address + chainID *big.Int + + UnstartedTransactions []*types.Transaction + UnconfirmedTransactions map[uint64]*types.Transaction + ConfirmedTransactions map[uint64]*types.Transaction + FatalTransactions []*types.Transaction + + Transactions map[uint64]*types.Transaction +} + +func NewInMemoryStore(lggr logger.Logger, address common.Address, chainID *big.Int) *InMemoryStore { + return &InMemoryStore{ + lggr: logger.Named(lggr, "InMemoryStore"), + address: address, + chainID: chainID, + UnconfirmedTransactions: make(map[uint64]*types.Transaction), + ConfirmedTransactions: make(map[uint64]*types.Transaction), + Transactions: make(map[uint64]*types.Transaction), + } +} + +func (m *InMemoryStore) AbandonPendingTransactions() { + m.Lock() + defer m.Unlock() + + for _, tx := range m.UnstartedTransactions { + tx.State = types.TxFatalError + } + m.FatalTransactions = m.UnstartedTransactions + m.UnstartedTransactions = []*types.Transaction{} + + for _, tx := range m.UnconfirmedTransactions { + tx.State = types.TxFatalError + m.FatalTransactions = append(m.FatalTransactions, tx) + } + m.UnconfirmedTransactions = make(map[uint64]*types.Transaction) +} + +func (m *InMemoryStore) AppendAttemptToTransaction(txNonce uint64, attempt *types.Attempt) error { + m.Lock() + defer m.Unlock() + + tx, exists := m.UnconfirmedTransactions[txNonce] + if !exists { + return fmt.Errorf("unconfirmed tx was not found for nonce: %d - txID: %v", txNonce, attempt.TxID) + } + + if tx.ID != attempt.TxID { + return fmt.Errorf("unconfirmed tx with nonce exists but attempt points to a different txID. Found Tx: %v - txID: %v", m.UnconfirmedTransactions[txNonce], attempt.TxID) + } + + attempt.CreatedAt = time.Now() + attempt.ID = uint64(len(tx.Attempts)) // Attempts are not collectively tracked by the in-memory store so attemptIDs are not unique between transactions and can be reused. + tx.AttemptCount++ + m.UnconfirmedTransactions[txNonce].Attempts = append(m.UnconfirmedTransactions[txNonce].Attempts, attempt.DeepCopy()) + + return nil +} + +func (m *InMemoryStore) CountUnstartedTransactions() int { + m.RLock() + defer m.RUnlock() + + return len(m.UnstartedTransactions) +} + +func (m *InMemoryStore) CreateEmptyUnconfirmedTransaction(nonce uint64, gasLimit uint64) (*types.Transaction, error) { + m.Lock() + defer m.Unlock() + + m.txIDCount++ + emptyTx := &types.Transaction{ + ID: m.txIDCount, + ChainID: m.chainID, + Nonce: nonce, + FromAddress: m.address, + ToAddress: common.Address{}, + Value: big.NewInt(0), + SpecifiedGasLimit: gasLimit, + CreatedAt: time.Now(), + State: types.TxUnconfirmed, + } + + if _, exists := m.UnconfirmedTransactions[nonce]; exists { + return nil, fmt.Errorf("an unconfirmed tx with the same nonce already exists: %v", m.UnconfirmedTransactions[nonce]) + } + + m.UnconfirmedTransactions[nonce] = emptyTx + m.Transactions[emptyTx.ID] = emptyTx + + return emptyTx.DeepCopy(), nil +} + +func (m *InMemoryStore) CreateTransaction(txRequest *types.TxRequest) *types.Transaction { + m.Lock() + defer m.Unlock() + + m.txIDCount++ + + tx := &types.Transaction{ + ID: m.txIDCount, + IdempotencyKey: txRequest.IdempotencyKey, + ChainID: m.chainID, + FromAddress: m.address, + ToAddress: txRequest.ToAddress, + Value: txRequest.Value, + Data: txRequest.Data, + SpecifiedGasLimit: txRequest.SpecifiedGasLimit, + CreatedAt: time.Now(), + State: types.TxUnstarted, + Meta: txRequest.Meta, + MinConfirmations: txRequest.MinConfirmations, + PipelineTaskRunID: txRequest.PipelineTaskRunID, + SignalCallback: txRequest.SignalCallback, + } + + if len(m.UnstartedTransactions) == maxQueuedTransactions { + m.lggr.Warnf("Unstarted transactions queue for address: %v reached max limit of: %d. Dropping oldest transaction: %v.", + m.address, maxQueuedTransactions, m.UnstartedTransactions[0]) + delete(m.Transactions, m.UnstartedTransactions[0].ID) + m.UnstartedTransactions = m.UnstartedTransactions[1:maxQueuedTransactions] + } + + txCopy := tx.DeepCopy() + m.Transactions[txCopy.ID] = txCopy + m.UnstartedTransactions = append(m.UnstartedTransactions, txCopy) + return tx +} + +func (m *InMemoryStore) FetchUnconfirmedTransactionAtNonceWithCount(latestNonce uint64) (txCopy *types.Transaction, unconfirmedCount int) { + m.RLock() + defer m.RUnlock() + + tx := m.UnconfirmedTransactions[latestNonce] + if tx != nil { + txCopy = tx.DeepCopy() + } + unconfirmedCount = len(m.UnconfirmedTransactions) + return +} + +func (m *InMemoryStore) MarkTransactionsConfirmed(latestNonce uint64) ([]uint64, []uint64) { + m.Lock() + defer m.Unlock() + + var confirmedTransactionIDs []uint64 + for _, tx := range m.UnconfirmedTransactions { + if tx.Nonce < latestNonce { + tx.State = types.TxConfirmed + confirmedTransactionIDs = append(confirmedTransactionIDs, tx.ID) + m.ConfirmedTransactions[tx.Nonce] = tx + delete(m.UnconfirmedTransactions, tx.Nonce) + } + } + + var unconfirmedTransactionIDs []uint64 + for _, tx := range m.ConfirmedTransactions { + if tx.Nonce >= latestNonce { + tx.State = types.TxUnconfirmed + tx.LastBroadcastAt = time.Time{} // Mark reorged transaction as if it wasn't broadcasted before + unconfirmedTransactionIDs = append(unconfirmedTransactionIDs, tx.ID) + m.UnconfirmedTransactions[tx.Nonce] = tx + delete(m.ConfirmedTransactions, tx.Nonce) + } + } + + if len(m.ConfirmedTransactions) >= maxQueuedTransactions { + prunedTxIDs := m.pruneConfirmedTransactions() + m.lggr.Debugf("Confirmed transactions map for address: %v reached max limit of: %d. Pruned 1/3 of the oldest confirmed transactions. TxIDs: %v", + m.address, maxQueuedTransactions, prunedTxIDs) + } + sort.Slice(confirmedTransactionIDs, func(i, j int) bool { return confirmedTransactionIDs[i] < confirmedTransactionIDs[j] }) + sort.Slice(unconfirmedTransactionIDs, func(i, j int) bool { return unconfirmedTransactionIDs[i] < unconfirmedTransactionIDs[j] }) + return confirmedTransactionIDs, unconfirmedTransactionIDs +} + +func (m *InMemoryStore) MarkUnconfirmedTransactionPurgeable(nonce uint64) error { + m.Lock() + defer m.Unlock() + + tx, exists := m.UnconfirmedTransactions[nonce] + if !exists { + return fmt.Errorf("unconfirmed tx with nonce: %d was not found", nonce) + } + + tx.IsPurgeable = true + + return nil +} + +func (m *InMemoryStore) UpdateTransactionBroadcast(txID uint64, txNonce uint64, attemptHash common.Hash) error { + m.Lock() + defer m.Unlock() + + unconfirmedTx, exists := m.UnconfirmedTransactions[txNonce] + if !exists { + return fmt.Errorf("unconfirmed tx was not found for nonce: %d - txID: %v", txNonce, txID) + } + + // Set the same time for both the tx and its attempt + now := time.Now() + unconfirmedTx.LastBroadcastAt = now + a, err := unconfirmedTx.FindAttemptByHash(attemptHash) + if err != nil { + return err + } + a.BroadcastAt = now + + return nil +} + +func (m *InMemoryStore) UpdateUnstartedTransactionWithNonce(nonce uint64) (*types.Transaction, error) { + m.Lock() + defer m.Unlock() + + if len(m.UnstartedTransactions) == 0 { + m.lggr.Debugf("Unstarted transactions queue is empty for address: %v", m.address) + return nil, nil + } + + if _, exists := m.UnconfirmedTransactions[nonce]; exists { + return nil, fmt.Errorf("an unconfirmed tx with the same nonce already exists: %v", m.UnconfirmedTransactions[nonce]) + } + + tx := m.UnstartedTransactions[0] + tx.Nonce = nonce + tx.State = types.TxUnconfirmed + + m.UnstartedTransactions = m.UnstartedTransactions[1:] + m.UnconfirmedTransactions[nonce] = tx + + return tx.DeepCopy(), nil +} + +// Shouldn't call lock because it's being called by a method that already has the lock +func (m *InMemoryStore) pruneConfirmedTransactions() []uint64 { + noncesToPrune := make([]uint64, 0, len(m.ConfirmedTransactions)) + for nonce := range m.ConfirmedTransactions { + noncesToPrune = append(noncesToPrune, nonce) + } + if len(noncesToPrune) == 0 { + return nil + } + sort.Slice(noncesToPrune, func(i, j int) bool { return noncesToPrune[i] < noncesToPrune[j] }) + minNonce := noncesToPrune[len(noncesToPrune)/pruneSubset] + + var txIDsToPrune []uint64 + for nonce, tx := range m.ConfirmedTransactions { + if nonce < minNonce { + txIDsToPrune = append(txIDsToPrune, tx.ID) + delete(m.Transactions, tx.ID) + delete(m.ConfirmedTransactions, nonce) + } + } + + sort.Slice(txIDsToPrune, func(i, j int) bool { return txIDsToPrune[i] < txIDsToPrune[j] }) + return txIDsToPrune +} + +// Error Handler +func (m *InMemoryStore) DeleteAttemptForUnconfirmedTx(transactionNonce uint64, attempt *types.Attempt) error { + m.Lock() + defer m.Unlock() + + tx, exists := m.UnconfirmedTransactions[transactionNonce] + if !exists { + return fmt.Errorf("unconfirmed tx was not found for nonce: %d - txID: %v", transactionNonce, attempt.TxID) + } + + for i, a := range tx.Attempts { + if a.Hash == attempt.Hash { + tx.Attempts = append(tx.Attempts[:i], tx.Attempts[i+1:]...) + return nil + } + } + + return fmt.Errorf("attempt with hash: %v for txID: %v was not found", attempt.Hash, attempt.TxID) +} + +func (m *InMemoryStore) MarkTxFatal(*types.Transaction) error { + return errors.New("not implemented") +} + +// Orchestrator +func (m *InMemoryStore) FindTxWithIdempotencyKey(idempotencyKey *string) *types.Transaction { + m.Lock() + defer m.Unlock() + + if idempotencyKey != nil { + for _, tx := range m.Transactions { + if tx.IdempotencyKey != nil && tx.IdempotencyKey == idempotencyKey { + return tx.DeepCopy() + } + } + } + + return nil +} diff --git a/core/chains/evm/txm/storage/inmemory_store_manager.go b/core/chains/evm/txm/storage/inmemory_store_manager.go new file mode 100644 index 00000000000..dfb777ab22b --- /dev/null +++ b/core/chains/evm/txm/storage/inmemory_store_manager.go @@ -0,0 +1,135 @@ +package storage + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +const StoreNotFoundForAddress string = "InMemoryStore for address: %v not found" + +type InMemoryStoreManager struct { + lggr logger.Logger + chainID *big.Int + InMemoryStoreMap map[common.Address]*InMemoryStore +} + +func NewInMemoryStoreManager(lggr logger.Logger, chainID *big.Int) *InMemoryStoreManager { + inMemoryStoreMap := make(map[common.Address]*InMemoryStore) + return &InMemoryStoreManager{ + lggr: lggr, + chainID: chainID, + InMemoryStoreMap: inMemoryStoreMap} +} + +func (m *InMemoryStoreManager) AbandonPendingTransactions(_ context.Context, fromAddress common.Address) error { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + store.AbandonPendingTransactions() + return nil + } + return fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) Add(addresses ...common.Address) error { + for _, address := range addresses { + if _, exists := m.InMemoryStoreMap[address]; exists { + return fmt.Errorf("address %v already exists in store manager", address) + } + m.InMemoryStoreMap[address] = NewInMemoryStore(m.lggr, address, m.chainID) + } + return nil +} + +func (m *InMemoryStoreManager) AppendAttemptToTransaction(_ context.Context, txNonce uint64, fromAddress common.Address, attempt *types.Attempt) error { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + return store.AppendAttemptToTransaction(txNonce, attempt) + } + return fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) CountUnstartedTransactions(fromAddress common.Address) (int, error) { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + return store.CountUnstartedTransactions(), nil + } + return 0, fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) CreateEmptyUnconfirmedTransaction(_ context.Context, fromAddress common.Address, nonce uint64, gasLimit uint64) (*types.Transaction, error) { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + return store.CreateEmptyUnconfirmedTransaction(nonce, gasLimit) + } + return nil, fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) CreateTransaction(_ context.Context, txRequest *types.TxRequest) (*types.Transaction, error) { + if store, exists := m.InMemoryStoreMap[txRequest.FromAddress]; exists { + return store.CreateTransaction(txRequest), nil + } + return nil, fmt.Errorf(StoreNotFoundForAddress, txRequest.FromAddress) +} + +func (m *InMemoryStoreManager) FetchUnconfirmedTransactionAtNonceWithCount(_ context.Context, nonce uint64, fromAddress common.Address) (tx *types.Transaction, count int, err error) { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + tx, count = store.FetchUnconfirmedTransactionAtNonceWithCount(nonce) + return + } + return nil, 0, fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) MarkTransactionsConfirmed(_ context.Context, nonce uint64, fromAddress common.Address) (confirmedTxIDs []uint64, unconfirmedTxIDs []uint64, err error) { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + confirmedTxIDs, unconfirmedTxIDs = store.MarkTransactionsConfirmed(nonce) + return + } + return nil, nil, fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) MarkUnconfirmedTransactionPurgeable(_ context.Context, nonce uint64, fromAddress common.Address) error { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + return store.MarkUnconfirmedTransactionPurgeable(nonce) + } + return fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) UpdateTransactionBroadcast(_ context.Context, txID uint64, nonce uint64, attemptHash common.Hash, fromAddress common.Address) error { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + return store.UpdateTransactionBroadcast(txID, nonce, attemptHash) + } + return fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) UpdateUnstartedTransactionWithNonce(_ context.Context, fromAddress common.Address, nonce uint64) (*types.Transaction, error) { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + return store.UpdateUnstartedTransactionWithNonce(nonce) + } + return nil, fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) DeleteAttemptForUnconfirmedTx(_ context.Context, nonce uint64, attempt *types.Attempt, fromAddress common.Address) error { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + return store.DeleteAttemptForUnconfirmedTx(nonce, attempt) + } + return fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) MarkTxFatal(_ context.Context, tx *types.Transaction, fromAddress common.Address) error { + if store, exists := m.InMemoryStoreMap[fromAddress]; exists { + return store.MarkTxFatal(tx) + } + return fmt.Errorf(StoreNotFoundForAddress, fromAddress) +} + +func (m *InMemoryStoreManager) FindTxWithIdempotencyKey(_ context.Context, idempotencyKey *string) (*types.Transaction, error) { + for _, store := range m.InMemoryStoreMap { + tx := store.FindTxWithIdempotencyKey(idempotencyKey) + if tx != nil { + return tx, nil + } + } + return nil, nil +} diff --git a/core/chains/evm/txm/storage/inmemory_store_manager_test.go b/core/chains/evm/txm/storage/inmemory_store_manager_test.go new file mode 100644 index 00000000000..e10870a9942 --- /dev/null +++ b/core/chains/evm/txm/storage/inmemory_store_manager_test.go @@ -0,0 +1,35 @@ +package storage + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/testutils" +) + +func TestAdd(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + m := NewInMemoryStoreManager(logger.Test(t), testutils.FixtureChainID) + // Adds a new address + err := m.Add(fromAddress) + assert.NoError(t, err) + assert.Len(t, m.InMemoryStoreMap, 1) + + // Fails if address exists + err = m.Add(fromAddress) + assert.Error(t, err) + + // Adds multiple addresses + fromAddress1 := testutils.NewAddress() + fromAddress2 := testutils.NewAddress() + addresses := []common.Address{fromAddress1, fromAddress2} + err = m.Add(addresses...) + assert.NoError(t, err) + assert.Len(t, m.InMemoryStoreMap, 3) +} diff --git a/core/chains/evm/txm/storage/inmemory_store_test.go b/core/chains/evm/txm/storage/inmemory_store_test.go new file mode 100644 index 00000000000..68c791cb400 --- /dev/null +++ b/core/chains/evm/txm/storage/inmemory_store_test.go @@ -0,0 +1,476 @@ +package storage + +import ( + "fmt" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/testutils" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +func TestAbandonPendingTransactions(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + t.Run("abandons unstarted and unconfirmed transactions", func(t *testing.T) { + // Unstarted + tx1 := insertUnstartedTransaction(m) + tx2 := insertUnstartedTransaction(m) + + // Unconfirmed + tx3, err := insertUnconfirmedTransaction(m, 3) + assert.NoError(t, err) + tx4, err := insertUnconfirmedTransaction(m, 4) + assert.NoError(t, err) + + m.AbandonPendingTransactions() + + assert.Equal(t, types.TxFatalError, tx1.State) + assert.Equal(t, types.TxFatalError, tx2.State) + assert.Equal(t, types.TxFatalError, tx3.State) + assert.Equal(t, types.TxFatalError, tx4.State) + }) + + t.Run("skips all types apart from unstarted and unconfirmed transactions", func(t *testing.T) { + // Fatal + tx1 := insertFataTransaction(m) + tx2 := insertFataTransaction(m) + + // Confirmed + tx3, err := insertConfirmedTransaction(m, 3) + assert.NoError(t, err) + tx4, err := insertConfirmedTransaction(m, 4) + assert.NoError(t, err) + + m.AbandonPendingTransactions() + + assert.Equal(t, types.TxFatalError, tx1.State) + assert.Equal(t, types.TxFatalError, tx2.State) + assert.Equal(t, types.TxConfirmed, tx3.State) + assert.Equal(t, types.TxConfirmed, tx4.State) + }) +} + +func TestAppendAttemptToTransaction(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + + _, err := insertUnconfirmedTransaction(m, 0) // txID = 1 + assert.NoError(t, err) + _, err = insertConfirmedTransaction(m, 2) // txID = 1 + assert.NoError(t, err) + + t.Run("fails if corresponding unconfirmed transaction for attempt was not found", func(t *testing.T) { + var nonce uint64 = 1 + newAttempt := &types.Attempt{ + TxID: 1, + } + assert.Error(t, m.AppendAttemptToTransaction(nonce, newAttempt)) + }) + + t.Run("fails if unconfirmed transaction was found but has doesn't match the txID", func(t *testing.T) { + var nonce uint64 + newAttempt := &types.Attempt{ + TxID: 2, + } + assert.Error(t, m.AppendAttemptToTransaction(nonce, newAttempt)) + }) + + t.Run("appends attempt to transaction", func(t *testing.T) { + var nonce uint64 + newAttempt := &types.Attempt{ + TxID: 1, + } + assert.NoError(t, m.AppendAttemptToTransaction(nonce, newAttempt)) + }) +} + +func TestCountUnstartedTransactions(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + + assert.Equal(t, 0, m.CountUnstartedTransactions()) + + insertUnstartedTransaction(m) + assert.Equal(t, 1, m.CountUnstartedTransactions()) +} + +func TestCreateEmptyUnconfirmedTransaction(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + _, err := insertUnconfirmedTransaction(m, 0) + assert.NoError(t, err) + + t.Run("fails if unconfirmed transaction with the same nonce exists", func(t *testing.T) { + _, err := m.CreateEmptyUnconfirmedTransaction(0, 0) + assert.Error(t, err) + }) + + t.Run("creates a new empty unconfirmed transaction", func(t *testing.T) { + tx, err := m.CreateEmptyUnconfirmedTransaction(1, 0) + assert.NoError(t, err) + assert.Equal(t, types.TxUnconfirmed, tx.State) + }) +} + +func TestCreateTransaction(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + + t.Run("creates new transactions", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + now := time.Now() + txR1 := &types.TxRequest{} + txR2 := &types.TxRequest{} + tx1 := m.CreateTransaction(txR1) + assert.Equal(t, uint64(1), tx1.ID) + assert.LessOrEqual(t, now, tx1.CreatedAt) + + tx2 := m.CreateTransaction(txR2) + assert.Equal(t, uint64(2), tx2.ID) + assert.LessOrEqual(t, now, tx2.CreatedAt) + + assert.Equal(t, 2, m.CountUnstartedTransactions()) + }) + + t.Run("prunes oldest unstarted transactions if limit is reached", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + overshot := 5 + for i := 1; i < maxQueuedTransactions+overshot; i++ { + r := &types.TxRequest{} + tx := m.CreateTransaction(r) + //nolint:gosec // this won't overflow + assert.Equal(t, uint64(i), tx.ID) + } + // total shouldn't exceed maxQueuedTransactions + assert.Equal(t, maxQueuedTransactions, m.CountUnstartedTransactions()) + // earliest tx ID should be the same amount of the number of transactions that we dropped + tx, err := m.UpdateUnstartedTransactionWithNonce(0) + assert.NoError(t, err) + //nolint:gosec // this won't overflow + assert.Equal(t, uint64(overshot), tx.ID) + }) +} + +func TestFetchUnconfirmedTransactionAtNonceWithCount(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + + tx, count := m.FetchUnconfirmedTransactionAtNonceWithCount(0) + assert.Nil(t, tx) + assert.Equal(t, 0, count) + + var nonce uint64 + _, err := insertUnconfirmedTransaction(m, nonce) + assert.NoError(t, err) + tx, count = m.FetchUnconfirmedTransactionAtNonceWithCount(0) + assert.Equal(t, tx.Nonce, nonce) + assert.Equal(t, 1, count) +} + +func TestMarkTransactionsConfirmed(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + + t.Run("returns 0 if there are no transactions", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + un, cn := m.MarkTransactionsConfirmed(100) + assert.Empty(t, un) + assert.Empty(t, cn) + }) + + t.Run("confirms transaction with nonce lower than the latest", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + ctx1, err := insertUnconfirmedTransaction(m, 0) + assert.NoError(t, err) + + ctx2, err := insertUnconfirmedTransaction(m, 1) + assert.NoError(t, err) + + ctxs, utxs := m.MarkTransactionsConfirmed(1) + assert.NoError(t, err) + assert.Equal(t, types.TxConfirmed, ctx1.State) + assert.Equal(t, types.TxUnconfirmed, ctx2.State) + assert.Equal(t, ctxs[0], ctx1.ID) + assert.Empty(t, utxs) + }) + + t.Run("unconfirms transaction with nonce equal to or higher than the latest", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + ctx1, err := insertConfirmedTransaction(m, 0) + assert.NoError(t, err) + + ctx2, err := insertConfirmedTransaction(m, 1) + assert.NoError(t, err) + + ctxs, utxs := m.MarkTransactionsConfirmed(1) + assert.NoError(t, err) + assert.Equal(t, types.TxConfirmed, ctx1.State) + assert.Equal(t, types.TxUnconfirmed, ctx2.State) + assert.Equal(t, utxs[0], ctx2.ID) + assert.Empty(t, ctxs) + }) + t.Run("prunes confirmed transactions map if it reaches the limit", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + for i := 0; i < maxQueuedTransactions; i++ { + //nolint:gosec // this won't overflow + _, err := insertConfirmedTransaction(m, uint64(i)) + assert.NoError(t, err) + } + assert.Len(t, m.ConfirmedTransactions, maxQueuedTransactions) + m.MarkTransactionsConfirmed(maxQueuedTransactions) + assert.Len(t, m.ConfirmedTransactions, (maxQueuedTransactions - maxQueuedTransactions/pruneSubset)) + }) +} + +func TestMarkUnconfirmedTransactionPurgeable(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + + // fails if tx was not found + err := m.MarkUnconfirmedTransactionPurgeable(0) + assert.Error(t, err) + + tx, err := insertUnconfirmedTransaction(m, 0) + assert.NoError(t, err) + err = m.MarkUnconfirmedTransactionPurgeable(0) + assert.NoError(t, err) + assert.True(t, tx.IsPurgeable) +} + +func TestUpdateTransactionBroadcast(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + hash := testutils.NewHash() + t.Run("fails if unconfirmed transaction was not found", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + var nonce uint64 + assert.Error(t, m.UpdateTransactionBroadcast(0, nonce, hash)) + }) + + t.Run("fails if attempt was not found for a given transaction", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + var nonce uint64 + tx, err := insertUnconfirmedTransaction(m, nonce) + assert.NoError(t, err) + assert.Error(t, m.UpdateTransactionBroadcast(0, nonce, hash)) + + // Attempt with different hash + attempt := &types.Attempt{TxID: tx.ID, Hash: testutils.NewHash()} + tx.Attempts = append(tx.Attempts, attempt) + assert.Error(t, m.UpdateTransactionBroadcast(0, nonce, hash)) + }) + + t.Run("updates transaction's and attempt's broadcast times", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + var nonce uint64 + tx, err := insertUnconfirmedTransaction(m, nonce) + assert.NoError(t, err) + attempt := &types.Attempt{TxID: tx.ID, Hash: hash} + tx.Attempts = append(tx.Attempts, attempt) + assert.NoError(t, m.UpdateTransactionBroadcast(0, nonce, hash)) + assert.False(t, tx.LastBroadcastAt.IsZero()) + assert.False(t, attempt.BroadcastAt.IsZero()) + }) +} + +func TestUpdateUnstartedTransactionWithNonce(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + t.Run("returns nil if there are no unstarted transactions", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + tx, err := m.UpdateUnstartedTransactionWithNonce(0) + assert.NoError(t, err) + assert.Nil(t, tx) + }) + + t.Run("fails if there is already another unstarted transaction with the same nonce", func(t *testing.T) { + var nonce uint64 + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + insertUnstartedTransaction(m) + _, err := insertUnconfirmedTransaction(m, nonce) + assert.NoError(t, err) + + _, err = m.UpdateUnstartedTransactionWithNonce(nonce) + assert.Error(t, err) + }) + + t.Run("updates unstarted transaction to unconfirmed and assigns a nonce", func(t *testing.T) { + var nonce uint64 + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + insertUnstartedTransaction(m) + + tx, err := m.UpdateUnstartedTransactionWithNonce(nonce) + assert.NoError(t, err) + assert.Equal(t, nonce, tx.Nonce) + assert.Equal(t, types.TxUnconfirmed, tx.State) + }) +} + +func TestDeleteAttemptForUnconfirmedTx(t *testing.T) { + t.Parallel() + + fromAddress := testutils.NewAddress() + t.Run("fails if corresponding unconfirmed transaction for attempt was not found", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + tx := &types.Transaction{Nonce: 0} + attempt := &types.Attempt{TxID: 0} + err := m.DeleteAttemptForUnconfirmedTx(tx.Nonce, attempt) + assert.Error(t, err) + }) + + t.Run("fails if corresponding unconfirmed attempt for txID was not found", func(t *testing.T) { + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + _, err := insertUnconfirmedTransaction(m, 0) + assert.NoError(t, err) + + attempt := &types.Attempt{TxID: 2, Hash: testutils.NewHash()} + err = m.DeleteAttemptForUnconfirmedTx(0, attempt) + + assert.Error(t, err) + }) + + t.Run("deletes attempt of unconfirmed transaction", func(t *testing.T) { + hash := testutils.NewHash() + var nonce uint64 + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + tx, err := insertUnconfirmedTransaction(m, nonce) + assert.NoError(t, err) + + attempt := &types.Attempt{TxID: 0, Hash: hash} + tx.Attempts = append(tx.Attempts, attempt) + err = m.DeleteAttemptForUnconfirmedTx(nonce, attempt) + assert.NoError(t, err) + + assert.Empty(t, tx.Attempts) + }) +} + +func TestPruneConfirmedTransactions(t *testing.T) { + t.Parallel() + fromAddress := testutils.NewAddress() + m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID) + total := 5 + for i := 0; i < total; i++ { + //nolint:gosec // this won't overflow + _, err := insertConfirmedTransaction(m, uint64(i)) + assert.NoError(t, err) + } + prunedTxIDs := m.pruneConfirmedTransactions() + left := total - total/pruneSubset + assert.Len(t, m.ConfirmedTransactions, left) + assert.Len(t, prunedTxIDs, total/pruneSubset) +} + +func insertUnstartedTransaction(m *InMemoryStore) *types.Transaction { + m.Lock() + defer m.Unlock() + + m.txIDCount++ + tx := &types.Transaction{ + ID: m.txIDCount, + ChainID: testutils.FixtureChainID, + Nonce: 0, + FromAddress: m.address, + ToAddress: testutils.NewAddress(), + Value: big.NewInt(0), + SpecifiedGasLimit: 0, + CreatedAt: time.Now(), + State: types.TxUnstarted, + } + + m.UnstartedTransactions = append(m.UnstartedTransactions, tx) + return tx +} + +func insertUnconfirmedTransaction(m *InMemoryStore, nonce uint64) (*types.Transaction, error) { + m.Lock() + defer m.Unlock() + + m.txIDCount++ + tx := &types.Transaction{ + ID: m.txIDCount, + ChainID: testutils.FixtureChainID, + Nonce: nonce, + FromAddress: m.address, + ToAddress: testutils.NewAddress(), + Value: big.NewInt(0), + SpecifiedGasLimit: 0, + CreatedAt: time.Now(), + State: types.TxUnconfirmed, + } + + if _, exists := m.UnconfirmedTransactions[nonce]; exists { + return nil, fmt.Errorf("an unconfirmed tx with the same nonce already exists: %v", m.UnconfirmedTransactions[nonce]) + } + + m.UnconfirmedTransactions[nonce] = tx + return tx, nil +} + +func insertConfirmedTransaction(m *InMemoryStore, nonce uint64) (*types.Transaction, error) { + m.Lock() + defer m.Unlock() + + m.txIDCount++ + tx := &types.Transaction{ + ID: m.txIDCount, + ChainID: testutils.FixtureChainID, + Nonce: nonce, + FromAddress: m.address, + ToAddress: testutils.NewAddress(), + Value: big.NewInt(0), + SpecifiedGasLimit: 0, + CreatedAt: time.Now(), + State: types.TxConfirmed, + } + + if _, exists := m.ConfirmedTransactions[nonce]; exists { + return nil, fmt.Errorf("a confirmed tx with the same nonce already exists: %v", m.ConfirmedTransactions[nonce]) + } + + m.ConfirmedTransactions[nonce] = tx + return tx, nil +} + +func insertFataTransaction(m *InMemoryStore) *types.Transaction { + m.Lock() + defer m.Unlock() + + m.txIDCount++ + tx := &types.Transaction{ + ID: m.txIDCount, + ChainID: testutils.FixtureChainID, + Nonce: 0, + FromAddress: m.address, + ToAddress: testutils.NewAddress(), + Value: big.NewInt(0), + SpecifiedGasLimit: 0, + CreatedAt: time.Now(), + State: types.TxFatalError, + } + + m.FatalTransactions = append(m.FatalTransactions, tx) + return tx +} diff --git a/core/chains/evm/txm/stuck_tx_detector.go b/core/chains/evm/txm/stuck_tx_detector.go new file mode 100644 index 00000000000..68d8caf0ed1 --- /dev/null +++ b/core/chains/evm/txm/stuck_tx_detector.go @@ -0,0 +1,115 @@ +package txm + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/chaintype" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +type StuckTxDetectorConfig struct { + BlockTime time.Duration + StuckTxBlockThreshold uint32 + DetectionURL string +} + +type stuckTxDetector struct { + lggr logger.Logger + chainType chaintype.ChainType + config StuckTxDetectorConfig +} + +func NewStuckTxDetector(lggr logger.Logger, chaintype chaintype.ChainType, config StuckTxDetectorConfig) *stuckTxDetector { + return &stuckTxDetector{ + lggr: lggr, + chainType: chaintype, + config: config, + } +} + +func (s *stuckTxDetector) DetectStuckTransaction(ctx context.Context, tx *types.Transaction) (bool, error) { + switch s.chainType { + // TODO: rename + case chaintype.ChainDualBroadcast: + result, err := s.dualBroadcastDetection(ctx, tx) + if result || err != nil { + return result, err + } + return s.timeBasedDetection(tx), nil + default: + return s.timeBasedDetection(tx), nil + } +} + +func (s *stuckTxDetector) timeBasedDetection(tx *types.Transaction) bool { + threshold := (s.config.BlockTime * time.Duration(s.config.StuckTxBlockThreshold)) + if time.Since(tx.LastBroadcastAt) > threshold && !tx.LastBroadcastAt.IsZero() { + s.lggr.Debugf("TxID: %v last broadcast was: %v which is more than the max configured duration: %v. Transaction is now considered stuck and will be purged.", + tx.ID, tx.LastBroadcastAt, threshold) + return true + } + return false +} + +type APIResponse struct { + Status string `json:"status,omitempty"` + Hash common.Hash `json:"hash,omitempty"` +} + +const ( + APIStatusPending = "PENDING" + APIStatusIncluded = "INCLUDED" + APIStatusFailed = "FAILED" + APIStatusCancelled = "CANCELLED" + APIStatusUnknown = "UNKNOWN" +) + +func (s *stuckTxDetector) dualBroadcastDetection(ctx context.Context, tx *types.Transaction) (bool, error) { + for _, attempt := range tx.Attempts { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.config.DetectionURL+attempt.Hash.String(), nil) + if err != nil { + return false, fmt.Errorf("failed to make request for txID: %v, attemptHash: %v - %w", tx.ID, attempt.Hash, err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("failed to get transaction status for txID: %v, attemptHash: %v - %w", tx.ID, attempt.Hash, err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return false, fmt.Errorf("request %v failed with status: %d", req, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return false, err + } + + var apiResponse APIResponse + err = json.Unmarshal(body, &apiResponse) + if err != nil { + return false, fmt.Errorf("failed to unmarshal response for txID: %v, attemptHash: %v - %w: %s", tx.ID, attempt.Hash, err, string(body)) + } + switch apiResponse.Status { + case APIStatusPending, APIStatusIncluded: + return false, nil + case APIStatusFailed, APIStatusCancelled: + s.lggr.Debugf("TxID: %v with attempHash: %v was marked as failed/cancelled by the RPC. Transaction is now considered stuck and will be purged.", + tx.ID, attempt.Hash) + return true, nil + case APIStatusUnknown: + continue + default: + continue + } + } + return false, nil +} diff --git a/core/chains/evm/txm/txm.go b/core/chains/evm/txm/txm.go new file mode 100644 index 00000000000..042744cb218 --- /dev/null +++ b/core/chains/evm/txm/txm.go @@ -0,0 +1,395 @@ +package txm + +import ( + "context" + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/jpillora/backoff" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/utils" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +const ( + broadcastInterval time.Duration = 30 * time.Second + maxInFlightTransactions int = 16 + maxInFlightSubset int = 3 + maxAllowedAttempts uint16 = 10 +) + +type Client interface { + PendingNonceAt(context.Context, common.Address) (uint64, error) + NonceAt(context.Context, common.Address, *big.Int) (uint64, error) + SendTransaction(ctx context.Context, tx *types.Transaction, attempt *types.Attempt) error +} + +type TxStore interface { + AbandonPendingTransactions(context.Context, common.Address) error + AppendAttemptToTransaction(context.Context, uint64, common.Address, *types.Attempt) error + CreateEmptyUnconfirmedTransaction(context.Context, common.Address, uint64, uint64) (*types.Transaction, error) + CreateTransaction(context.Context, *types.TxRequest) (*types.Transaction, error) + FetchUnconfirmedTransactionAtNonceWithCount(context.Context, uint64, common.Address) (*types.Transaction, int, error) + MarkTransactionsConfirmed(context.Context, uint64, common.Address) ([]uint64, []uint64, error) + MarkUnconfirmedTransactionPurgeable(context.Context, uint64, common.Address) error + UpdateTransactionBroadcast(context.Context, uint64, uint64, common.Hash, common.Address) error + UpdateUnstartedTransactionWithNonce(context.Context, common.Address, uint64) (*types.Transaction, error) + + // ErrorHandler + DeleteAttemptForUnconfirmedTx(context.Context, uint64, *types.Attempt, common.Address) error + MarkTxFatal(context.Context, *types.Transaction, common.Address) error +} + +type AttemptBuilder interface { + NewAttempt(context.Context, logger.Logger, *types.Transaction, bool) (*types.Attempt, error) + NewBumpAttempt(context.Context, logger.Logger, *types.Transaction, types.Attempt) (*types.Attempt, error) +} + +type ErrorHandler interface { + HandleError(*types.Transaction, error, AttemptBuilder, Client, TxStore, func(common.Address, uint64), bool) (err error) +} + +type StuckTxDetector interface { + DetectStuckTransaction(ctx context.Context, tx *types.Transaction) (bool, error) +} + +type Keystore interface { + EnabledAddressesForChain(ctx context.Context, chainID *big.Int) (addresses []common.Address, err error) +} + +type Config struct { + EIP1559 bool + BlockTime time.Duration + RetryBlockThreshold uint16 + EmptyTxLimitDefault uint64 +} + +type Txm struct { + services.StateMachine + lggr logger.SugaredLogger + chainID *big.Int + client Client + attemptBuilder AttemptBuilder + errorHandler ErrorHandler + stuckTxDetector StuckTxDetector + txStore TxStore + keystore Keystore + config Config + + nonceMapMu sync.Mutex + nonceMap map[common.Address]uint64 + + triggerCh map[common.Address]chan struct{} + stopCh services.StopChan + wg sync.WaitGroup +} + +func NewTxm(lggr logger.Logger, chainID *big.Int, client Client, attemptBuilder AttemptBuilder, txStore TxStore, stuckTxDetector StuckTxDetector, config Config, keystore Keystore) *Txm { + return &Txm{ + lggr: logger.Sugared(logger.Named(lggr, "Txm")), + keystore: keystore, + chainID: chainID, + client: client, + attemptBuilder: attemptBuilder, + txStore: txStore, + stuckTxDetector: stuckTxDetector, + config: config, + nonceMap: make(map[common.Address]uint64), + triggerCh: make(map[common.Address]chan struct{}), + } +} + +func (t *Txm) Start(ctx context.Context) error { + return t.StartOnce("Txm", func() error { + t.stopCh = make(chan struct{}) + + addresses, err := t.keystore.EnabledAddressesForChain(ctx, t.chainID) + if err != nil { + return err + } + for _, address := range addresses { + err := t.startAddress(address) + if err != nil { + return err + } + } + return nil + }) +} + +func (t *Txm) startAddress(address common.Address) error { + t.triggerCh[address] = make(chan struct{}, 1) + pendingNonce, err := t.client.PendingNonceAt(context.TODO(), address) + if err != nil { + return err + } + t.setNonce(address, pendingNonce) + + t.wg.Add(2) + go t.broadcastLoop(address) + go t.backfillLoop(address) + return nil +} + +func (t *Txm) Close() error { + return t.StopOnce("Txm", func() error { + close(t.stopCh) + t.wg.Wait() + return nil + }) +} + +func (t *Txm) CreateTransaction(ctx context.Context, txRequest *types.TxRequest) (tx *types.Transaction, err error) { + tx, err = t.txStore.CreateTransaction(ctx, txRequest) + if err == nil { + t.lggr.Infow("Created transaction", "tx", tx) + } + return +} + +func (t *Txm) Trigger(address common.Address) { + if !t.IfStarted(func() { + t.triggerCh[address] <- struct{}{} + }) { + t.lggr.Error("Txm unstarted") + } +} + +func (t *Txm) Abandon(address common.Address) error { + return t.txStore.AbandonPendingTransactions(context.TODO(), address) +} + +func (t *Txm) getNonce(address common.Address) uint64 { + t.nonceMapMu.Lock() + defer t.nonceMapMu.Unlock() + return t.nonceMap[address] +} + +func (t *Txm) setNonce(address common.Address, nonce uint64) { + t.nonceMapMu.Lock() + t.nonceMap[address] = nonce + defer t.nonceMapMu.Unlock() +} + +func newBackoff(min time.Duration) backoff.Backoff { + return backoff.Backoff{ + Min: min, + Max: 1 * time.Minute, + Jitter: true, + } +} + +func (t *Txm) broadcastLoop(address common.Address) { + defer t.wg.Done() + ctx, cancel := t.stopCh.NewCtx() + defer cancel() + broadcastWithBackoff := newBackoff(1 * time.Second) + var broadcastCh <-chan time.Time + + for { + start := time.Now() + bo, err := t.broadcastTransaction(ctx, address) + if err != nil { + t.lggr.Errorf("Error during transaction broadcasting: %v", err) + } else { + t.lggr.Debug("Transaction broadcasting time elapsed: ", time.Since(start)) + } + if bo { + broadcastCh = time.After(broadcastWithBackoff.Duration()) + } else { + broadcastWithBackoff.Reset() + broadcastCh = time.After(utils.WithJitter(broadcastInterval)) + } + select { + case <-ctx.Done(): + return + case <-t.triggerCh[address]: + continue + case <-broadcastCh: + continue + } + } +} + +func (t *Txm) backfillLoop(address common.Address) { + defer t.wg.Done() + ctx, cancel := t.stopCh.NewCtx() + defer cancel() + backfillWithBackoff := newBackoff(t.config.BlockTime) + backfillCh := time.After(utils.WithJitter(t.config.BlockTime)) + + for { + select { + case <-ctx.Done(): + return + case <-backfillCh: + start := time.Now() + bo, err := t.backfillTransactions(ctx, address) + if err != nil { + t.lggr.Errorf("Error during backfill: %v", err) + } else { + t.lggr.Debug("Backfill time elapsed: ", time.Since(start)) + } + if bo { + backfillCh = time.After(backfillWithBackoff.Duration()) + } else { + backfillWithBackoff.Reset() + backfillCh = time.After(utils.WithJitter(t.config.BlockTime)) + } + } + } +} + +func (t *Txm) broadcastTransaction(ctx context.Context, address common.Address) (bool, error) { + for { + _, unconfirmedCount, err := t.txStore.FetchUnconfirmedTransactionAtNonceWithCount(ctx, 0, address) + if err != nil { + return false, err + } + + // Optimistically send up to 1/3 of the maxInFlightTransactions. After that threshold, broadcast more cautiously + // by checking the pending nonce so no more than maxInFlightTransactions/3 can get stuck simultaneously i.e. due + // to insufficient balance. We're making this trade-off to avoid storing stuck transactions and making unnecessary + // RPC calls. The upper limit is always maxInFlightTransactions regardless of the pending nonce. + if unconfirmedCount >= maxInFlightTransactions/maxInFlightSubset { + if unconfirmedCount > maxInFlightTransactions { + t.lggr.Warnf("Reached transaction limit: %d for unconfirmed transactions", maxInFlightTransactions) + return true, nil + } + pendingNonce, e := t.client.PendingNonceAt(ctx, address) + if e != nil { + return false, e + } + nonce := t.getNonce(address) + if nonce > pendingNonce { + t.lggr.Warnf("Reached transaction limit. LocalNonce: %d, PendingNonce %d, unconfirmedCount: %d", + nonce, pendingNonce, unconfirmedCount) + return true, nil + } + } + + tx, err := t.txStore.UpdateUnstartedTransactionWithNonce(ctx, address, t.getNonce(address)) + if err != nil { + return false, err + } + if tx == nil { + return false, nil + } + tx.Nonce = t.getNonce(address) + t.setNonce(address, tx.Nonce+1) + tx.State = types.TxUnconfirmed + + if err := t.createAndSendAttempt(ctx, tx, address); err != nil { + return true, err + } + } +} + +func (t *Txm) createAndSendAttempt(ctx context.Context, tx *types.Transaction, address common.Address) error { + attempt, err := t.attemptBuilder.NewAttempt(ctx, t.lggr, tx, t.config.EIP1559) + if err != nil { + return err + } + + if err = t.txStore.AppendAttemptToTransaction(ctx, tx.Nonce, address, attempt); err != nil { + return err + } + + return t.sendTransactionWithError(ctx, tx, attempt, address) +} + +func (t *Txm) sendTransactionWithError(ctx context.Context, tx *types.Transaction, attempt *types.Attempt, address common.Address) (err error) { + start := time.Now() + txErr := t.client.SendTransaction(ctx, tx, attempt) + tx.AttemptCount++ + t.lggr.Infow("Broadcasted attempt", "tx", tx, "attempt", attempt, "duration", time.Since(start), "txErr: ", txErr) + if txErr != nil && t.errorHandler != nil { + if err = t.errorHandler.HandleError(tx, txErr, t.attemptBuilder, t.client, t.txStore, t.setNonce, false); err != nil { + return + } + } else if txErr != nil { + pendingNonce, err := t.client.PendingNonceAt(ctx, address) + if err != nil { + return err + } + if pendingNonce <= tx.Nonce { + t.lggr.Debugf("Pending nonce for txID: %v didn't increase. PendingNonce: %d, TxNonce: %d", tx.ID, pendingNonce, tx.Nonce) + return nil + } + } + + return t.txStore.UpdateTransactionBroadcast(ctx, attempt.TxID, tx.Nonce, attempt.Hash, address) +} + +func (t *Txm) backfillTransactions(ctx context.Context, address common.Address) (bool, error) { + latestNonce, err := t.client.NonceAt(ctx, address, nil) + if err != nil { + return false, err + } + + confirmedTransactionIDs, unconfirmedTransactionIDs, err := t.txStore.MarkTransactionsConfirmed(ctx, latestNonce, address) + if err != nil { + return false, err + } + if len(confirmedTransactionIDs) > 0 || len(unconfirmedTransactionIDs) > 0 { + t.lggr.Infof("Confirmed transaction IDs: %v . Re-orged transaction IDs: %v", confirmedTransactionIDs, unconfirmedTransactionIDs) + } + + tx, unconfirmedCount, err := t.txStore.FetchUnconfirmedTransactionAtNonceWithCount(ctx, latestNonce, address) + if err != nil { + return false, err + } + if unconfirmedCount == 0 { + t.lggr.Debugf("All transactions confirmed for address: %v", address) + return false, err // TODO: add backoff to optimize requests + } + + if tx == nil || tx.Nonce != latestNonce { + t.lggr.Warnf("Nonce gap at nonce: %d - address: %v. Creating a new transaction\n", latestNonce, address) + return false, t.createAndSendEmptyTx(ctx, latestNonce, address) + //nolint:revive //linter nonsense + } else { + if !tx.IsPurgeable && t.stuckTxDetector != nil { + isStuck, err := t.stuckTxDetector.DetectStuckTransaction(ctx, tx) + if err != nil { + return false, err + } + if isStuck { + tx.IsPurgeable = true + err = t.txStore.MarkUnconfirmedTransactionPurgeable(ctx, tx.Nonce, address) + if err != nil { + return false, err + } + t.lggr.Infof("Marked tx as purgeable. Sending purge attempt for txID: %d", tx.ID) + return false, t.createAndSendAttempt(ctx, tx, address) + } + } + + if tx.AttemptCount >= maxAllowedAttempts { + return true, fmt.Errorf("reached max allowed attempts for txID: %d. TXM won't broadcast any more attempts."+ + "If this error persists, it means the transaction won't be confirmed and the TXM needs to be restarted."+ + "Look for any error messages from previous attempts that may indicate why this happened, i.e. wallet is out of funds. Tx: %v", tx.ID, tx) + } + + if time.Since(tx.LastBroadcastAt) > (t.config.BlockTime*time.Duration(t.config.RetryBlockThreshold)) || tx.LastBroadcastAt.IsZero() { + // TODO: add optional graceful bumping strategy + t.lggr.Info("Rebroadcasting attempt for txID: ", tx.ID) + return false, t.createAndSendAttempt(ctx, tx, address) + } + } + return false, nil +} + +func (t *Txm) createAndSendEmptyTx(ctx context.Context, latestNonce uint64, address common.Address) error { + tx, err := t.txStore.CreateEmptyUnconfirmedTransaction(ctx, address, latestNonce, t.config.EmptyTxLimitDefault) + if err != nil { + return err + } + return t.createAndSendAttempt(ctx, tx, address) +} diff --git a/core/chains/evm/txm/txm_test.go b/core/chains/evm/txm/txm_test.go new file mode 100644 index 00000000000..ddd75d92693 --- /dev/null +++ b/core/chains/evm/txm/txm_test.go @@ -0,0 +1,230 @@ +package txm + +import ( + "errors" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services/servicetest" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/testutils" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/mocks" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/storage" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/types" +) + +func TestLifecycle(t *testing.T) { + t.Parallel() + + client := mocks.NewClient(t) + ab := mocks.NewAttemptBuilder(t) + config := Config{BlockTime: 10 * time.Millisecond} + address1 := testutils.NewAddress() + address2 := testutils.NewAddress() + assert.NotEqual(t, address1, address2) + addresses := []common.Address{address1, address2} + keystore := mocks.NewKeystore(t) + keystore.On("EnabledAddressesForChain", mock.Anything, mock.Anything).Return(addresses, nil) + + t.Run("fails to start if initial pending nonce call fails", func(t *testing.T) { + txm := NewTxm(logger.Test(t), testutils.FixtureChainID, client, ab, nil, nil, config, keystore) + client.On("PendingNonceAt", mock.Anything, address1).Return(uint64(0), errors.New("error")).Once() + require.Error(t, txm.Start(tests.Context(t))) + }) + + t.Run("tests lifecycle successfully without any transactions", func(t *testing.T) { + lggr, observedLogs := logger.TestObserved(t, zap.DebugLevel) + txStore := storage.NewInMemoryStoreManager(lggr, testutils.FixtureChainID) + require.NoError(t, txStore.Add(addresses...)) + txm := NewTxm(lggr, testutils.FixtureChainID, client, ab, txStore, nil, config, keystore) + var nonce uint64 + // Start + client.On("PendingNonceAt", mock.Anything, address1).Return(nonce, nil).Once() + client.On("PendingNonceAt", mock.Anything, address2).Return(nonce, nil).Once() + // backfill loop (may or may not be executed multiple times) + client.On("NonceAt", mock.Anything, address1, mock.Anything).Return(nonce, nil) + client.On("NonceAt", mock.Anything, address2, mock.Anything).Return(nonce, nil) + + servicetest.Run(t, txm) + tests.AssertLogEventually(t, observedLogs, "Backfill time elapsed") + }) +} + +func TestTrigger(t *testing.T) { + t.Parallel() + + address := testutils.NewAddress() + keystore := mocks.NewKeystore(t) + keystore.On("EnabledAddressesForChain", mock.Anything, mock.Anything).Return([]common.Address{address}, nil) + t.Run("Trigger fails if Txm is unstarted", func(t *testing.T) { + lggr, observedLogs := logger.TestObserved(t, zap.ErrorLevel) + txm := NewTxm(lggr, nil, nil, nil, nil, nil, Config{}, keystore) + txm.Trigger(address) + tests.AssertLogEventually(t, observedLogs, "Txm unstarted") + }) + + t.Run("executes Trigger", func(t *testing.T) { + lggr := logger.Test(t) + txStore := storage.NewInMemoryStoreManager(lggr, testutils.FixtureChainID) + require.NoError(t, txStore.Add(address)) + client := mocks.NewClient(t) + ab := mocks.NewAttemptBuilder(t) + config := Config{BlockTime: 1 * time.Minute, RetryBlockThreshold: 10} + txm := NewTxm(lggr, testutils.FixtureChainID, client, ab, txStore, nil, config, keystore) + var nonce uint64 + // Start + client.On("PendingNonceAt", mock.Anything, address).Return(nonce, nil).Once() + servicetest.Run(t, txm) + }) +} + +func TestBroadcastTransaction(t *testing.T) { + t.Parallel() + + ctx := tests.Context(t) + client := mocks.NewClient(t) + ab := mocks.NewAttemptBuilder(t) + config := Config{} + address := testutils.NewAddress() + keystore := mocks.NewKeystore(t) + + t.Run("fails if FetchUnconfirmedTransactionAtNonceWithCount for unconfirmed transactions fails", func(t *testing.T) { + mTxStore := mocks.NewTxStore(t) + mTxStore.On("FetchUnconfirmedTransactionAtNonceWithCount", mock.Anything, mock.Anything, mock.Anything).Return(nil, 0, errors.New("call failed")).Once() + txm := NewTxm(logger.Test(t), testutils.FixtureChainID, client, ab, mTxStore, nil, config, keystore) + bo, err := txm.broadcastTransaction(ctx, address) + require.Error(t, err) + assert.False(t, bo) + require.ErrorContains(t, err, "call failed") + }) + + t.Run("throws a warning and returns if unconfirmed transactions exceed maxInFlightTransactions", func(t *testing.T) { + lggr, observedLogs := logger.TestObserved(t, zap.DebugLevel) + mTxStore := mocks.NewTxStore(t) + mTxStore.On("FetchUnconfirmedTransactionAtNonceWithCount", mock.Anything, mock.Anything, mock.Anything).Return(nil, maxInFlightTransactions+1, nil).Once() + txm := NewTxm(lggr, testutils.FixtureChainID, client, ab, mTxStore, nil, config, keystore) + bo, err := txm.broadcastTransaction(ctx, address) + assert.True(t, bo) + require.NoError(t, err) + tests.AssertLogEventually(t, observedLogs, "Reached transaction limit") + }) + + t.Run("checks pending nonce if unconfirmed transactions are more than 1/3 of maxInFlightTransactions", func(t *testing.T) { + lggr, observedLogs := logger.TestObserved(t, zap.DebugLevel) + mTxStore := mocks.NewTxStore(t) + txm := NewTxm(lggr, testutils.FixtureChainID, client, ab, mTxStore, nil, config, keystore) + txm.setNonce(address, 1) + mTxStore.On("FetchUnconfirmedTransactionAtNonceWithCount", mock.Anything, mock.Anything, mock.Anything).Return(nil, maxInFlightTransactions/3, nil).Twice() + + client.On("PendingNonceAt", mock.Anything, address).Return(uint64(0), nil).Once() // LocalNonce: 1, PendingNonce: 0 + bo, err := txm.broadcastTransaction(ctx, address) + assert.True(t, bo) + require.NoError(t, err) + + client.On("PendingNonceAt", mock.Anything, address).Return(uint64(1), nil).Once() // LocalNonce: 1, PendingNonce: 1 + mTxStore.On("UpdateUnstartedTransactionWithNonce", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Once() + bo, err = txm.broadcastTransaction(ctx, address) + assert.False(t, bo) + require.NoError(t, err) + tests.AssertLogCountEventually(t, observedLogs, "Reached transaction limit.", 1) + }) + + t.Run("fails if UpdateUnstartedTransactionWithNonce fails", func(t *testing.T) { + mTxStore := mocks.NewTxStore(t) + mTxStore.On("FetchUnconfirmedTransactionAtNonceWithCount", mock.Anything, mock.Anything, mock.Anything).Return(nil, 0, nil).Once() + txm := NewTxm(logger.Test(t), testutils.FixtureChainID, client, ab, mTxStore, nil, config, keystore) + mTxStore.On("UpdateUnstartedTransactionWithNonce", mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("call failed")).Once() + bo, err := txm.broadcastTransaction(ctx, address) + assert.False(t, bo) + require.Error(t, err) + require.ErrorContains(t, err, "call failed") + }) + + t.Run("returns if there are no unstarted transactions", func(t *testing.T) { + lggr := logger.Test(t) + txStore := storage.NewInMemoryStoreManager(lggr, testutils.FixtureChainID) + require.NoError(t, txStore.Add(address)) + txm := NewTxm(lggr, testutils.FixtureChainID, client, ab, txStore, nil, config, keystore) + bo, err := txm.broadcastTransaction(ctx, address) + require.NoError(t, err) + assert.False(t, bo) + assert.Equal(t, uint64(0), txm.getNonce(address)) + }) + + t.Run("picks a new tx and creates a new attempt then sends it and updates the broadcast time", func(t *testing.T) { + lggr := logger.Test(t) + txStore := storage.NewInMemoryStoreManager(lggr, testutils.FixtureChainID) + require.NoError(t, txStore.Add(address)) + txm := NewTxm(lggr, testutils.FixtureChainID, client, ab, txStore, nil, config, keystore) + txm.setNonce(address, 8) + IDK := "IDK" + txRequest := &types.TxRequest{ + IdempotencyKey: &IDK, + ChainID: testutils.FixtureChainID, + FromAddress: address, + ToAddress: testutils.NewAddress(), + SpecifiedGasLimit: 22000, + } + tx, err := txm.CreateTransaction(tests.Context(t), txRequest) + require.NoError(t, err) + attempt := &types.Attempt{ + TxID: tx.ID, + Fee: gas.EvmFee{GasPrice: assets.NewWeiI(1)}, + GasLimit: 22000, + } + ab.On("NewAttempt", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(attempt, nil).Once() + client.On("SendTransaction", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + + bo, err := txm.broadcastTransaction(ctx, address) + require.NoError(t, err) + assert.False(t, bo) + assert.Equal(t, uint64(9), txm.getNonce(address)) + tx, err = txStore.FindTxWithIdempotencyKey(tests.Context(t), &IDK) + require.NoError(t, err) + assert.Len(t, tx.Attempts, 1) + var zeroTime time.Time + assert.Greater(t, tx.LastBroadcastAt, zeroTime) + assert.Greater(t, tx.Attempts[0].BroadcastAt, zeroTime) + }) +} + +func TestBackfillTransactions(t *testing.T) { + t.Parallel() + + ctx := tests.Context(t) + client := mocks.NewClient(t) + ab := mocks.NewAttemptBuilder(t) + storage := mocks.NewTxStore(t) + config := Config{} + address := testutils.NewAddress() + keystore := mocks.NewKeystore(t) + + t.Run("fails if latest nonce fetching fails", func(t *testing.T) { + txm := NewTxm(logger.Test(t), testutils.FixtureChainID, client, ab, storage, nil, config, keystore) + client.On("NonceAt", mock.Anything, address, mock.Anything).Return(uint64(0), errors.New("latest nonce fail")).Once() + bo, err := txm.backfillTransactions(ctx, address) + require.Error(t, err) + assert.False(t, bo) + require.ErrorContains(t, err, "latest nonce fail") + }) + + t.Run("fails if MarkTransactionsConfirmed fails", func(t *testing.T) { + txm := NewTxm(logger.Test(t), testutils.FixtureChainID, client, ab, storage, nil, config, keystore) + client.On("NonceAt", mock.Anything, address, mock.Anything).Return(uint64(0), nil).Once() + storage.On("MarkTransactionsConfirmed", mock.Anything, mock.Anything, address).Return([]uint64{}, []uint64{}, errors.New("marking transactions confirmed failed")).Once() + bo, err := txm.backfillTransactions(ctx, address) + require.Error(t, err) + assert.False(t, bo) + require.ErrorContains(t, err, "marking transactions confirmed failed") + }) +} diff --git a/core/chains/evm/txm/types/transaction.go b/core/chains/evm/txm/types/transaction.go new file mode 100644 index 00000000000..62d788861ad --- /dev/null +++ b/core/chains/evm/txm/types/transaction.go @@ -0,0 +1,170 @@ +package types + +import ( + "encoding/json" + "fmt" + "math/big" + "time" + + "github.com/google/uuid" + "gopkg.in/guregu/null.v4" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" + clnull "github.com/smartcontractkit/chainlink-common/pkg/utils/null" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" +) + +type TxState string + +const ( + TxUnstarted = TxState("unstarted") + TxUnconfirmed = TxState("unconfirmed") + TxConfirmed = TxState("confirmed") + + TxFatalError = TxState("fatal") + TxFinalized = TxState("finalized") +) + +type Transaction struct { + ID uint64 + IdempotencyKey *string + ChainID *big.Int + Nonce uint64 + FromAddress common.Address + ToAddress common.Address + Value *big.Int + Data []byte + SpecifiedGasLimit uint64 + + CreatedAt time.Time + LastBroadcastAt time.Time + + State TxState + IsPurgeable bool + Attempts []*Attempt + AttemptCount uint16 // AttempCount is strictly kept in memory and prevents indefinite retrying + Meta *sqlutil.JSON + Subject uuid.NullUUID + + // Pipeline variables - if you aren't calling this from chain tx task within + // the pipeline, you don't need these variables + PipelineTaskRunID uuid.NullUUID + MinConfirmations clnull.Uint32 + SignalCallback bool + CallbackCompleted bool +} + +func (t *Transaction) FindAttemptByHash(attemptHash common.Hash) (*Attempt, error) { + for _, a := range t.Attempts { + if a.Hash == attemptHash { + return a, nil + } + } + return nil, fmt.Errorf("attempt with hash: %v was not found", attemptHash) +} + +func (t *Transaction) DeepCopy() *Transaction { + txCopy := *t + attemptsCopy := make([]*Attempt, 0, len(t.Attempts)) + for _, attempt := range t.Attempts { + attemptsCopy = append(attemptsCopy, attempt.DeepCopy()) + } + txCopy.Attempts = attemptsCopy + return &txCopy +} + +func (t *Transaction) GetMeta() (*TxMeta, error) { + if t.Meta == nil { + return nil, nil + } + var m TxMeta + if err := json.Unmarshal(*t.Meta, &m); err != nil { + return nil, fmt.Errorf("unmarshalling meta: %w", err) + } + return &m, nil +} + +type Attempt struct { + ID uint64 + TxID uint64 + Hash common.Hash + Fee gas.EvmFee + GasLimit uint64 + Type byte + SignedTransaction *types.Transaction + + CreatedAt time.Time + BroadcastAt time.Time +} + +func (a *Attempt) DeepCopy() *Attempt { + txCopy := *a + if a.SignedTransaction != nil { + txCopy.SignedTransaction = a.SignedTransaction.WithoutBlobTxSidecar() + } + return &txCopy +} + +type TxRequest struct { + IdempotencyKey *string + ChainID *big.Int + FromAddress common.Address + ToAddress common.Address + Value *big.Int + Data []byte + SpecifiedGasLimit uint64 + + Meta *sqlutil.JSON // TODO: *TxMeta after migration + ForwarderAddress common.Address + // QueueingTxStrategy QueueingTxStrategy + + // Pipeline variables - if you aren't calling this from chain tx task within + // the pipeline, you don't need these variables + PipelineTaskRunID uuid.NullUUID + MinConfirmations clnull.Uint32 + SignalCallback bool +} + +type TxMeta struct { + // Pipeline + JobID *int32 `json:"JobID,omitempty"` + FailOnRevert null.Bool `json:"FailOnRevert,omitempty"` + + // VRF + RequestID *common.Hash `json:"RequestID,omitempty"` + RequestTxHash *common.Hash `json:"RequestTxHash,omitempty"` + RequestIDs []common.Hash `json:"RequestIDs,omitempty"` + RequestTxHashes []common.Hash `json:"RequestTxHashes,omitempty"` + MaxLink *string `json:"MaxLink,omitempty"` + SubID *uint64 `json:"SubId,omitempty"` + GlobalSubID *string `json:"GlobalSubId,omitempty"` + MaxEth *string `json:"MaxEth,omitempty"` + ForceFulfilled *bool `json:"ForceFulfilled,omitempty"` + ForceFulfillmentAttempt *uint64 `json:"ForceFulfillmentAttempt,omitempty"` + + // Used for keepers + UpkeepID *string `json:"UpkeepID,omitempty"` + + // Used for Keystone Workflows + WorkflowExecutionID *string `json:"WorkflowExecutionID,omitempty"` + + // Forwarders + FwdrDestAddress *common.Address `json:"ForwarderDestAddress,omitempty"` + + // CCIP + MessageIDs []string `json:"MessageIDs,omitempty"` + SeqNumbers []uint64 `json:"SeqNumbers,omitempty"` + + // Dual Broadcast + DualBroadcast *bool `json:"DualBroadcast,omitempty"` + DualBroadcastParams *string `json:"DualBroadcastParams,omitempty"` +} + +type QueueingTxStrategy struct { + QueueSize uint32 + Subject uuid.NullUUID +} diff --git a/core/chains/evm/txmgr/builder.go b/core/chains/evm/txmgr/builder.go index 73c5614aba3..b5be9c829f2 100644 --- a/core/chains/evm/txmgr/builder.go +++ b/core/chains/evm/txmgr/builder.go @@ -18,6 +18,9 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/keystore" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/clientwrappers" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/txm/storage" evmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/types" ) @@ -40,6 +43,7 @@ func NewTxm( keyStore keystore.Eth, estimator gas.EvmFeeEstimator, headTracker latestAndFinalizedBlockHeadTracker, + txmv2wrapper TxManager, ) (txm TxManager, err error, ) { @@ -67,7 +71,7 @@ func NewTxm( if txConfig.ResendAfterThreshold() > 0 { evmResender = NewEvmResender(lggr, txStore, txmClient, evmTracker, keyStore, txmgr.DefaultResenderPollInterval, chainConfig, txConfig) } - txm = NewEvmTxm(chainID, txmCfg, txConfig, keyStore, lggr, checker, fwdMgr, txAttemptBuilder, txStore, evmBroadcaster, evmConfirmer, evmResender, evmTracker, evmFinalizer) + txm = NewEvmTxm(chainID, txmCfg, txConfig, keyStore, lggr, checker, fwdMgr, txAttemptBuilder, txStore, evmBroadcaster, evmConfirmer, evmResender, evmTracker, evmFinalizer, txmv2wrapper) return txm, nil } @@ -87,8 +91,59 @@ func NewEvmTxm( resender *Resender, tracker *Tracker, finalizer Finalizer, + txmv2wrapper TxManager, ) *Txm { - return txmgr.NewTxm(chainId, cfg, txCfg, keyStore, lggr, checkerFactory, fwdMgr, txAttemptBuilder, txStore, broadcaster, confirmer, resender, tracker, finalizer, client.NewTxError) + return txmgr.NewTxm(chainId, cfg, txCfg, keyStore, lggr, checkerFactory, fwdMgr, txAttemptBuilder, txStore, broadcaster, confirmer, resender, tracker, finalizer, client.NewTxError, txmv2wrapper) +} + +func NewTxmV2( + ds sqlutil.DataSource, + chainConfig ChainConfig, + fCfg FeeConfig, + txConfig config.Transactions, + txmV2Config config.TxmV2, + client client.Client, + lggr logger.Logger, + logPoller logpoller.LogPoller, + keyStore keystore.Eth, + estimator gas.EvmFeeEstimator, +) (TxManager, error) { + var fwdMgr *forwarders.FwdMgr + if txConfig.ForwardersEnabled() { + fwdMgr = forwarders.NewFwdMgr(ds, client, logPoller, lggr, chainConfig) + } else { + lggr.Info("ForwarderManager: Disabled") + } + + chainID := client.ConfiguredChainID() + + var stuckTxDetector txm.StuckTxDetector + if txConfig.AutoPurge().Enabled() { + stuckTxDetectorConfig := txm.StuckTxDetectorConfig{ + BlockTime: *txmV2Config.BlockTime(), + StuckTxBlockThreshold: *txConfig.AutoPurge().Threshold(), + DetectionURL: txConfig.AutoPurge().DetectionApiUrl().String(), + } + stuckTxDetector = txm.NewStuckTxDetector(lggr, chainConfig.ChainType(), stuckTxDetectorConfig) + } + + attemptBuilder := txm.NewAttemptBuilder(chainID, fCfg.PriceMax(), estimator, keyStore) + inMemoryStoreManager := storage.NewInMemoryStoreManager(lggr, chainID) + config := txm.Config{ + EIP1559: fCfg.EIP1559DynamicFees(), + BlockTime: *txmV2Config.BlockTime(), + //nolint:gosec // reuse existing config until migration + RetryBlockThreshold: uint16(fCfg.BumpThreshold()), + EmptyTxLimitDefault: fCfg.LimitDefault(), + } + var c txm.Client + if chainConfig.ChainType() == chaintype.ChainDualBroadcast { + c = clientwrappers.NewDualBroadcastClient(client, keyStore, txmV2Config.CustomURL()) + } else { + c = clientwrappers.NewChainClient(client) + } + t := txm.NewTxm(lggr, chainID, c, attemptBuilder, inMemoryStoreManager, stuckTxDetector, config, keyStore) + return txm.NewTxmOrchestrator(lggr, chainID, t, inMemoryStoreManager, fwdMgr, keyStore, attemptBuilder), nil } // NewEvmResender creates a new concrete EvmResender diff --git a/core/chains/evm/txmgr/confirmer_test.go b/core/chains/evm/txmgr/confirmer_test.go index ea251971860..d4d77c629bd 100644 --- a/core/chains/evm/txmgr/confirmer_test.go +++ b/core/chains/evm/txmgr/confirmer_test.go @@ -1594,6 +1594,180 @@ func TestEthConfirmer_RebroadcastWhereNecessary_TerminallyStuckError(t *testing. }) } +func TestEthConfirmer_EnsureConfirmedTransactionsInLongestChain(t *testing.T) { + t.Parallel() + + db := pgtest.NewSqlxDB(t) + txStore := cltest.NewTestTxStore(t, db) + ctx := tests.Context(t) + + ethKeyStore := cltest.NewKeyStore(t, db).Eth() + + _, fromAddress := cltest.MustInsertRandomKeyReturningState(t, ethKeyStore) + + ethClient := testutils.NewEthClientMockWithDefaultChain(t) + + gconfig, config := newTestChainScopedConfig(t) + ec := newEthConfirmer(t, txStore, ethClient, gconfig, config, ethKeyStore, nil) + + h8 := &evmtypes.Head{ + Number: 8, + Hash: testutils.NewHash(), + } + h9 := &evmtypes.Head{ + Hash: testutils.NewHash(), + Number: 9, + } + h9.Parent.Store(h8) + head := &evmtypes.Head{ + Hash: testutils.NewHash(), + Number: 10, + } + head.Parent.Store(h9) + t.Run("does nothing if there aren't any transactions", func(t *testing.T) { + require.NoError(t, ec.EnsureConfirmedTransactionsInLongestChain(tests.Context(t), head)) + }) + + t.Run("does nothing to unconfirmed transactions", func(t *testing.T) { + etx := cltest.MustInsertUnconfirmedEthTxWithBroadcastLegacyAttempt(t, txStore, 0, fromAddress) + + // Do the thing + require.NoError(t, ec.EnsureConfirmedTransactionsInLongestChain(tests.Context(t), head)) + + etx, err := txStore.FindTxWithAttempts(ctx, etx.ID) + require.NoError(t, err) + assert.Equal(t, txmgrcommon.TxUnconfirmed, etx.State) + }) + + t.Run("does nothing to confirmed transactions with receipts within head height of the chain and included in the chain", func(t *testing.T) { + etx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, txStore, 2, 1, fromAddress) + mustInsertEthReceipt(t, txStore, head.Number, head.Hash, etx.TxAttempts[0].Hash) + + // Do the thing + require.NoError(t, ec.EnsureConfirmedTransactionsInLongestChain(tests.Context(t), head)) + + etx, err := txStore.FindTxWithAttempts(ctx, etx.ID) + require.NoError(t, err) + assert.Equal(t, txmgrcommon.TxConfirmed, etx.State) + }) + + t.Run("does nothing to confirmed transactions that only have receipts older than the start of the chain", func(t *testing.T) { + etx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, txStore, 3, 1, fromAddress) + // Add receipt that is older than the lowest block of the chain + mustInsertEthReceipt(t, txStore, h8.Number-1, testutils.NewHash(), etx.TxAttempts[0].Hash) + + // Do the thing + require.NoError(t, ec.EnsureConfirmedTransactionsInLongestChain(tests.Context(t), head)) + + etx, err := txStore.FindTxWithAttempts(ctx, etx.ID) + require.NoError(t, err) + assert.Equal(t, txmgrcommon.TxConfirmed, etx.State) + }) + + t.Run("unconfirms and rebroadcasts transactions that have receipts within head height of the chain but not included in the chain", func(t *testing.T) { + etx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, txStore, 4, 1, fromAddress) + attempt := etx.TxAttempts[0] + // Include one within head height but a different block hash + mustInsertEthReceipt(t, txStore, head.Parent.Load().Number, testutils.NewHash(), attempt.Hash) + + ethClient.On("SendTransactionReturnCode", mock.Anything, mock.MatchedBy(func(tx *types.Transaction) bool { + atx, err := txmgr.GetGethSignedTx(attempt.SignedRawTx) + require.NoError(t, err) + // Keeps gas price and nonce the same + return atx.GasPrice().Cmp(tx.GasPrice()) == 0 && atx.Nonce() == tx.Nonce() + }), fromAddress).Return(commonclient.Successful, nil).Once() + + // Do the thing + require.NoError(t, ec.EnsureConfirmedTransactionsInLongestChain(tests.Context(t), head)) + + etx, err := txStore.FindTxWithAttempts(ctx, etx.ID) + require.NoError(t, err) + assert.Equal(t, txmgrcommon.TxUnconfirmed, etx.State) + require.Len(t, etx.TxAttempts, 1) + attempt = etx.TxAttempts[0] + assert.Equal(t, txmgrtypes.TxAttemptBroadcast, attempt.State) + }) + + t.Run("unconfirms and rebroadcasts transactions that have receipts within head height of chain but not included in the chain even if a receipt exists older than the start of the chain", func(t *testing.T) { + etx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, txStore, 5, 1, fromAddress) + attempt := etx.TxAttempts[0] + attemptHash := attempt.Hash + // Add receipt that is older than the lowest block of the chain + mustInsertEthReceipt(t, txStore, h8.Number-1, testutils.NewHash(), attemptHash) + // Include one within head height but a different block hash + mustInsertEthReceipt(t, txStore, head.Parent.Load().Number, testutils.NewHash(), attemptHash) + + ethClient.On("SendTransactionReturnCode", mock.Anything, mock.Anything, fromAddress).Return( + commonclient.Successful, nil).Once() + + // Do the thing + require.NoError(t, ec.EnsureConfirmedTransactionsInLongestChain(tests.Context(t), head)) + + etx, err := txStore.FindTxWithAttempts(ctx, etx.ID) + require.NoError(t, err) + assert.Equal(t, txmgrcommon.TxUnconfirmed, etx.State) + require.Len(t, etx.TxAttempts, 1) + attempt = etx.TxAttempts[0] + assert.Equal(t, txmgrtypes.TxAttemptBroadcast, attempt.State) + }) + + t.Run("if more than one attempt has a receipt (should not be possible but isn't prevented by database constraints) unconfirms and rebroadcasts only the attempt with the highest gas price", func(t *testing.T) { + etx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, txStore, 6, 1, fromAddress) + require.Len(t, etx.TxAttempts, 1) + // Sanity check to assert the included attempt has the lowest gas price + require.Less(t, etx.TxAttempts[0].TxFee.GasPrice.ToInt().Int64(), int64(30000)) + + attempt2 := newBroadcastLegacyEthTxAttempt(t, etx.ID, 30000) + attempt2.SignedRawTx = hexutil.MustDecode("0xf88c8301f3a98503b9aca000832ab98094f5fff180082d6017036b771ba883025c654bc93580a4daa6d556000000000000000000000000000000000000000000000000000000000000000026a0f25601065ee369b6470c0399a2334afcfbeb0b5c8f3d9a9042e448ed29b5bcbda05b676e00248b85faf4dd889f0e2dcf91eb867e23ac9eeb14a73f9e4c14972cdf") + attempt3 := newBroadcastLegacyEthTxAttempt(t, etx.ID, 40000) + attempt3.SignedRawTx = hexutil.MustDecode("0xf88c8301f3a88503b9aca0008316e36094151445852b0cfdf6a4cc81440f2af99176e8ad0880a4daa6d556000000000000000000000000000000000000000000000000000000000000000026a0dcb5a7ad52b96a866257134429f944c505820716567f070e64abb74899803855a04c13eff2a22c218e68da80111e1bb6dc665d3dea7104ab40ff8a0275a99f630d") + require.NoError(t, txStore.InsertTxAttempt(ctx, &attempt2)) + require.NoError(t, txStore.InsertTxAttempt(ctx, &attempt3)) + + // Receipt is within head height but a different block hash + mustInsertEthReceipt(t, txStore, head.Parent.Load().Number, testutils.NewHash(), attempt2.Hash) + // Receipt is within head height but a different block hash + mustInsertEthReceipt(t, txStore, head.Parent.Load().Number, testutils.NewHash(), attempt3.Hash) + + ethClient.On("SendTransactionReturnCode", mock.Anything, mock.MatchedBy(func(tx *types.Transaction) bool { + s, err := txmgr.GetGethSignedTx(attempt3.SignedRawTx) + require.NoError(t, err) + return tx.Hash() == s.Hash() + }), fromAddress).Return(commonclient.Successful, nil).Once() + + // Do the thing + require.NoError(t, ec.EnsureConfirmedTransactionsInLongestChain(tests.Context(t), head)) + + etx, err := txStore.FindTxWithAttempts(ctx, etx.ID) + require.NoError(t, err) + assert.Equal(t, txmgrcommon.TxUnconfirmed, etx.State) + require.Len(t, etx.TxAttempts, 3) + attempt1 := etx.TxAttempts[0] + assert.Equal(t, txmgrtypes.TxAttemptBroadcast, attempt1.State) + attempt2 = etx.TxAttempts[1] + assert.Equal(t, txmgrtypes.TxAttemptBroadcast, attempt2.State) + attempt3 = etx.TxAttempts[2] + assert.Equal(t, txmgrtypes.TxAttemptBroadcast, attempt3.State) + }) + + t.Run("if receipt has a block number that is in the future, does not mark for rebroadcast (the safe thing to do is simply wait until heads catches up)", func(t *testing.T) { + etx := cltest.MustInsertConfirmedEthTxWithLegacyAttempt(t, txStore, 7, 1, fromAddress) + attempt := etx.TxAttempts[0] + // Add receipt that is higher than head + mustInsertEthReceipt(t, txStore, head.Number+1, testutils.NewHash(), attempt.Hash) + + require.NoError(t, ec.EnsureConfirmedTransactionsInLongestChain(tests.Context(t), head)) + + etx, err := txStore.FindTxWithAttempts(ctx, etx.ID) + require.NoError(t, err) + assert.Equal(t, txmgrcommon.TxConfirmed, etx.State) + require.Len(t, etx.TxAttempts, 1) + attempt = etx.TxAttempts[0] + assert.Equal(t, txmgrtypes.TxAttemptBroadcast, attempt.State) + assert.Len(t, attempt.Receipts, 1) + }) +} + func TestEthConfirmer_ForceRebroadcast(t *testing.T) { t.Parallel() diff --git a/core/chains/evm/txmgr/evm_tx_store_test.go b/core/chains/evm/txmgr/evm_tx_store_test.go index a05cf3f9010..4c520c75f0b 100644 --- a/core/chains/evm/txmgr/evm_tx_store_test.go +++ b/core/chains/evm/txmgr/evm_tx_store_test.go @@ -1286,7 +1286,7 @@ func TestORM_UpdateTxUnstartedToInProgress(t *testing.T) { evmTxmCfg := txmgr.NewEvmTxmConfig(ccfg.EVM()) ec := evmtest.NewEthClientMockWithDefaultChain(t) txMgr := txmgr.NewEvmTxm(ec.ConfiguredChainID(), evmTxmCfg, ccfg.EVM().Transactions(), nil, logger.Test(t), nil, nil, - nil, txStore, nil, nil, nil, nil, nil) + nil, txStore, nil, nil, nil, nil, nil, nil) err := txMgr.XXXTestAbandon(fromAddress) // mark transaction as abandoned require.NoError(t, err) diff --git a/core/chains/evm/txmgr/txmgr_test.go b/core/chains/evm/txmgr/txmgr_test.go index 9ee2396846d..c02406a1c69 100644 --- a/core/chains/evm/txmgr/txmgr_test.go +++ b/core/chains/evm/txmgr/txmgr_test.go @@ -86,7 +86,8 @@ func makeTestEvmTxm( lp, keyStore, estimator, - ht) + ht, + nil) } func TestTxm_SendNativeToken_DoesNotSendToZero(t *testing.T) { diff --git a/core/chains/legacyevm/evm_txm.go b/core/chains/legacyevm/evm_txm.go index 3a96a9da937..37e9e9f7223 100644 --- a/core/chains/legacyevm/evm_txm.go +++ b/core/chains/legacyevm/evm_txm.go @@ -6,6 +6,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" evmconfig "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/chaintype" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups" httypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/headtracker/types" @@ -55,6 +56,24 @@ func newEvmTxm( } if opts.GenTxManager == nil { + var txmv2 txmgr.TxManager + if cfg.TxmV2().Enabled() { + txmv2, err = txmgr.NewTxmV2( + ds, + cfg, + txmgr.NewEvmTxmFeeConfig(cfg.GasEstimator()), + cfg.Transactions(), + cfg.TxmV2(), + client, + lggr, + logPoller, + opts.KeyStore, + estimator, + ) + if cfg.ChainType() != chaintype.ChainDualBroadcast { + return txmv2, estimator, err + } + } txm, err = txmgr.NewTxm( ds, cfg, @@ -68,7 +87,8 @@ func newEvmTxm( logPoller, opts.KeyStore, estimator, - headTracker) + headTracker, + txmv2) } else { txm = opts.GenTxManager(chainID) } diff --git a/core/config/docs/chains-evm.toml b/core/config/docs/chains-evm.toml index 62360cb02cb..d94a89e29fe 100644 --- a/core/config/docs/chains-evm.toml +++ b/core/config/docs/chains-evm.toml @@ -105,6 +105,14 @@ LogBroadcasterEnabled = true # Default # Set to zero to disable. NoNewFinalizedHeadsThreshold = '0' # Default +[EVM.TxmV2] +# Enabled enables TxmV2. +Enabled = false # Default +# BlockTime controls the frequency of the backfill loop of TxmV2. +BlockTime = '10s' # Example +# CustomURL configures the base url of a custom endpoint used by the ChainDualBroadcast chain type. +CustomURL = 'https://example.api.io' # Example + [EVM.Transactions] # ForwardersEnabled enables or disables sending transactions through forwarder contracts. ForwardersEnabled = false # Default diff --git a/core/config/docs/docs_test.go b/core/config/docs/docs_test.go index 9fca08ee99b..6ec0e2acb6b 100644 --- a/core/config/docs/docs_test.go +++ b/core/config/docs/docs_test.go @@ -92,6 +92,10 @@ func TestDoc(t *testing.T) { docDefaults.Workflow.GasLimitDefault = &gasLimitDefault docDefaults.NodePool.Errors = evmcfg.ClientErrors{} + // TxmV2 configs are only set if the feature is enabled + docDefaults.TxmV2.BlockTime = nil + docDefaults.TxmV2.CustomURL = nil + // Transactions.AutoPurge configs are only set if the feature is enabled docDefaults.Transactions.AutoPurge.DetectionApiUrl = nil docDefaults.Transactions.AutoPurge.Threshold = nil diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go index 76b80672dbb..5f49a8970c9 100644 --- a/core/services/chainlink/config_test.go +++ b/core/services/chainlink/config_test.go @@ -648,6 +648,9 @@ func TestConfig_Marshal(t *testing.T) { RPCBlockQueryDelay: ptr[uint16](10), NoNewFinalizedHeadsThreshold: &hour, + TxmV2: evmcfg.TxmV2{ + Enabled: ptr(false), + }, Transactions: evmcfg.Transactions{ MaxInFlight: ptr[uint32](19), MaxQueued: ptr[uint32](99), @@ -1111,6 +1114,9 @@ RPCBlockQueryDelay = 10 FinalizedBlockOffset = 16 NoNewFinalizedHeadsThreshold = '1h0m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = true MaxInFlight = 19 @@ -1395,6 +1401,12 @@ func TestConfig_full(t *testing.T) { got.EVM[c].Nodes[n].Order = ptr(int32(100)) } } + if got.EVM[c].TxmV2.BlockTime == nil { + got.EVM[c].TxmV2.BlockTime = new(commoncfg.Duration) + } + if got.EVM[c].TxmV2.CustomURL == nil { + got.EVM[c].TxmV2.CustomURL = new(commoncfg.URL) + } if got.EVM[c].Transactions.AutoPurge.Threshold == nil { got.EVM[c].Transactions.AutoPurge.Threshold = ptr(uint32(0)) } @@ -1461,7 +1473,7 @@ func TestConfig_Validate(t *testing.T) { - 1: 10 errors: - ChainType: invalid value (Foo): must not be set with this chain id - Nodes: missing: must have at least one node - - ChainType: invalid value (Foo): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, scroll, wemix, xlayer, zkevm, zksync, zircuit or omitted + - ChainType: invalid value (Foo): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, scroll, wemix, xlayer, zkevm, zksync, zircuit, dualBroadcast or omitted - HeadTracker.HistoryDepth: invalid value (30): must be greater than or equal to FinalizedBlockOffset - GasEstimator.BumpThreshold: invalid value (0): cannot be 0 if auto-purge feature is enabled for Foo - Transactions.AutoPurge.Threshold: missing: needs to be set if auto-purge feature is enabled for Foo @@ -1474,7 +1486,7 @@ func TestConfig_Validate(t *testing.T) { - 2: 5 errors: - ChainType: invalid value (Arbitrum): only "optimismBedrock" can be used with this chain id - Nodes: missing: must have at least one node - - ChainType: invalid value (Arbitrum): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, scroll, wemix, xlayer, zkevm, zksync, zircuit or omitted + - ChainType: invalid value (Arbitrum): must be one of arbitrum, astar, celo, gnosis, hedera, kroma, mantle, metis, optimismBedrock, scroll, wemix, xlayer, zkevm, zksync, zircuit, dualBroadcast or omitted - FinalityDepth: invalid value (0): must be greater than or equal to 1 - MinIncomingConfirmations: invalid value (0): must be greater than or equal to 1 - 3: 3 errors: diff --git a/core/services/chainlink/testdata/config-full.toml b/core/services/chainlink/testdata/config-full.toml index c6a5302a459..bbf6ca02103 100644 --- a/core/services/chainlink/testdata/config-full.toml +++ b/core/services/chainlink/testdata/config-full.toml @@ -330,6 +330,9 @@ RPCBlockQueryDelay = 10 FinalizedBlockOffset = 16 NoNewFinalizedHeadsThreshold = '1h0m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = true MaxInFlight = 19 diff --git a/core/services/chainlink/testdata/config-invalid.toml b/core/services/chainlink/testdata/config-invalid.toml index 967ef76de8e..8dc38dcb7d9 100644 --- a/core/services/chainlink/testdata/config-invalid.toml +++ b/core/services/chainlink/testdata/config-invalid.toml @@ -55,6 +55,9 @@ ChainType = 'Foo' FinalityDepth = 32 FinalizedBlockOffset = 64 +[EVM.TxmV2] +Enabled = true + [EVM.Transactions.AutoPurge] Enabled = true @@ -108,6 +111,9 @@ WSURL = 'ws://dupe.com' ChainID = '534352' ChainType = 'scroll' +[EVM.TxmV2] +Enabled = true + [EVM.Transactions.AutoPurge] Enabled = true DetectionApiUrl = '' diff --git a/core/services/chainlink/testdata/config-multi-chain-effective.toml b/core/services/chainlink/testdata/config-multi-chain-effective.toml index e8da8142181..1e317bb23ef 100644 --- a/core/services/chainlink/testdata/config-multi-chain-effective.toml +++ b/core/services/chainlink/testdata/config-multi-chain-effective.toml @@ -321,6 +321,9 @@ ReaperInterval = '1h0m0s' ReaperThreshold = '168h0m0s' ResendAfterThreshold = '1m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions.AutoPurge] Enabled = false @@ -423,6 +426,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -535,6 +541,9 @@ ReaperInterval = '1h0m0s' ReaperThreshold = '168h0m0s' ResendAfterThreshold = '1m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions.AutoPurge] Enabled = false diff --git a/core/services/headreporter/prometheus_reporter_test.go b/core/services/headreporter/prometheus_reporter_test.go index 9fd42baa15e..9d2a2bb18a6 100644 --- a/core/services/headreporter/prometheus_reporter_test.go +++ b/core/services/headreporter/prometheus_reporter_test.go @@ -135,7 +135,8 @@ func newLegacyChainContainer(t *testing.T, db *sqlx.DB) legacyevm.LegacyChainCon lp, keyStore, estimator, - ht) + ht, + nil) require.NoError(t, err) cfg := configtest.NewGeneralConfig(t, nil) diff --git a/core/services/vrf/delegate_test.go b/core/services/vrf/delegate_test.go index 7ef3febd021..03abaae29b6 100644 --- a/core/services/vrf/delegate_test.go +++ b/core/services/vrf/delegate_test.go @@ -82,7 +82,7 @@ func buildVrfUni(t *testing.T, db *sqlx.DB, cfg chainlink.GeneralConfig) vrfUniv btORM := bridges.NewORM(db) ks := keystore.NewInMemory(db, utils.FastScryptParams, lggr) _, dbConfig, evmConfig := txmgr.MakeTestConfigs(t) - txm, err := txmgr.NewTxm(db, evmConfig, evmConfig.GasEstimator(), evmConfig.Transactions(), nil, dbConfig, dbConfig.Listener(), ec, logger.TestLogger(t), nil, ks.Eth(), nil, nil) + txm, err := txmgr.NewTxm(db, evmConfig, evmConfig.GasEstimator(), evmConfig.Transactions(), nil, dbConfig, dbConfig.Listener(), ec, logger.TestLogger(t), nil, ks.Eth(), nil, nil, nil) orm := headtracker.NewORM(*testutils.FixtureChainID, db) require.NoError(t, orm.IdempotentInsertHead(testutils.Context(t), cltest.Head(51))) jrm := job.NewORM(db, prm, btORM, ks, lggr) diff --git a/core/services/vrf/v2/integration_v2_test.go b/core/services/vrf/v2/integration_v2_test.go index d9086a52a33..349e63a5a27 100644 --- a/core/services/vrf/v2/integration_v2_test.go +++ b/core/services/vrf/v2/integration_v2_test.go @@ -139,7 +139,7 @@ func makeTestTxm(t *testing.T, txStore txmgr.TestEvmTxStore, keyStore keystore.M _, _, evmConfig := txmgr.MakeTestConfigs(t) txmConfig := txmgr.NewEvmTxmConfig(evmConfig) txm := txmgr.NewEvmTxm(ec.ConfiguredChainID(), txmConfig, evmConfig.Transactions(), keyStore.Eth(), logger.TestLogger(t), nil, nil, - nil, txStore, nil, nil, nil, nil, nil) + nil, txStore, nil, nil, nil, nil, nil, nil) return txm } diff --git a/core/services/vrf/v2/listener_v2_test.go b/core/services/vrf/v2/listener_v2_test.go index b7a8710c4f8..e2a2a703de8 100644 --- a/core/services/vrf/v2/listener_v2_test.go +++ b/core/services/vrf/v2/listener_v2_test.go @@ -40,7 +40,7 @@ func makeTestTxm(t *testing.T, txStore txmgr.TestEvmTxStore, keyStore keystore.M ec := evmtest.NewEthClientMockWithDefaultChain(t) txmConfig := txmgr.NewEvmTxmConfig(evmConfig) txm := txmgr.NewEvmTxm(ec.ConfiguredChainID(), txmConfig, evmConfig.Transactions(), keyStore.Eth(), logger.TestLogger(t), nil, nil, - nil, txStore, nil, nil, nil, nil, nil) + nil, txStore, nil, nil, nil, nil, nil, nil) return txm } diff --git a/core/web/resolver/testdata/config-full.toml b/core/web/resolver/testdata/config-full.toml index bfb0dcb9961..0dcea06af76 100644 --- a/core/web/resolver/testdata/config-full.toml +++ b/core/web/resolver/testdata/config-full.toml @@ -330,6 +330,9 @@ RPCBlockQueryDelay = 10 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '15m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = true MaxInFlight = 19 diff --git a/core/web/resolver/testdata/config-multi-chain-effective.toml b/core/web/resolver/testdata/config-multi-chain-effective.toml index 074cb82482b..e26a3793d7a 100644 --- a/core/web/resolver/testdata/config-multi-chain-effective.toml +++ b/core/web/resolver/testdata/config-multi-chain-effective.toml @@ -321,6 +321,9 @@ ReaperInterval = '1h0m0s' ReaperThreshold = '168h0m0s' ResendAfterThreshold = '1m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions.AutoPurge] Enabled = false @@ -423,6 +426,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -535,6 +541,9 @@ ReaperInterval = '1h0m0s' ReaperThreshold = '168h0m0s' ResendAfterThreshold = '1m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions.AutoPurge] Enabled = false diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ff918468c07..212472236d8 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1997,6 +1997,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '9m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2101,6 +2104,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2205,6 +2211,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2309,6 +2318,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2414,6 +2426,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '13m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2522,6 +2537,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2626,6 +2644,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2731,6 +2752,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2835,6 +2859,9 @@ RPCBlockQueryDelay = 2 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '45s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -2938,6 +2965,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3041,6 +3071,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3145,6 +3178,9 @@ RPCBlockQueryDelay = 2 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '40s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3250,6 +3286,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '2m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3354,6 +3393,9 @@ RPCBlockQueryDelay = 2 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3458,6 +3500,9 @@ RPCBlockQueryDelay = 10 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '6m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3562,6 +3607,9 @@ RPCBlockQueryDelay = 15 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3667,6 +3715,9 @@ RPCBlockQueryDelay = 15 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3772,6 +3823,9 @@ RPCBlockQueryDelay = 2 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3876,6 +3930,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -3984,6 +4041,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4091,6 +4151,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4195,6 +4258,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4299,6 +4365,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4406,6 +4475,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4514,6 +4586,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4622,6 +4697,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4725,6 +4803,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4829,6 +4910,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -4933,6 +5017,9 @@ RPCBlockQueryDelay = 15 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5038,6 +5125,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '40s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5142,6 +5232,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '40s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5245,6 +5338,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5350,6 +5446,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '2h0m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5458,6 +5557,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5566,6 +5668,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5671,6 +5776,9 @@ RPCBlockQueryDelay = 2 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5774,6 +5882,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5878,6 +5989,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '15m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -5986,6 +6100,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '2m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6091,6 +6208,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6199,6 +6319,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6307,6 +6430,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6414,6 +6540,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '1m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6518,6 +6647,9 @@ RPCBlockQueryDelay = 2 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '1m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6622,6 +6754,9 @@ RPCBlockQueryDelay = 2 FinalizedBlockOffset = 2 NoNewFinalizedHeadsThreshold = '1m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6726,6 +6861,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '45m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6831,6 +6969,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '15m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -6942,6 +7083,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '15m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7051,6 +7195,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7154,6 +7301,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7259,6 +7409,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7365,6 +7518,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7469,6 +7625,9 @@ RPCBlockQueryDelay = 10 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7572,6 +7731,9 @@ RPCBlockQueryDelay = 10 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '12m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7676,6 +7838,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7784,6 +7949,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '12m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -7893,6 +8061,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -8001,6 +8172,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -8108,6 +8282,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -8215,6 +8392,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -8324,6 +8504,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -8433,6 +8616,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -8537,6 +8723,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '15m0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -8645,6 +8834,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -8749,6 +8941,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '0s' +[TxmV2] +Enabled = false + [Transactions] ForwardersEnabled = false MaxInFlight = 16 @@ -9032,6 +9227,33 @@ out-of-sync. Only applicable if `FinalityTagEnabled=true` Set to zero to disable. +## EVM.TxmV2 +```toml +[EVM.TxmV2] +Enabled = false # Default +BlockTime = '10s' # Example +CustomURL = 'https://example.api.io' # Example +``` + + +### Enabled +```toml +Enabled = false # Default +``` +Enabled enables TxmV2. + +### BlockTime +```toml +BlockTime = '10s' # Example +``` +BlockTime controls the frequency of the backfill loop of TxmV2. + +### CustomURL +```toml +CustomURL = 'https://example.api.io' # Example +``` +CustomURL configures the base url of a custom endpoint used by the ChainDualBroadcast chain type. + ## EVM.Transactions ```toml [EVM.Transactions] diff --git a/testdata/scripts/node/validate/defaults-override.txtar b/testdata/scripts/node/validate/defaults-override.txtar index bf8bece28bf..2e88078e68d 100644 --- a/testdata/scripts/node/validate/defaults-override.txtar +++ b/testdata/scripts/node/validate/defaults-override.txtar @@ -386,6 +386,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '9m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = false MaxInFlight = 16 diff --git a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar index 2e72ed7e9bb..a6db7907423 100644 --- a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar +++ b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar @@ -369,6 +369,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '9m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = false MaxInFlight = 16 diff --git a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar index 7b27328f7a6..be021a7f625 100644 --- a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar +++ b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar @@ -369,6 +369,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '9m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = false MaxInFlight = 16 diff --git a/testdata/scripts/node/validate/disk-based-logging.txtar b/testdata/scripts/node/validate/disk-based-logging.txtar index 83d23546175..98b0a17fce4 100644 --- a/testdata/scripts/node/validate/disk-based-logging.txtar +++ b/testdata/scripts/node/validate/disk-based-logging.txtar @@ -369,6 +369,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '9m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = false MaxInFlight = 16 diff --git a/testdata/scripts/node/validate/invalid.txtar b/testdata/scripts/node/validate/invalid.txtar index 5ea0aa289a8..d7002fb4754 100644 --- a/testdata/scripts/node/validate/invalid.txtar +++ b/testdata/scripts/node/validate/invalid.txtar @@ -359,6 +359,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '9m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = false MaxInFlight = 16 diff --git a/testdata/scripts/node/validate/valid.txtar b/testdata/scripts/node/validate/valid.txtar index 26641c0ef76..c2edb65da18 100644 --- a/testdata/scripts/node/validate/valid.txtar +++ b/testdata/scripts/node/validate/valid.txtar @@ -366,6 +366,9 @@ RPCBlockQueryDelay = 1 FinalizedBlockOffset = 0 NoNewFinalizedHeadsThreshold = '9m0s' +[EVM.TxmV2] +Enabled = false + [EVM.Transactions] ForwardersEnabled = false MaxInFlight = 16