internal/ethapi: add eth_SendRawTransactionSync (#32830)

New RPC method eth_sendRawTransactionSync(rawTx, timeoutMs?) that
submits a signed tx and blocks until a receipt is available or a timeout
elapses.

Two CLI flags to tune server-side limits:

--rpc.txsync.defaulttimeout (default wait window)

--rpc.txsync.maxtimeout (upper bound; requests are clamped)

closes https://github.com/ethereum/go-ethereum/issues/32094

---------

Co-authored-by: aodhgan <gawnieg@gmail.com>
Co-authored-by: Sina Mahmoodi <itz.s1na@gmail.com>
This commit is contained in:
aodhgan 2025-10-16 08:34:47 -07:00 committed by GitHub
parent c37bd67019
commit ff54ca02de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 359 additions and 26 deletions

View file

@ -188,6 +188,8 @@ var (
utils.AllowUnprotectedTxs,
utils.BatchRequestLimit,
utils.BatchResponseMaxSize,
utils.RPCTxSyncDefaultTimeoutFlag,
utils.RPCTxSyncMaxTimeoutFlag,
}
metricsFlags = []cli.Flag{

View file

@ -615,6 +615,18 @@ var (
Value: ethconfig.Defaults.LogQueryLimit,
Category: flags.APICategory,
}
RPCTxSyncDefaultTimeoutFlag = &cli.DurationFlag{
Name: "rpc.txsync.defaulttimeout",
Usage: "Default timeout for eth_sendRawTransactionSync (e.g. 2s, 500ms)",
Value: ethconfig.Defaults.TxSyncDefaultTimeout,
Category: flags.APICategory,
}
RPCTxSyncMaxTimeoutFlag = &cli.DurationFlag{
Name: "rpc.txsync.maxtimeout",
Usage: "Maximum allowed timeout for eth_sendRawTransactionSync (e.g. 5m)",
Value: ethconfig.Defaults.TxSyncMaxTimeout,
Category: flags.APICategory,
}
// Authenticated RPC HTTP settings
AuthListenFlag = &cli.StringFlag{
Name: "authrpc.addr",
@ -1717,6 +1729,12 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
if ctx.IsSet(RPCGlobalLogQueryLimit.Name) {
cfg.LogQueryLimit = ctx.Int(RPCGlobalLogQueryLimit.Name)
}
if ctx.IsSet(RPCTxSyncDefaultTimeoutFlag.Name) {
cfg.TxSyncDefaultTimeout = ctx.Duration(RPCTxSyncDefaultTimeoutFlag.Name)
}
if ctx.IsSet(RPCTxSyncMaxTimeoutFlag.Name) {
cfg.TxSyncMaxTimeout = ctx.Duration(RPCTxSyncMaxTimeoutFlag.Name)
}
if !ctx.Bool(SnapshotFlag.Name) || cfg.SnapshotCache == 0 {
// If snap-sync is requested, this flag is also required
if cfg.SyncMode == ethconfig.SnapSync {

View file

@ -486,3 +486,11 @@ func (b *EthAPIBackend) StateAtBlock(ctx context.Context, block *types.Block, re
func (b *EthAPIBackend) StateAtTransaction(ctx context.Context, block *types.Block, txIndex int, reexec uint64) (*types.Transaction, vm.BlockContext, *state.StateDB, tracers.StateReleaseFunc, error) {
return b.eth.stateAtTransaction(ctx, block, txIndex, reexec)
}
func (b *EthAPIBackend) RPCTxSyncDefaultTimeout() time.Duration {
return b.eth.config.TxSyncDefaultTimeout
}
func (b *EthAPIBackend) RPCTxSyncMaxTimeout() time.Duration {
return b.eth.config.TxSyncMaxTimeout
}

View file

@ -49,27 +49,29 @@ var FullNodeGPO = gasprice.Config{
// Defaults contains default settings for use on the Ethereum main net.
var Defaults = Config{
HistoryMode: history.KeepAll,
SyncMode: SnapSync,
NetworkId: 0, // enable auto configuration of networkID == chainID
TxLookupLimit: 2350000,
TransactionHistory: 2350000,
LogHistory: 2350000,
StateHistory: params.FullImmutabilityThreshold,
DatabaseCache: 512,
TrieCleanCache: 154,
TrieDirtyCache: 256,
TrieTimeout: 60 * time.Minute,
SnapshotCache: 102,
FilterLogCacheSize: 32,
LogQueryLimit: 1000,
Miner: miner.DefaultConfig,
TxPool: legacypool.DefaultConfig,
BlobPool: blobpool.DefaultConfig,
RPCGasCap: 50000000,
RPCEVMTimeout: 5 * time.Second,
GPO: FullNodeGPO,
RPCTxFeeCap: 1, // 1 ether
HistoryMode: history.KeepAll,
SyncMode: SnapSync,
NetworkId: 0, // enable auto configuration of networkID == chainID
TxLookupLimit: 2350000,
TransactionHistory: 2350000,
LogHistory: 2350000,
StateHistory: params.FullImmutabilityThreshold,
DatabaseCache: 512,
TrieCleanCache: 154,
TrieDirtyCache: 256,
TrieTimeout: 60 * time.Minute,
SnapshotCache: 102,
FilterLogCacheSize: 32,
LogQueryLimit: 1000,
Miner: miner.DefaultConfig,
TxPool: legacypool.DefaultConfig,
BlobPool: blobpool.DefaultConfig,
RPCGasCap: 50000000,
RPCEVMTimeout: 5 * time.Second,
GPO: FullNodeGPO,
RPCTxFeeCap: 1, // 1 ether
TxSyncDefaultTimeout: 20 * time.Second,
TxSyncMaxTimeout: 1 * time.Minute,
}
//go:generate go run github.com/fjl/gencodec -type Config -formats toml -out gen_config.go
@ -183,6 +185,10 @@ type Config struct {
// OverrideVerkle (TODO: remove after the fork)
OverrideVerkle *uint64 `toml:",omitempty"`
// EIP-7966: eth_sendRawTransactionSync timeouts
TxSyncDefaultTimeout time.Duration `toml:",omitempty"`
TxSyncMaxTimeout time.Duration `toml:",omitempty"`
}
// CreateConsensusEngine creates a consensus engine for the given chain config.

View file

@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"math/big"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
@ -705,6 +706,39 @@ func (ec *Client) SendTransaction(ctx context.Context, tx *types.Transaction) er
return ec.c.CallContext(ctx, nil, "eth_sendRawTransaction", hexutil.Encode(data))
}
// SendTransactionSync submits a signed tx and waits for a receipt (or until
// the optional timeout elapses on the server side). If timeout == 0, the server
// uses its default.
func (ec *Client) SendTransactionSync(
ctx context.Context,
tx *types.Transaction,
timeout *time.Duration,
) (*types.Receipt, error) {
raw, err := tx.MarshalBinary()
if err != nil {
return nil, err
}
return ec.SendRawTransactionSync(ctx, raw, timeout)
}
func (ec *Client) SendRawTransactionSync(
ctx context.Context,
rawTx []byte,
timeout *time.Duration,
) (*types.Receipt, error) {
var ms *hexutil.Uint64
if timeout != nil {
if d := hexutil.Uint64(timeout.Milliseconds()); d > 0 {
ms = &d
}
}
var receipt types.Receipt
if err := ec.c.CallContext(ctx, &receipt, "eth_sendRawTransactionSync", hexutil.Bytes(rawTx), ms); err != nil {
return nil, err
}
return &receipt, nil
}
// RevertErrorData returns the 'revert reason' data of a contract call.
//
// This can be used with CallContract and EstimateGas, and only when the server is Geth.

View file

@ -55,6 +55,7 @@ import (
const estimateGasErrorRatio = 0.015
var errBlobTxNotSupported = errors.New("signing blob transactions not supported")
var errSubClosed = errors.New("chain subscription closed")
// EthereumAPI provides an API to access Ethereum related information.
type EthereumAPI struct {
@ -1666,6 +1667,92 @@ func (api *TransactionAPI) SendRawTransaction(ctx context.Context, input hexutil
return SubmitTransaction(ctx, api.b, tx)
}
// SendRawTransactionSync will add the signed transaction to the transaction pool
// and wait until the transaction has been included in a block and return the receipt, or the timeout.
func (api *TransactionAPI) SendRawTransactionSync(ctx context.Context, input hexutil.Bytes, timeoutMs *hexutil.Uint64) (map[string]interface{}, error) {
tx := new(types.Transaction)
if err := tx.UnmarshalBinary(input); err != nil {
return nil, err
}
ch := make(chan core.ChainEvent, 128)
sub := api.b.SubscribeChainEvent(ch)
subErrCh := sub.Err()
defer sub.Unsubscribe()
hash, err := SubmitTransaction(ctx, api.b, tx)
if err != nil {
return nil, err
}
maxTimeout := api.b.RPCTxSyncMaxTimeout()
defaultTimeout := api.b.RPCTxSyncDefaultTimeout()
timeout := defaultTimeout
if timeoutMs != nil && *timeoutMs > 0 {
req := time.Duration(*timeoutMs) * time.Millisecond
if req > maxTimeout {
timeout = maxTimeout
} else {
timeout = req
}
}
receiptCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Fast path.
if r, err := api.GetTransactionReceipt(receiptCtx, hash); err == nil && r != nil {
return r, nil
}
for {
select {
case <-receiptCtx.Done():
// If server-side wait window elapsed, return the structured timeout.
if errors.Is(receiptCtx.Err(), context.DeadlineExceeded) {
return nil, &txSyncTimeoutError{
msg: fmt.Sprintf("The transaction was added to the transaction pool but wasn't processed in %v.", timeout),
hash: hash,
}
}
return nil, receiptCtx.Err()
case err, ok := <-subErrCh:
if !ok {
return nil, errSubClosed
}
return nil, err
case ev, ok := <-ch:
if !ok {
return nil, errSubClosed
}
rs := ev.Receipts
txs := ev.Transactions
if len(rs) == 0 || len(rs) != len(txs) {
continue
}
for i := range rs {
if rs[i].TxHash == hash {
if rs[i].BlockNumber != nil && rs[i].BlockHash != (common.Hash{}) {
signer := types.LatestSigner(api.b.ChainConfig())
return MarshalReceipt(
rs[i],
rs[i].BlockHash,
rs[i].BlockNumber.Uint64(),
signer,
txs[i],
int(rs[i].TransactionIndex),
), nil
}
return api.GetTransactionReceipt(receiptCtx, hash)
}
}
}
}
}
// Sign calculates an ECDSA signature for:
// keccak256("\x19Ethereum Signed Message:\n" + len(message) + message).
//

View file

@ -440,6 +440,19 @@ type testBackend struct {
pending *types.Block
pendingReceipts types.Receipts
chainFeed *event.Feed
autoMine bool
sentTx *types.Transaction
sentTxHash common.Hash
syncDefaultTimeout time.Duration
syncMaxTimeout time.Duration
}
func fakeBlockHash(txh common.Hash) common.Hash {
return crypto.Keccak256Hash([]byte("testblock"), txh.Bytes())
}
func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.Engine, generator func(i int, b *core.BlockGen)) *testBackend {
@ -466,6 +479,7 @@ func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.E
acc: acc,
pending: blocks[n],
pendingReceipts: receipts[n],
chainFeed: new(event.Feed),
}
return backend
}
@ -587,19 +601,64 @@ func (b testBackend) GetEVM(ctx context.Context, state *state.StateDB, header *t
return vm.NewEVM(context, state, b.chain.Config(), *vmConfig)
}
func (b testBackend) SubscribeChainEvent(ch chan<- core.ChainEvent) event.Subscription {
panic("implement me")
return b.chainFeed.Subscribe(ch)
}
func (b testBackend) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription {
panic("implement me")
}
func (b testBackend) SendTx(ctx context.Context, signedTx *types.Transaction) error {
panic("implement me")
func (b *testBackend) SendTx(ctx context.Context, tx *types.Transaction) error {
b.sentTx = tx
b.sentTxHash = tx.Hash()
if b.autoMine {
// Synthesize a "mined" receipt at head+1
num := b.chain.CurrentHeader().Number.Uint64() + 1
receipt := &types.Receipt{
TxHash: tx.Hash(),
Status: types.ReceiptStatusSuccessful,
BlockHash: fakeBlockHash(tx.Hash()),
BlockNumber: new(big.Int).SetUint64(num),
TransactionIndex: 0,
CumulativeGasUsed: 21000,
GasUsed: 21000,
}
// Broadcast a ChainEvent that includes the receipts and txs
b.chainFeed.Send(core.ChainEvent{
Header: &types.Header{
Number: new(big.Int).SetUint64(num),
},
Receipts: types.Receipts{receipt},
Transactions: types.Transactions{tx},
})
}
return nil
}
func (b testBackend) GetCanonicalTransaction(txHash common.Hash) (bool, *types.Transaction, common.Hash, uint64, uint64) {
func (b *testBackend) GetCanonicalTransaction(txHash common.Hash) (bool, *types.Transaction, common.Hash, uint64, uint64) {
// Treat the auto-mined tx as canonically placed at head+1.
if b.autoMine && txHash == b.sentTxHash {
num := b.chain.CurrentHeader().Number.Uint64() + 1
return true, b.sentTx, fakeBlockHash(txHash), num, 0
}
tx, blockHash, blockNumber, index := rawdb.ReadCanonicalTransaction(b.db, txHash)
return tx != nil, tx, blockHash, blockNumber, index
}
func (b testBackend) GetCanonicalReceipt(tx *types.Transaction, blockHash common.Hash, blockNumber, blockIndex uint64) (*types.Receipt, error) {
func (b *testBackend) GetCanonicalReceipt(tx *types.Transaction, blockHash common.Hash, blockNumber, blockIndex uint64) (*types.Receipt, error) {
if b.autoMine && tx != nil && tx.Hash() == b.sentTxHash &&
blockHash == fakeBlockHash(tx.Hash()) &&
blockIndex == 0 &&
blockNumber == b.chain.CurrentHeader().Number.Uint64()+1 {
return &types.Receipt{
Type: tx.Type(),
Status: types.ReceiptStatusSuccessful,
CumulativeGasUsed: 21000,
GasUsed: 21000,
EffectiveGasPrice: big.NewInt(1),
BlockHash: blockHash,
BlockNumber: new(big.Int).SetUint64(blockNumber),
TransactionIndex: 0,
TxHash: tx.Hash(),
}, nil
}
return b.chain.GetCanonicalReceipt(tx, blockHash, blockNumber, blockIndex)
}
func (b testBackend) TxIndexDone() bool {
@ -3889,3 +3948,109 @@ func (b configTimeBackend) HeaderByNumber(_ context.Context, n rpc.BlockNumber)
func (b configTimeBackend) CurrentHeader() *types.Header {
return &types.Header{Time: b.time}
}
func (b *testBackend) RPCTxSyncDefaultTimeout() time.Duration {
if b.syncDefaultTimeout != 0 {
return b.syncDefaultTimeout
}
return 2 * time.Second
}
func (b *testBackend) RPCTxSyncMaxTimeout() time.Duration {
if b.syncMaxTimeout != 0 {
return b.syncMaxTimeout
}
return 5 * time.Minute
}
func (b *backendMock) RPCTxSyncDefaultTimeout() time.Duration { return 2 * time.Second }
func (b *backendMock) RPCTxSyncMaxTimeout() time.Duration { return 5 * time.Minute }
func makeSignedRaw(t *testing.T, api *TransactionAPI, from, to common.Address, value *big.Int) (hexutil.Bytes, *types.Transaction) {
t.Helper()
fillRes, err := api.FillTransaction(context.Background(), TransactionArgs{
From: &from,
To: &to,
Value: (*hexutil.Big)(value),
})
if err != nil {
t.Fatalf("FillTransaction failed: %v", err)
}
signRes, err := api.SignTransaction(context.Background(), argsFromTransaction(fillRes.Tx, from))
if err != nil {
t.Fatalf("SignTransaction failed: %v", err)
}
return signRes.Raw, signRes.Tx
}
// makeSelfSignedRaw is a convenience for a 0-ETH self-transfer.
func makeSelfSignedRaw(t *testing.T, api *TransactionAPI, addr common.Address) (hexutil.Bytes, *types.Transaction) {
return makeSignedRaw(t, api, addr, addr, big.NewInt(0))
}
func TestSendRawTransactionSync_Success(t *testing.T) {
t.Parallel()
genesis := &core.Genesis{
Config: params.TestChainConfig,
Alloc: types.GenesisAlloc{},
}
b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil)
b.autoMine = true // immediately “mines” the tx in-memory
api := NewTransactionAPI(b, new(AddrLocker))
raw, _ := makeSelfSignedRaw(t, api, b.acc.Address)
receipt, err := api.SendRawTransactionSync(context.Background(), raw, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receipt == nil {
t.Fatalf("expected non-nil receipt")
}
if _, ok := receipt["blockNumber"]; !ok {
t.Fatalf("expected blockNumber in receipt, got %#v", receipt)
}
}
func TestSendRawTransactionSync_Timeout(t *testing.T) {
t.Parallel()
genesis := &core.Genesis{
Config: params.TestChainConfig,
Alloc: types.GenesisAlloc{},
}
b := newTestBackend(t, 0, genesis, ethash.NewFaker(), nil)
b.autoMine = false // don't mine, should time out
api := NewTransactionAPI(b, new(AddrLocker))
raw, _ := makeSelfSignedRaw(t, api, b.acc.Address)
timeout := hexutil.Uint64(200) // 200ms
receipt, err := api.SendRawTransactionSync(context.Background(), raw, &timeout)
if receipt != nil {
t.Fatalf("expected nil receipt, got %#v", receipt)
}
if err == nil {
t.Fatalf("expected timeout error, got nil")
}
// assert error shape & data (hash)
var de interface {
ErrorCode() int
ErrorData() interface{}
}
if !errors.As(err, &de) {
t.Fatalf("expected data error with code/data, got %T %v", err, err)
}
if de.ErrorCode() != errCodeTxSyncTimeout {
t.Fatalf("expected code %d, got %d", errCodeTxSyncTimeout, de.ErrorCode())
}
tx := new(types.Transaction)
if e := tx.UnmarshalBinary(raw); e != nil {
t.Fatal(e)
}
if got, want := de.ErrorData(), tx.Hash().Hex(); got != want {
t.Fatalf("expected ErrorData=%s, got %v", want, got)
}
}

View file

@ -53,6 +53,8 @@ type Backend interface {
RPCEVMTimeout() time.Duration // global timeout for eth_call over rpc: DoS protection
RPCTxFeeCap() float64 // global tx fee cap for all transaction related APIs
UnprotectedAllowed() bool // allows only for EIP155 transactions.
RPCTxSyncDefaultTimeout() time.Duration
RPCTxSyncMaxTimeout() time.Duration
// Blockchain API
SetHead(number uint64)

View file

@ -21,6 +21,7 @@ import (
"fmt"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/vm"
@ -33,6 +34,11 @@ type revertError struct {
reason string // revert reason hex encoded
}
type txSyncTimeoutError struct {
msg string
hash common.Hash
}
// ErrorCode returns the JSON error code for a revert.
// See: https://ethereum.org/en/developers/docs/apis/json-rpc/#error-codes
func (e *revertError) ErrorCode() int {
@ -108,6 +114,7 @@ const (
errCodeInvalidParams = -32602
errCodeReverted = -32000
errCodeVMError = -32015
errCodeTxSyncTimeout = 4
)
func txValidationError(err error) *invalidTxError {
@ -168,3 +175,7 @@ type blockGasLimitReachedError struct{ message string }
func (e *blockGasLimitReachedError) Error() string { return e.message }
func (e *blockGasLimitReachedError) ErrorCode() int { return errCodeBlockGasLimitReached }
func (e *txSyncTimeoutError) Error() string { return e.msg }
func (e *txSyncTimeoutError) ErrorCode() int { return errCodeTxSyncTimeout }
func (e *txSyncTimeoutError) ErrorData() interface{} { return e.hash.Hex() }