Repository: smartcontractkit/chainlink
Pull request #16736: [DPA-1610] tx util and mcms util
Author: login: akuzni2
Created: 2025-03-12T02:40:36Z | Merged: 2025-03-14T19:09:14Z

DPA-1610 - Create abstraction to reduce boiler plate in generating MCMS proposals and executing transactions

Plan to add mcms util tests after this is merged and tested amongst a few changesets to ensure the approach/interface is working


Diff:
diff --git a/deployment/data-streams/utils/mcmsutil/mcmsutil.go b/deployment/data-streams/utils/mcmsutil/mcmsutil.go
new file mode 100644
index 00000000000..d3d9e5e44a5
--- /dev/null
+++ b/deployment/data-streams/utils/mcmsutil/mcmsutil.go
@@ -0,0 +1,66 @@
+package mcmsutil
+
+import (
+	"fmt"
+	"time"
+
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/utils/txutil"
+)
+
+// CreateMCMSProposal creates a new MCMS proposal with prepared (but not sent) transactions.
+func CreateMCMSProposal(e deployment.Environment, preparedTxs []*txutil.PreparedTx, mcmsMinDelay time.Duration, proposalName string) (*mcmslib.TimelockProposal, error) {
+	var chainSelectors []uint64
+	for _, tx := range preparedTxs {
+		chainSelectors = append(chainSelectors, tx.ChainSelector)
+	}
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(e, chainSelectors)
+	if err != nil {
+		return nil, err
+	}
+	inspectors, err := proposalutils.McmsInspectors(e)
+	if err != nil {
+		return nil, err
+	}
+
+	// Get MCMS state for each chain
+	timelockAddressesPerChain := map[uint64]string{}
+	proposerMcmsPerChain := map[uint64]string{}
+	for _, chainSelector := range chainSelectors {
+		state := mcmsStatePerChain[chainSelector]
+		timelockAddressesPerChain[chainSelector] = state.Timelock.Address().Hex()
+		proposerMcmsPerChain[chainSelector] = state.ProposerMcm.Address().Hex()
+	}
+
+	// Create batch operations from generated transactions
+	var batches []mcmstypes.BatchOperation
+	for _, tx := range preparedTxs {
+		batchOp, err := proposalutils.BatchOperationForChain(
+			tx.ChainSelector,
+			tx.Tx.To().Hex(),
+			tx.Tx.Data(),
+			tx.Tx.Value(),
+			tx.ContractType,
+			tx.Tags,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create batch operation: %w", err)
+		}
+		batches = append(batches, batchOp)
+	}
+
+	return proposalutils.BuildProposalFromBatchesV2(
+		e,
+		timelockAddressesPerChain,
+		proposerMcmsPerChain,
+		inspectors,
+		batches,
+		proposalName,
+		mcmsMinDelay,
+	)
+}
diff --git a/deployment/data-streams/utils/txutil/transaction.go b/deployment/data-streams/utils/txutil/transaction.go
new file mode 100644
index 00000000000..0d6997f86e0
--- /dev/null
+++ b/deployment/data-streams/utils/txutil/transaction.go
@@ -0,0 +1,90 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	// To execute the txs in parallel this would need to batch up the txs by chain to avoid nonce issues
+	for _, tx := range preparedTxs {
+		chain, exists := e.Chains[tx.ChainSelector]
+		if !exists {
+			return executeTxResults, fmt.Errorf("chain not found in env %d", tx.ChainSelector)
+		}
+		// The environment level context `e.GetContext()` is also an option to use here
+		ctx := chain.DeployerKey.Context
+		reconfiguredTx, err := reconfigureTx(ctx, chain, tx)
+		if err != nil {
+			return executeTxResults, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return executeTxResults, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+		err = chain.Client.SendTransaction(ctx, tx.Tx)
+		if err != nil {
+			return executeTxResults, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return executeTxResults, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use valid nonce, gas price and gas limit
+func reconfigureTx(ctx context.Context, chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(ctx, chain.DeployerKey.From, nil)
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to get nonce: %w", chain.Selector, err)
+	}
+	gasPrice, err := chain.Client.SuggestGasPrice(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to get gas price: %w", chain.Selector, err)
+	}
+	estimate, err := chain.Client.EstimateGas(ctx, ethereum.CallMsg{
+		From: chain.DeployerKey.From,
+		To:   preparedTx.Tx.To(),
+		Data: preparedTx.Tx.Data(),
+	})
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to estimate gas: %w", chain.Selector, err)
+	}
+
+	rawTx := gethtypes.NewTx(&gethtypes.LegacyTx{
+		Nonce:    nonce,
+		GasPrice: gasPrice,
+		Gas:      estimate,
+		To:       preparedTx.Tx.To(),
+		Value:    preparedTx.Tx.Value(),
+		Data:     preparedTx.Tx.Data(),
+	})
+	return rawTx, nil
+}
diff --git a/deployment/data-streams/utils/txutil/transaction_test.go b/deployment/data-streams/utils/txutil/transaction_test.go
new file mode 100644
index 00000000000..f24f043d0c0
--- /dev/null
+++ b/deployment/data-streams/utils/txutil/transaction_test.go
@@ -0,0 +1,141 @@
+package txutil
+
+import (
+	"context"
+	"math/big"
+	"testing"
+
+	"github.com/ethereum/go-ethereum/accounts/abi/bind"
+	"github.com/ethereum/go-ethereum/common"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/zap/zapcore"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/types"
+	"github.com/smartcontractkit/chainlink/deployment/environment/memory"
+	"github.com/smartcontractkit/chainlink/v2/core/logger"
+)
+
+func TestSignAndExecute_ETHTransfer(t *testing.T) {
+	lggr := logger.TestLogger(t)
+	e := memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{
+		Chains: 1,
+	})
+
+	testChain := e.AllChainSelectors()[0]
+	chain := e.Chains[testChain]
+
+	recipient := common.HexToAddress("0x742d35Cc6634C0532925a3b844Bc454e4438f44e")
+
+	initialBalance, err := chain.Client.BalanceAt(e.GetContext(), recipient, nil)
+	require.NoError(t, err)
+
+	// Basic ETH transfer transaction
+	value := big.NewInt(1000000000000000000) // 1 ETH
+
+	tx := gethtypes.NewTx(&gethtypes.DynamicFeeTx{
+		To:    &recipient,
+		Value: value,
+		Gas:   21000,
+	})
+
+	preparedTx := &PreparedTx{
+		Tx:            tx,
+		ChainSelector: testChain,
+		ContractType:  "ETHTransfer",
+	}
+
+	// Execute
+	results, err := SignAndExecute(e, []*PreparedTx{preparedTx})
+	require.NoError(t, err)
+	require.Len(t, results, 1)
+	require.Positive(t, results[0].BlockNumber)
+
+	// Verify
+	newBalance, err := chain.Client.BalanceAt(e.GetContext(), recipient, nil)
+	require.NoError(t, err)
+	require.Equal(t, big.NewInt(0).Add(initialBalance, value), newBalance)
+}
+
+func TestSignAndExecute_ContractInteraction(t *testing.T) {
+	ctx := context.Background()
+	lggr := logger.TestLogger(t)
+	e := memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{
+		Chains: 1,
+	})
+
+	testChain := e.AllChainSelectors()[0]
+	chain := e.Chains[testChain]
+
+	e, err := commonchangeset.Apply(t, e, nil,
+		commonchangeset.Configure(
+			deployment.CreateLegacyChangeSet(commonchangeset.DeployLinkToken),
+			[]uint64{testChain},
+		),
+	)
+	require.NoError(t, err)
+
+	addresses, err := e.ExistingAddresses.AddressesForChain(testChain)
+	require.NoError(t, err)
+
+	linkState, err := commonchangeset.MaybeLoadLinkTokenChainState(chain, addresses)
+	require.NoError(t, err)
+
+	// Mint some funds
+	// grant minter permissions
+	tx, err := linkState.LinkToken.GrantMintRole(chain.DeployerKey, chain.DeployerKey.From)
+	require.NoError(t, err)
+	_, err = deployment.ConfirmIfNoError(chain, tx, err)
+	require.NoError(t, err)
+
+	// Mint the deployer address some tokens
+	tx, err = linkState.LinkToken.Mint(chain.DeployerKey, chain.DeployerKey.From, big.NewInt(500))
+	require.NoError(t, err)
+	_, err = deployment.ConfirmIfNoError(chain, tx, err)
+	require.NoError(t, err)
+
+	// Deployer should have the tokens
+	endBalance, err := linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, chain.DeployerKey.From)
+	require.NoError(t, err)
+	expectedBalance := big.NewInt(500)
+	require.Equal(t, expectedBalance, endBalance)
+
+	r1 := common.HexToAddress("0x0fd8b81e3d1143ec7f1ce474827ab93c43523ea2")
+	r2 := common.HexToAddress("0x742d35Cc6634C0532925a3b844Bc454e4438f44e")
+	r3 := common.HexToAddress("0x3e5e9111ae8eb78fe1cc3bb8915d5d461f3ef9a9")
+	receivers := []common.Address{r1, r2, r3}
+	preparedTxs := make([]*PreparedTx, 0, len(receivers))
+
+	// Transfer some tokens to multiple receivers
+	for _, receiver := range receivers {
+		// This is the key part - generate a transaction with call data / arguments but do not send it
+		tx, err = linkState.LinkToken.Transfer(deployment.SimTransactOpts(), receiver, big.NewInt(100))
+		require.NoError(t, err)
+		preparedTxs = append(preparedTxs, &PreparedTx{
+			Tx:            tx,
+			ChainSelector: testChain,
+			ContractType:  types.LinkToken.String(),
+		})
+
+		// Ensure that the transaction is not sent to the chain
+		endBalance, err = linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, r1)
+		require.NoError(t, err)
+		require.Equal(t, big.NewInt(0).Int64(), endBalance.Int64())
+	}
+	require.Len(t, preparedTxs, len(receivers))
+
+	// Execute
+	results, err := SignAndExecute(e, preparedTxs)
+	require.NoError(t, err)
+	require.Len(t, results, len(receivers))
+
+	// Verify
+	for _, receiver := range receivers {
+		endBalance, err = linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, receiver)
+		require.NoError(t, err)
+		expectedBalance = big.NewInt(100)
+		require.Equal(t, expectedBalance, endBalance)
+	}
+}


