diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index badd5a3759..b84bd209d9 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -61,8 +61,12 @@ import ( // allowed to produce in order to speed up calculations. const estimateGasErrorRatio = 0.015 -var errBlobTxNotSupported = errors.New("signing blob transactions not supported") -var errSubClosed = errors.New("chain subscription closed") +var ( + errBlobTxNotSupported = errors.New("signing blob transactions not supported") + errSubClosed = errors.New("chain subscription closed") + errPreconfNotEnabled = errors.New("preconf transactions are not accepted on this node") + errPrivateTxNotEnabled = errors.New("private transactions are not accepted on this node") +) // EthereumAPI provides an API to access Ethereum related information. type EthereumAPI struct { @@ -2189,6 +2193,13 @@ func (api *TransactionAPI) SendRawTransaction(ctx context.Context, input hexutil } } + // On a node that relays private transactions, route every raw tx through the + // private path so it never enters the public mempool. Private takes precedence + // over preconf; clients wanting preconf must use SendRawTransactionForPreconf. + if api.b.PrivateTxEnabled() { + return api.submitPrivateTransaction(ctx, tx) + } + hash, err := SubmitTransaction(ctx, api.b, tx) // If preconf is enabled, submit tx directly to BP @@ -2226,7 +2237,17 @@ func (api *TransactionAPI) SendRawTransactionSync(ctx context.Context, input hex sub := api.b.SubscribeChainEvent(ch) defer sub.Unsubscribe() - hash, err := SubmitTransaction(ctx, api.b, tx) + var ( + hash common.Hash + err error + ) + // On a private-tx-enabled node, route through the private path so the tx is + // not gossiped publicly. Receipt-wait below works the same either way. + if api.b.PrivateTxEnabled() { + hash, err = api.submitPrivateTransaction(ctx, tx) + } else { + hash, err = SubmitTransaction(ctx, api.b, tx) + } if err != nil { return nil, err } @@ -2300,10 +2321,11 @@ func (api *TransactionAPI) SendRawTransactionSync(ctx context.Context, input hex } // SendRawTransactionForPreconf will accept a preconf transaction from relay if enabled. It will -// offer a soft inclusion confirmation if the transaction is accepted into the pending pool. +// offer a soft inclusion confirmation if the transaction is accepted into the pending pool. Note +// that this is an internal API used only by the relay for submitting transactions. func (api *TransactionAPI) SendRawTransactionForPreconf(ctx context.Context, input hexutil.Bytes) (map[string]interface{}, error) { if !api.b.AcceptPreconfTxs() { - return nil, errors.New("preconf transactions are not accepted on this node") + return nil, errPreconfNotEnabled } tx := new(types.Transaction) @@ -2339,11 +2361,35 @@ func (api *TransactionAPI) SendRawTransactionForPreconf(ctx context.Context, inp }, nil } -// SendRawTransactionForPreconf will accept a private transaction from relay if enabled. It will ensure +// SendRawTransactionPreconf will accept a preconf transaction if allowed. It will offer a +// soft inclusion confirmation if the transaction is accepted into the pending pool. Note +// that this is an external API. +func (api *TransactionAPI) SendRawTransactionPreconf(ctx context.Context, input hexutil.Bytes) (common.Hash, error) { + if !api.b.PreconfEnabled() { + return common.Hash{}, errPreconfNotEnabled + } + + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(input); err != nil { + return common.Hash{}, err + } + + hash, err := SubmitTransaction(ctx, api.b, tx) + + // Submit tx directly to BP. Preconf processing mostly happens in background so don't + // float the error back to the user. + if err := api.b.SubmitTxForPreconf(tx); err != nil { + log.Error("Transaction accepted locally but submission for preconf failed", "err", err) + } + + return hash, err +} + +// SendRawTransactionPrivate will accept a private transaction from relay if enabled. It will ensure // that the transaction is not gossiped over public network. func (api *TransactionAPI) SendRawTransactionPrivate(ctx context.Context, input hexutil.Bytes) (common.Hash, error) { if !api.b.AcceptPrivateTxs() && !api.b.PrivateTxEnabled() { - return common.Hash{}, errors.New("private transactions are not accepted on this node") + return common.Hash{}, errPrivateTxNotEnabled } tx := new(types.Transaction) @@ -2351,7 +2397,13 @@ func (api *TransactionAPI) SendRawTransactionPrivate(ctx context.Context, input return common.Hash{}, err } - // Track the tx hash to ensure it is not gossiped in public + return api.submitPrivateTransaction(ctx, tx) +} + +// submitPrivateTransaction tracks the tx hash so it is not gossiped on the +// public network, submits it to the local pool, and (when this node is +// configured to relay) forwards it directly to block producers. +func (api *TransactionAPI) submitPrivateTransaction(ctx context.Context, tx *types.Transaction) (common.Hash, error) { api.b.RecordPrivateTx(tx.Hash()) hash, err := SubmitTransaction(ctx, api.b, tx) @@ -2377,7 +2429,7 @@ func (api *TransactionAPI) SendRawTransactionPrivate(ctx context.Context, input func (api *TransactionAPI) CheckPreconfStatus(ctx context.Context, hash common.Hash) (bool, error) { if !api.b.PreconfEnabled() { - return false, errors.New("preconf transactions are not accepted on this node") + return false, errPreconfNotEnabled } return api.b.CheckPreconfStatus(hash) } diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index ce26d7db3a..f61b16c989 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -5003,6 +5003,109 @@ func TestSendRawTransaction_PreconfPath(t *testing.T) { require.Equal(t, tx.Hash(), hash) }) } + +func TestSendRawTransaction_PrivatePath(t *testing.T) { + t.Parallel() + + t.Run("private enabled, routes through private path", func(t *testing.T) { + t.Parallel() + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + b.privateTxEnabled = true + + var recordCount, submitPrivateCount atomic.Int32 + b.recordPrivateTxFn = func(hash common.Hash) { recordCount.Add(1) } + b.submitPrivateTxFn = func(tx *types.Transaction) error { submitPrivateCount.Add(1); return nil } + + api := NewTransactionAPI(b, new(AddrLocker)) + raw, tx := makeSelfSignedRaw(t, api, b.acc.Address) + + hash, err := api.SendRawTransaction(context.Background(), raw) + require.NoError(t, err) + require.Equal(t, tx.Hash(), hash) + require.Equal(t, int32(1), recordCount.Load(), "RecordPrivateTx should be called once") + require.Equal(t, int32(1), submitPrivateCount.Load(), "SubmitPrivateTx should be called once") + }) + + t.Run("private precedence: preconf NOT called when both enabled", func(t *testing.T) { + t.Parallel() + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + b.privateTxEnabled = true + b.preconfEnabled = true + + var preconfCount, submitPrivateCount atomic.Int32 + b.submitTxForPreconfFn = func(tx *types.Transaction) error { preconfCount.Add(1); return nil } + b.submitPrivateTxFn = func(tx *types.Transaction) error { submitPrivateCount.Add(1); return nil } + + api := NewTransactionAPI(b, new(AddrLocker)) + raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) + + _, err := api.SendRawTransaction(context.Background(), raw) + require.NoError(t, err) + require.Equal(t, int32(1), submitPrivateCount.Load(), "SubmitPrivateTx should be called when private is enabled") + require.Equal(t, int32(0), preconfCount.Load(), "SubmitTxForPreconf should NOT be called when private path is taken") + }) + + t.Run("private enabled, SubmitPrivateTx fails returns wrapped error", func(t *testing.T) { + t.Parallel() + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + b.privateTxEnabled = true + b.submitPrivateTxFn = func(tx *types.Transaction) error { return errors.New("relay down") } + + api := NewTransactionAPI(b, new(AddrLocker)) + raw, tx := makeSelfSignedRaw(t, api, b.acc.Address) + + hash, err := api.SendRawTransaction(context.Background(), raw) + require.Error(t, err) + require.Contains(t, err.Error(), "private tx accepted locally, submission failed") + require.Contains(t, err.Error(), "relay down") + require.Equal(t, tx.Hash(), hash, "hash should be returned even on SubmitPrivateTx failure") + }) + + t.Run("private enabled, SendTx fails purges and returns error", func(t *testing.T) { + t.Parallel() + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + b.privateTxEnabled = true + b.sendTxErr = errors.New("pool full") + + var purgeCount, submitPrivateCount atomic.Int32 + b.purgePrivateTxFn = func(hash common.Hash) { purgeCount.Add(1) } + b.submitPrivateTxFn = func(tx *types.Transaction) error { submitPrivateCount.Add(1); return nil } + + api := NewTransactionAPI(b, new(AddrLocker)) + raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) + + _, err := api.SendRawTransaction(context.Background(), raw) + require.Error(t, err) + require.Contains(t, err.Error(), "pool full") + require.Equal(t, int32(1), purgeCount.Load(), "PurgePrivateTx should be called on SendTx failure") + require.Equal(t, int32(0), submitPrivateCount.Load(), "SubmitPrivateTx should NOT be called when SendTx fails") + }) + + t.Run("private enabled, ErrAlreadyKnown does not purge but still submits to BPs", func(t *testing.T) { + t.Parallel() + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + b.privateTxEnabled = true + b.sendTxErr = txpool.ErrAlreadyKnown + + var purgeCount, submitPrivateCount atomic.Int32 + b.purgePrivateTxFn = func(hash common.Hash) { purgeCount.Add(1) } + b.submitPrivateTxFn = func(tx *types.Transaction) error { submitPrivateCount.Add(1); return nil } + + api := NewTransactionAPI(b, new(AddrLocker)) + raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) + + _, err := api.SendRawTransaction(context.Background(), raw) + require.ErrorIs(t, err, txpool.ErrAlreadyKnown) + require.Equal(t, int32(0), purgeCount.Load(), "PurgePrivateTx should NOT be called for ErrAlreadyKnown") + require.Equal(t, int32(1), submitPrivateCount.Load(), "SubmitPrivateTx should still be called for ErrAlreadyKnown") + }) +} + func (b *testBackend) ProtocolVersion() uint { return 69 // ETH69 } @@ -5141,6 +5244,50 @@ func TestSendRawTransactionSync_Timeout(t *testing.T) { } } +func TestSendRawTransactionSync_PrivatePath(t *testing.T) { + t.Parallel() + + t.Run("private enabled, routes through private path and returns receipt", func(t *testing.T) { + t.Parallel() + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + b.privateTxEnabled = true + b.autoMine = true + + var recordCount, submitPrivateCount atomic.Int32 + b.recordPrivateTxFn = func(hash common.Hash) { recordCount.Add(1) } + b.submitPrivateTxFn = func(tx *types.Transaction) error { submitPrivateCount.Add(1); return nil } + + api := NewTransactionAPI(b, new(AddrLocker)) + raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) + + receipt, err := api.SendRawTransactionSync(context.Background(), raw, nil) + require.NoError(t, err) + require.NotNil(t, receipt) + require.Contains(t, receipt, "blockNumber") + require.Equal(t, int32(1), recordCount.Load(), "RecordPrivateTx should be called once") + require.Equal(t, int32(1), submitPrivateCount.Load(), "SubmitPrivateTx should be called once") + }) + + t.Run("private enabled, SubmitPrivateTx fails returns wrapped error and no receipt", func(t *testing.T) { + t.Parallel() + genesis := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{}} + b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil) + b.privateTxEnabled = true + b.autoMine = true + b.submitPrivateTxFn = func(tx *types.Transaction) error { return errors.New("relay down") } + + api := NewTransactionAPI(b, new(AddrLocker)) + raw, _ := makeSelfSignedRaw(t, api, b.acc.Address) + + receipt, err := api.SendRawTransactionSync(context.Background(), raw, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "private tx accepted locally, submission failed") + require.Contains(t, err.Error(), "relay down") + require.Nil(t, receipt) + }) +} + func TestCoinbase(t *testing.T) { t.Parallel() diff --git a/params/version.go b/params/version.go index 1a7e3d3dfb..7f992c7cf0 100644 --- a/params/version.go +++ b/params/version.go @@ -25,7 +25,7 @@ import ( const ( VersionMajor = 2 // Major version component of the current release VersionMinor = 7 // Minor version component of the current release - VersionPatch = 2 // Patch version component of the current release + VersionPatch = 3 // Patch version component of the current release VersionMeta = "" // Version metadata to append to the version string )