Reviews:
user: login: ChrisAmora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:47:02Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T18:21:34Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T18:27:38Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T18:28:26Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:03:19Z
user: login: ChrisAmora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:31:38Z
user: login: ro-tex
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T18:32:34Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T20:54:46Z
user: login: ChrisAmora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T20:54:49Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T20:55:14Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T20:55:28Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T21:16:11Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T21:51:04Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T21:52:41Z
user: login: ChrisAmora
body: LGTM!
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:13:45Z
user: login: akuzni2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:48:03Z
user: login: ro-tex
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T08:16:03Z
user: login: ro-tex
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:16:28Z
user: login: ChrisAmora
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T15:14:46Z
user: login: samsondav
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T16:35:47Z
user: login: jmank88
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T16:49:09Z

Review comments:
diff_hunk: @@ -0,0 +1,115 @@
+package txutil
+
+import (
+	"fmt"
+	"math/big"
+
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/changeset"
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+)
+
+// TransactionGenerator defines how to generate a transaction for a specific chain
+type TransactionGenerator interface {
+	GenerateTransactions(e deployment.Environment, chainSelector uint64, mcms bool) ([]*gethtypes.Transaction, string, error)
+}
+
+// TransactionHandler handles executing transactions either directly or via MCMS proposal
+type TransactionHandler struct {
+	Environment    deployment.Environment
+	ChainSelectors []uint64
+	ContractType   string
+	ProposalName   string
+	MCMSConfig     *changeset.MCMSConfig
+}
+
+// HandleTransactions processes transactions either directly or through MCMS proposal
+func (h *TransactionHandler) HandleTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	if h.MCMSConfig != nil {
+		return h.createMCMSProposal(txGen)
+	}
+	return h.executeDirectTransactions(txGen)
+}
+
+// executeDirectTransactions executes transactions directly on the chain with the given deployer address
+func (h *TransactionHandler) executeDirectTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	for _, chainSelector := range h.ChainSelectors {
+		chain := h.Environment.Chains[chainSelector]
+
+		txs, _, err := txGen.GenerateTransactions(h.Environment, chainSelector, false)
+
+		for _, tx := range txs {
+			if err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+
+			if _, err := chain.Confirm(tx); err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+		}
+
+	}
+	return deployment.ChangesetOutput{}, nil
+}
+
+func (h *TransactionHandler) createMCMSProposal(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(h.Environment, h.ChainSelectors)
path: deployment/data-streams/changeset/txutil/tx_util.go
user: login: ChrisAmora
body: I think if we have MCMS in our viewstate we don't need this.
created_at: 2025-03-12T13:11:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+package txutil
+
+import (
+	"fmt"
+	"math/big"
+
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/changeset"
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+)
+
+// TransactionGenerator defines how to generate a transaction for a specific chain
+type TransactionGenerator interface {
+	GenerateTransactions(e deployment.Environment, chainSelector uint64, mcms bool) ([]*gethtypes.Transaction, string, error)
+}
+
+// TransactionHandler handles executing transactions either directly or via MCMS proposal
+type TransactionHandler struct {
+	Environment    deployment.Environment
+	ChainSelectors []uint64
+	ContractType   string
+	ProposalName   string
+	MCMSConfig     *changeset.MCMSConfig
+}
+
+// HandleTransactions processes transactions either directly or through MCMS proposal
+func (h *TransactionHandler) HandleTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	if h.MCMSConfig != nil {
+		return h.createMCMSProposal(txGen)
+	}
+	return h.executeDirectTransactions(txGen)
+}
+
+// executeDirectTransactions executes transactions directly on the chain with the given deployer address
+func (h *TransactionHandler) executeDirectTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	for _, chainSelector := range h.ChainSelectors {
+		chain := h.Environment.Chains[chainSelector]
+
+		txs, _, err := txGen.GenerateTransactions(h.Environment, chainSelector, false)
+
+		for _, tx := range txs {
+			if err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+
+			if _, err := chain.Confirm(tx); err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+		}
+
+	}
+	return deployment.ChangesetOutput{}, nil
+}
+
+func (h *TransactionHandler) createMCMSProposal(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(h.Environment, h.ChainSelectors)
+	if err != nil {
+		return deployment.ChangesetOutput{}, err
+	}
+
+	inspectors, err := proposalutils.McmsInspectors(h.Environment)
+	if err != nil {
+		return deployment.ChangesetOutput{}, err
+	}
+
+	var batches []mcmstypes.BatchOperation
+	timelockAddressesPerChain := map[uint64]string{}
+	proposerMcmsPerChain := map[uint64]string{}
+
+	for _, chainSelector := range h.ChainSelectors {
+		txs, toAddress, err := txGen.GenerateTransactions(h.Environment, chainSelector, true)
+		if err != nil {
+			return deployment.ChangesetOutput{}, err
+		}
+		for _, tx := range txs {
+			batchOp, err := proposalutils.BatchOperationForChain(
+				chainSelector,
+				toAddress,
+				tx.Data(),
+				big.NewInt(0),
path: deployment/data-streams/changeset/txutil/tx_util.go
user: login: ChrisAmora
body: Is there any case that the value is non zero?
created_at: 2025-03-12T13:13:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+package txutil
+
+import (
+	"fmt"
+	"math/big"
+
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/changeset"
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+)
+
+// TransactionGenerator defines how to generate a transaction for a specific chain
+type TransactionGenerator interface {
+	GenerateTransactions(e deployment.Environment, chainSelector uint64, mcms bool) ([]*gethtypes.Transaction, string, error)
+}
+
+// TransactionHandler handles executing transactions either directly or via MCMS proposal
+type TransactionHandler struct {
+	Environment    deployment.Environment
+	ChainSelectors []uint64
+	ContractType   string
+	ProposalName   string
+	MCMSConfig     *changeset.MCMSConfig
+}
+
+// HandleTransactions processes transactions either directly or through MCMS proposal
+func (h *TransactionHandler) HandleTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	if h.MCMSConfig != nil {
+		return h.createMCMSProposal(txGen)
+	}
+	return h.executeDirectTransactions(txGen)
+}
+
+// executeDirectTransactions executes transactions directly on the chain with the given deployer address
+func (h *TransactionHandler) executeDirectTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	for _, chainSelector := range h.ChainSelectors {
+		chain := h.Environment.Chains[chainSelector]
+
+		txs, _, err := txGen.GenerateTransactions(h.Environment, chainSelector, false)
+
+		for _, tx := range txs {
+			if err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
path: deployment/data-streams/changeset/txutil/tx_util.go
user: login: ChrisAmora
body: is this err correct?
created_at: 2025-03-12T13:15:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+package txutil
+
+import (
+	"fmt"
+	"math/big"
+
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/changeset"
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+)
+
+// TransactionGenerator defines how to generate a transaction for a specific chain
+type TransactionGenerator interface {
+	GenerateTransactions(e deployment.Environment, chainSelector uint64, mcms bool) ([]*gethtypes.Transaction, string, error)
+}
+
+// TransactionHandler handles executing transactions either directly or via MCMS proposal
+type TransactionHandler struct {
+	Environment    deployment.Environment
+	ChainSelectors []uint64
+	ContractType   string
+	ProposalName   string
+	MCMSConfig     *changeset.MCMSConfig
+}
+
+// HandleTransactions processes transactions either directly or through MCMS proposal
+func (h *TransactionHandler) HandleTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	if h.MCMSConfig != nil {
+		return h.createMCMSProposal(txGen)
+	}
+	return h.executeDirectTransactions(txGen)
+}
+
+// executeDirectTransactions executes transactions directly on the chain with the given deployer address
+func (h *TransactionHandler) executeDirectTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	for _, chainSelector := range h.ChainSelectors {
+		chain := h.Environment.Chains[chainSelector]
+
+		txs, _, err := txGen.GenerateTransactions(h.Environment, chainSelector, false)
+
+		for _, tx := range txs {
+			if err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
path: deployment/data-streams/changeset/txutil/tx_util.go
user: login: akuzni2
body: outdated - please review again 
created_at: 2025-03-12T18:21:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+package txutil
+
+import (
+	"fmt"
+	"math/big"
+
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/changeset"
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+)
+
+// TransactionGenerator defines how to generate a transaction for a specific chain
+type TransactionGenerator interface {
+	GenerateTransactions(e deployment.Environment, chainSelector uint64, mcms bool) ([]*gethtypes.Transaction, string, error)
+}
+
+// TransactionHandler handles executing transactions either directly or via MCMS proposal
+type TransactionHandler struct {
+	Environment    deployment.Environment
+	ChainSelectors []uint64
+	ContractType   string
+	ProposalName   string
+	MCMSConfig     *changeset.MCMSConfig
+}
+
+// HandleTransactions processes transactions either directly or through MCMS proposal
+func (h *TransactionHandler) HandleTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	if h.MCMSConfig != nil {
+		return h.createMCMSProposal(txGen)
+	}
+	return h.executeDirectTransactions(txGen)
+}
+
+// executeDirectTransactions executes transactions directly on the chain with the given deployer address
+func (h *TransactionHandler) executeDirectTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	for _, chainSelector := range h.ChainSelectors {
+		chain := h.Environment.Chains[chainSelector]
+
+		txs, _, err := txGen.GenerateTransactions(h.Environment, chainSelector, false)
+
+		for _, tx := range txs {
+			if err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+
+			if _, err := chain.Confirm(tx); err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+		}
+
+	}
+	return deployment.ChangesetOutput{}, nil
+}
+
+func (h *TransactionHandler) createMCMSProposal(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(h.Environment, h.ChainSelectors)
+	if err != nil {
+		return deployment.ChangesetOutput{}, err
+	}
+
+	inspectors, err := proposalutils.McmsInspectors(h.Environment)
+	if err != nil {
+		return deployment.ChangesetOutput{}, err
+	}
+
+	var batches []mcmstypes.BatchOperation
+	timelockAddressesPerChain := map[uint64]string{}
+	proposerMcmsPerChain := map[uint64]string{}
+
+	for _, chainSelector := range h.ChainSelectors {
+		txs, toAddress, err := txGen.GenerateTransactions(h.Environment, chainSelector, true)
+		if err != nil {
+			return deployment.ChangesetOutput{}, err
+		}
+		for _, tx := range txs {
+			batchOp, err := proposalutils.BatchOperationForChain(
+				chainSelector,
+				toAddress,
+				tx.Data(),
+				big.NewInt(0),
path: deployment/data-streams/changeset/txutil/tx_util.go
user: login: akuzni2
body: maybe - let me see what I can do
created_at: 2025-03-12T18:27:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+package txutil
+
+import (
+	"fmt"
+	"math/big"
+
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/changeset"
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+)
+
+// TransactionGenerator defines how to generate a transaction for a specific chain
+type TransactionGenerator interface {
+	GenerateTransactions(e deployment.Environment, chainSelector uint64, mcms bool) ([]*gethtypes.Transaction, string, error)
+}
+
+// TransactionHandler handles executing transactions either directly or via MCMS proposal
+type TransactionHandler struct {
+	Environment    deployment.Environment
+	ChainSelectors []uint64
+	ContractType   string
+	ProposalName   string
+	MCMSConfig     *changeset.MCMSConfig
+}
+
+// HandleTransactions processes transactions either directly or through MCMS proposal
+func (h *TransactionHandler) HandleTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	if h.MCMSConfig != nil {
+		return h.createMCMSProposal(txGen)
+	}
+	return h.executeDirectTransactions(txGen)
+}
+
+// executeDirectTransactions executes transactions directly on the chain with the given deployer address
+func (h *TransactionHandler) executeDirectTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	for _, chainSelector := range h.ChainSelectors {
+		chain := h.Environment.Chains[chainSelector]
+
+		txs, _, err := txGen.GenerateTransactions(h.Environment, chainSelector, false)
+
+		for _, tx := range txs {
+			if err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+
+			if _, err := chain.Confirm(tx); err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+		}
+
+	}
+	return deployment.ChangesetOutput{}, nil
+}
+
+func (h *TransactionHandler) createMCMSProposal(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(h.Environment, h.ChainSelectors)
path: deployment/data-streams/changeset/txutil/tx_util.go
user: login: akuzni2
body: If so - how would we load it?
created_at: 2025-03-12T18:28:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+package txutil
+
+import (
+	"fmt"
+	"math/big"
+
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/changeset"
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+)
+
+// TransactionGenerator defines how to generate a transaction for a specific chain
+type TransactionGenerator interface {
+	GenerateTransactions(e deployment.Environment, chainSelector uint64, mcms bool) ([]*gethtypes.Transaction, string, error)
+}
+
+// TransactionHandler handles executing transactions either directly or via MCMS proposal
+type TransactionHandler struct {
+	Environment    deployment.Environment
+	ChainSelectors []uint64
+	ContractType   string
+	ProposalName   string
+	MCMSConfig     *changeset.MCMSConfig
+}
+
+// HandleTransactions processes transactions either directly or through MCMS proposal
+func (h *TransactionHandler) HandleTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	if h.MCMSConfig != nil {
+		return h.createMCMSProposal(txGen)
+	}
+	return h.executeDirectTransactions(txGen)
+}
+
+// executeDirectTransactions executes transactions directly on the chain with the given deployer address
+func (h *TransactionHandler) executeDirectTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	for _, chainSelector := range h.ChainSelectors {
+		chain := h.Environment.Chains[chainSelector]
+
+		txs, _, err := txGen.GenerateTransactions(h.Environment, chainSelector, false)
+
+		for _, tx := range txs {
+			if err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+
+			if _, err := chain.Confirm(tx); err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+		}
+
+	}
+	return deployment.ChangesetOutput{}, nil
+}
+
+func (h *TransactionHandler) createMCMSProposal(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(h.Environment, h.ChainSelectors)
path: deployment/data-streams/changeset/txutil/tx_util.go
user: login: akuzni2
body: might put this into common utils later 
created_at: 2025-03-12T20:03:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+package txutil
+
+import (
+	"fmt"
+	"math/big"
+
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/changeset"
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+)
+
+// TransactionGenerator defines how to generate a transaction for a specific chain
+type TransactionGenerator interface {
+	GenerateTransactions(e deployment.Environment, chainSelector uint64, mcms bool) ([]*gethtypes.Transaction, string, error)
+}
+
+// TransactionHandler handles executing transactions either directly or via MCMS proposal
+type TransactionHandler struct {
+	Environment    deployment.Environment
+	ChainSelectors []uint64
+	ContractType   string
+	ProposalName   string
+	MCMSConfig     *changeset.MCMSConfig
+}
+
+// HandleTransactions processes transactions either directly or through MCMS proposal
+func (h *TransactionHandler) HandleTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	if h.MCMSConfig != nil {
+		return h.createMCMSProposal(txGen)
+	}
+	return h.executeDirectTransactions(txGen)
+}
+
+// executeDirectTransactions executes transactions directly on the chain with the given deployer address
+func (h *TransactionHandler) executeDirectTransactions(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	for _, chainSelector := range h.ChainSelectors {
+		chain := h.Environment.Chains[chainSelector]
+
+		txs, _, err := txGen.GenerateTransactions(h.Environment, chainSelector, false)
+
+		for _, tx := range txs {
+			if err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+
+			if _, err := chain.Confirm(tx); err != nil {
+				return deployment.ChangesetOutput{}, err
+			}
+		}
+
+	}
+	return deployment.ChangesetOutput{}, nil
+}
+
+func (h *TransactionHandler) createMCMSProposal(txGen TransactionGenerator) (deployment.ChangesetOutput, error) {
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(h.Environment, h.ChainSelectors)
path: deployment/data-streams/changeset/txutil/tx_util.go
user: login: ChrisAmora
body: state, err := LoadOnchainState(e)
But we have `LoadOnchainState` just in my branch for now.
created_at: 2025-03-12T20:31:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+package mcmsutil
+
+import (
+	"fmt"
+	"time"
+
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/utils/txutil"
+)
+
+// CreateMCMSProposal creates a new MCMS proposal with the given generated transactions.
+func CreateMCMSProposal(e deployment.Environment, preparedTxs []*txutil.PreparedTx, mcmsMinDelay time.Duration, proposalName string) (*mcmslib.TimelockProposal, error) {
+	var chainSelectors []uint64
+	for _, tx := range preparedTxs {
+		chainSelectors = append(chainSelectors, tx.ChainSelector)
+	}
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(e, chainSelectors)
+	if err != nil {
+		return nil, err
+	}
+	inspectors, err := proposalutils.McmsInspectors(e)
+	if err != nil {
+		return nil, err
+	}
+
+	// Get MCMS state for each chain
+	timelockAddressesPerChain := map[uint64]string{}
+	proposerMcmsPerChain := map[uint64]string{}
+	for _, chainSelector := range chainSelectors {
+		state := mcmsStatePerChain[chainSelector]
+		timelockAddressesPerChain[chainSelector] = state.Timelock.Address().Hex()
+		proposerMcmsPerChain[chainSelector] = state.ProposerMcm.Address().Hex()
+	}
+
+	// Create batch operations from generated transactions
+	var batches []mcmstypes.BatchOperation
+	for _, tx := range preparedTxs {
+		batchOp, err := proposalutils.BatchOperationForChain(
+			tx.ChainSelector,
+			tx.Tx.To().Hex(),
+			tx.Tx.Data(),
+			tx.Tx.Value(),
+			tx.ContractType,
+			tx.Tags,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create batch operation: %w", err)
+		}
+		batches = append(batches, batchOp)
+	}
+
+	proposal, err := proposalutils.BuildProposalFromBatchesV2(
+		e,
+		timelockAddressesPerChain,
+		proposerMcmsPerChain,
+		inspectors,
+		batches,
+		proposalName,
+		mcmsMinDelay,
+	)
+	if err != nil {
+		return nil, err
+	}
path: deployment/data-streams/utils/mcmsutil/mcmsutil.go
user: login: ro-tex
body: nit: this part is redundant, you can just `return proposalutils.BuildProposalFromBatchesV2(...)`.
created_at: 2025-03-13T18:18:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
path: deployment/data-streams/utils/txutil/transaction.go
user: login: ro-tex
body: I highly appreciate these comments and the context they provide. What might look obvious while coding, is often confusing when reading. Thanks!
created_at: 2025-03-13T18:21:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
path: deployment/data-streams/utils/txutil/transaction.go
user: login: ro-tex
body: Might be good to check that `chain` exists, i.e. it's not `nil`. That might happen if it's not configured for this `env`.
created_at: 2025-03-13T18:26:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
path: deployment/data-streams/utils/txutil/transaction.go
user: login: ro-tex
body: Do we care how long this function runs? I'm a little concerned about these `context.Background()` calls.

If we care about this, I would suggest we:
- name the function in such a way that we know it's gonna make external calls and be very slow, e.g. `threaded`, `ext`, etc prefix.
- call it in a goroutine, so we can `SIgnAndExecute` a bunch of txns in parallel, instead of waiting on the RPCs sequentially.
created_at: 2025-03-13T18:30:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
path: deployment/data-streams/utils/txutil/transaction.go
user: login: ro-tex
body: The same goes for `err := chain.Client.SendTransaction(context.Background(), tx.Tx)` in `Execute` - it's a blocking set of calls that we can do in parallel. These can be pretty slow.
created_at: 2025-03-13T18:32:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
path: deployment/data-streams/utils/txutil/transaction.go
user: login: ChrisAmora
body: @akuzni2 I think using `Client.NonceAt `without specifying a block you will get mined but not pending txs. I am not sure it will be a problem here but is good to keep in mind. You don't need to do in this PR but it would be good to have tests with multiple txs.
created_at: 2025-03-13T20:50:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,71 @@
+package mcmsutil
+
+import (
+	"fmt"
+	"time"
+
+	mcmslib "github.com/smartcontractkit/mcms"
+	mcmstypes "github.com/smartcontractkit/mcms/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+	commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset"
+	"github.com/smartcontractkit/chainlink/deployment/common/proposalutils"
+	"github.com/smartcontractkit/chainlink/deployment/data-streams/utils/txutil"
+)
+
+// CreateMCMSProposal creates a new MCMS proposal with the given generated transactions.
+func CreateMCMSProposal(e deployment.Environment, preparedTxs []*txutil.PreparedTx, mcmsMinDelay time.Duration, proposalName string) (*mcmslib.TimelockProposal, error) {
+	var chainSelectors []uint64
+	for _, tx := range preparedTxs {
+		chainSelectors = append(chainSelectors, tx.ChainSelector)
+	}
+	mcmsStatePerChain, err := commonchangeset.MaybeLoadMCMSWithTimelockState(e, chainSelectors)
+	if err != nil {
+		return nil, err
+	}
+	inspectors, err := proposalutils.McmsInspectors(e)
+	if err != nil {
+		return nil, err
+	}
+
+	// Get MCMS state for each chain
+	timelockAddressesPerChain := map[uint64]string{}
+	proposerMcmsPerChain := map[uint64]string{}
+	for _, chainSelector := range chainSelectors {
+		state := mcmsStatePerChain[chainSelector]
+		timelockAddressesPerChain[chainSelector] = state.Timelock.Address().Hex()
+		proposerMcmsPerChain[chainSelector] = state.ProposerMcm.Address().Hex()
+	}
+
+	// Create batch operations from generated transactions
+	var batches []mcmstypes.BatchOperation
+	for _, tx := range preparedTxs {
+		batchOp, err := proposalutils.BatchOperationForChain(
+			tx.ChainSelector,
+			tx.Tx.To().Hex(),
+			tx.Tx.Data(),
+			tx.Tx.Value(),
+			tx.ContractType,
+			tx.Tags,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create batch operation: %w", err)
+		}
+		batches = append(batches, batchOp)
+	}
+
+	proposal, err := proposalutils.BuildProposalFromBatchesV2(
+		e,
+		timelockAddressesPerChain,
+		proposerMcmsPerChain,
+		inspectors,
+		batches,
+		proposalName,
+		mcmsMinDelay,
+	)
+	if err != nil {
+		return nil, err
+	}
path: deployment/data-streams/utils/mcmsutil/mcmsutil.go
user: login: akuzni2
body: done!
created_at: 2025-03-13T20:54:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to get nonce: %w", chain.Selector, err)
+	}
+	gasPrice, err := chain.Client.SuggestGasPrice(context.Background())
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to get gas price: %w", chain.Selector, err)
+	}
+	estimate, err := chain.Client.EstimateGas(context.Background(), ethereum.CallMsg{
+		From: chain.DeployerKey.From,
+		To:   preparedTx.Tx.To(),
+		Data: preparedTx.Tx.Data(),
+	})
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to estimate gas: %w", chain.Selector, err)
+	}
+
+	rawTx := gethtypes.NewTx(&gethtypes.LegacyTx{
path: deployment/data-streams/utils/txutil/transaction.go
user: login: ChrisAmora
body: We should probably check the chain to see if it's using legacy txs or not. Zksync had some weird behavior in G++ but we can revisit this later as well.
created_at: 2025-03-13T20:54:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
path: deployment/data-streams/utils/txutil/transaction.go
user: login: akuzni2
body: Thank you! 
created_at: 2025-03-13T20:55:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
path: deployment/data-streams/utils/txutil/transaction.go
user: login: akuzni2
body: good catch I've added the check
created_at: 2025-03-13T20:55:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
path: deployment/data-streams/utils/txutil/transaction.go
user: login: akuzni2
body: I think we should. 2 options I am thinking of 
1) use the parent environment context `e.getContext()`
2) use the deployer transaction options context `chain.DeployerKey.Context` (this is the one I implemented)

It's not clear actually how these contexts are related - looking at where we set this in CLD the deployerKey.Context is not set - which means all current contract calls will use background context by default [here](https://github.com/ethereum/go-ethereum/blob/f9f1172d5988e9ef40dd5124a72c99bcbee01991/accounts/abi/bind/auth.go#L162). But that doesn't mean we can't set it where we declare the onchain client

created_at: 2025-03-13T21:16:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
path: deployment/data-streams/utils/txutil/transaction.go
user: login: akuzni2
body: Definitely - the existing OnChainClient doesn't currently have the standard eth client methods `PendingNonceAt` which could be useful here. I've extended the test to do multiple transactions. If this starts to behave in unexpected ways we could just abandon this approach. 
created_at: 2025-03-13T21:51:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to get nonce: %w", chain.Selector, err)
+	}
+	gasPrice, err := chain.Client.SuggestGasPrice(context.Background())
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to get gas price: %w", chain.Selector, err)
+	}
+	estimate, err := chain.Client.EstimateGas(context.Background(), ethereum.CallMsg{
+		From: chain.DeployerKey.From,
+		To:   preparedTx.Tx.To(),
+		Data: preparedTx.Tx.Data(),
+	})
+	if err != nil {
+		return nil, fmt.Errorf("chain %d: failed to estimate gas: %w", chain.Selector, err)
+	}
+
+	rawTx := gethtypes.NewTx(&gethtypes.LegacyTx{
path: deployment/data-streams/utils/txutil/transaction.go
user: login: akuzni2
body: That's good to know -  as mentioned in the other PR comment If this util starts to behave in unexpected ways we could just abandon it 
created_at: 2025-03-13T21:52:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
path: deployment/data-streams/utils/txutil/transaction.go
user: login: akuzni2
body: I like the idea of parallelizing the txs but don't want to over do it right now - if we do we need to batch up the txs by chain to avoid nonce issues
created_at: 2025-03-13T22:48:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package txutil
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/ethereum/go-ethereum"
+	gethtypes "github.com/ethereum/go-ethereum/core/types"
+
+	"github.com/smartcontractkit/chainlink/deployment"
+)
+
+// PreparedTx represents a transaction that was prepared but not sent to the chain. This is intended to be
+// either signed and executed directly or bundled into an MCMS operation.
+type PreparedTx struct {
+	Tx            *gethtypes.Transaction
+	ChainSelector uint64
+	ContractType  string
+	Tags          []string
+}
+
+type ExecuteTxResult struct {
+	Tx          *PreparedTx
+	BlockNumber uint64
+}
+
+// SignAndExecute signs and then executes transactions directly on the chain with the given deployer key configured
+// for the chain. The transactions should not be already sent to the chain.
+func SignAndExecute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		reconfiguredTx, err := reconfigureTx(chain, tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to reconfigure transaction: %w", chain.Selector, err)
+		}
+		signedTx, err := chain.DeployerKey.Signer(chain.DeployerKey.From, reconfiguredTx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to sign transaction: %w", chain.Selector, err)
+		}
+		tx.Tx = signedTx
+	}
+	return Execute(e, preparedTxs)
+}
+
+// Execute executes the prepared transactions directly on the chain
+// the transactions should not be already sent to the chain
+func Execute(e deployment.Environment, preparedTxs []*PreparedTx) ([]ExecuteTxResult, error) {
+	var executeTxResults []ExecuteTxResult
+	for _, tx := range preparedTxs {
+		chain := e.Chains[tx.ChainSelector]
+		err := chain.Client.SendTransaction(context.Background(), tx.Tx)
+		tx.Tx.ChainId()
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to send transaction: %w", chain.Selector, err)
+		}
+		blockNumber, err := chain.Confirm(tx.Tx)
+		if err != nil {
+			return nil, fmt.Errorf("chain %d: failed to confirm transaction: %w", chain.Selector, err)
+		}
+		e.Logger.Infow("Transaction confirmed", "blockNumber", blockNumber, "tx", tx)
+		executeTxResults = append(executeTxResults, ExecuteTxResult{Tx: tx, BlockNumber: blockNumber})
+	}
+
+	return executeTxResults, nil
+}
+
+// reconfigureTx takes the tx `call data` and reconfigures the transaction to use updated correct nonce, gas price and gas limit
+func reconfigureTx(chain deployment.Chain, preparedTx *PreparedTx) (*gethtypes.Transaction, error) {
+	nonce, err := chain.Client.NonceAt(context.Background(), chain.DeployerKey.From, nil)
path: deployment/data-streams/utils/txutil/transaction.go
user: login: ro-tex
body: I see. Makes sense. Let's drop a comment at those two places, so don't forget to do it later.
created_at: 2025-03-14T08:16:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-12T02:53:12Z
body: 









<cicore>

## AER Report: [CI Core](https://github.com/smartcontractkit/chainlink/actions/runs/13826344204) ran successfully :white_check_mark:

[aer_workflow](https://github.com/smartcontractkit/chainlink/actions/runs/13826493850/job/38682336480) , [commit](https://github.com/smartcontractkit/chainlink/commit/790dc4d354de3b6290fa204de5b0cdf645537092)

</cicore>
  
    








<operatoruici>

## AER Report: [Operator UI CI](https://github.com/smartcontractkit/chainlink/actions/runs/13826344189) ran successfully :white_check_mark:

[aer_workflow](https://github.com/smartcontractkit/chainlink/actions/runs/13826358305/job/38681959883) , [commit](https://github.com/smartcontractkit/chainlink/commit/790dc4d354de3b6290fa204de5b0cdf645537092)

</operatoruici>

author_association: CONTRIBUTOR
user: login: ChrisAmora
created_at: 2025-03-12T20:34:45Z
body: @akuzni2 
Can you make a test doing something like (deploy a contract + transfer eth) just to see how the API would work on practice.
author_association: CONTRIBUTOR
user: login: cl-sonarqube-production[bot]
created_at: 2025-03-14T15:17:13Z
body: ## [![Quality Gate passed](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarqube.main.prod.cldev.sh/dashboard?id=smartcontractkit_chainlink&pullRequest=16736) **Quality Gate passed**  
Issues  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=16736&resolved=false) [0 New issues](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=16736&resolved=false)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/fixed-16px.png '')](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&fixedInPullRequest=16736) [0 Fixed issues](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&fixedInPullRequest=16736)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/accepted-16px.png '')](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=16736&issueStatuses=ACCEPTED) [0 Accepted issues](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=16736&issueStatuses=ACCEPTED)

Measures  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.main.prod.cldev.sh/security_hotspots?id=smartcontractkit_chainlink&pullRequest=16736) [0 Security Hotspots](https://sonarqube.main.prod.cldev.sh/security_hotspots?id=smartcontractkit_chainlink&pullRequest=16736)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=16736&metric=new_coverage&view=list) [0.0% Coverage on New Code](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=16736&metric=new_coverage&view=list)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=16736&metric=new_duplicated_lines_density&view=list) [0.0% Duplication on New Code](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=16736&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube](https://sonarqube.main.prod.cldev.sh/dashboard?id=smartcontractkit_chainlink&pullRequest=16736)


author_association: NONE