From 4b0cf93766a437040ed81e206e4b901b57002975 Mon Sep 17 00:00:00 2001 From: aodhgan Date: Wed, 1 Oct 2025 16:43:46 -0700 Subject: [PATCH 1/8] feat: initial api with default timeouts --- eth/api_backend.go | 8 ++++++ internal/ethapi/api.go | 56 ++++++++++++++++++++++++++++++++++++++ internal/ethapi/backend.go | 2 ++ internal/ethapi/errors.go | 13 +++++++++ 4 files changed, 79 insertions(+) diff --git a/eth/api_backend.go b/eth/api_backend.go index 3ae73e78af1..cbcf428d13e 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -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 2 * time.Second +} + +func (b *EthAPIBackend) RPCTxSyncMaxTimeout() time.Duration { + return 5 * time.Minute +} diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 2432bb70b85..9936328bfe4 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -1636,6 +1636,62 @@ 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 for the transaction to be mined until the timeout (in milliseconds) is reached. +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 + } + hash, err := SubmitTransaction(ctx, api.b, tx) + if err != nil { + return nil, err + } + + // compute effective timeout + max := api.b.RPCTxSyncMaxTimeout() + def := api.b.RPCTxSyncDefaultTimeout() + + eff := def + if timeoutMs != nil && *timeoutMs > 0 { + req := time.Duration(*timeoutMs) * time.Millisecond + if req > max { + eff = max + } else { + eff = req // allow shorter than default + } + } + + // Wait for receipt until timeout + receiptCtx, cancel := context.WithTimeout(ctx, eff) + defer cancel() + + ticker := time.NewTicker(time.Millisecond * 100) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + receipt, getErr := api.GetTransactionReceipt(receiptCtx, hash) + // If tx-index still building, keep polling + if getErr != nil && !errors.Is(getErr, NewTxIndexingError()) { + // transient or other error: just keep polling + } + if receipt != nil { + return receipt, nil + } + case <-receiptCtx.Done(): + if err := ctx.Err(); err != nil { + return nil, err // context canceled / deadline exceeded upstream + } + return nil, &txSyncTimeoutError{ + msg: fmt.Sprintf("The transaction was added to the transaction pool but wasn't processed in %v.", eff), + hash: hash, + } + } + } +} + // Sign calculates an ECDSA signature for: // keccak256("\x19Ethereum Signed Message:\n" + len(message) + message). // diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index f709a1fcdcc..af3d592b82b 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -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) diff --git a/internal/ethapi/errors.go b/internal/ethapi/errors.go index 154938fa0e3..235f5b3fa8d 100644 --- a/internal/ethapi/errors.go +++ b/internal/ethapi/errors.go @@ -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,9 @@ 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 } + +// ErrorData should be JSON-safe; return the 0x-hex string. +func (e *txSyncTimeoutError) ErrorData() interface{} { return e.hash.Hex() } From 5fda998e3a19c449f2629d5070e8c1efae14da26 Mon Sep 17 00:00:00 2001 From: aodhgan Date: Wed, 1 Oct 2025 17:41:47 -0700 Subject: [PATCH 2/8] add happy and timeout unit tests --- internal/ethapi/api_test.go | 142 +++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index 2e0b1c3bc08..2b3a9c94dde 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -440,6 +440,14 @@ type testBackend struct { pending *types.Block pendingReceipts types.Receipts + + // test-only fields for SendRawTransactionSync + autoMine bool + mined bool + syncDefaultTO time.Duration + syncMaxTO time.Duration + sentTx *types.Transaction + sentTxHash common.Hash } func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.Engine, generator func(i int, b *core.BlockGen)) *testBackend { @@ -592,14 +600,38 @@ func (b testBackend) SubscribeChainEvent(ch chan<- core.ChainEvent) event.Subscr 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 { + b.mined = true + } + return nil } func (b testBackend) GetCanonicalTransaction(txHash common.Hash) (bool, *types.Transaction, common.Hash, uint64, uint64) { + // in-memory fast path for tests + if b.mined && txHash == b.sentTxHash { + return true, b.sentTx, common.HexToHash("0x01"), 1, 0 + } + // fallback to existing DB-backed path 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) { + // In-memory fast path used by tests + if b.mined && tx != nil && tx.Hash() == b.sentTxHash && blockHash == common.HexToHash("0x01") && blockNumber == 1 && blockIndex == 0 { + 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, + }, nil + } + // Fallback: use the chain's canonical receipt (DB-backed) return b.chain.GetCanonicalReceipt(tx, blockHash, blockNumber, blockIndex) } func (b testBackend) TxIndexDone() bool { @@ -3885,3 +3917,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.syncDefaultTO != 0 { + return b.syncDefaultTO + } + return 2 * time.Second +} +func (b *testBackend) RPCTxSyncMaxTimeout() time.Duration { + if b.syncMaxTO != 0 { + return b.syncMaxTO + } + 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) + } +} From f5e4d90ea0d49c44af0fd497e6b5608e43d9ecf6 Mon Sep 17 00:00:00 2001 From: aodhgan Date: Wed, 1 Oct 2025 21:49:33 -0700 Subject: [PATCH 3/8] add flag --- cmd/geth/main.go | 2 ++ cmd/utils/flags.go | 18 ++++++++++++++++ eth/ethconfig/config.go | 48 +++++++++++++++++++++++------------------ 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 2465b52ad1f..2a36c7851c2 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -188,6 +188,8 @@ var ( utils.AllowUnprotectedTxs, utils.BatchRequestLimit, utils.BatchResponseMaxSize, + utils.RPCTxSyncDefaultFlag, + utils.RPCTxSyncMaxFlag, } metricsFlags = []cli.Flag{ diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index c9da08578c9..346990a2efe 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -615,6 +615,18 @@ var ( Value: ethconfig.Defaults.LogQueryLimit, Category: flags.APICategory, } + RPCTxSyncDefaultFlag = &cli.DurationFlag{ + Name: "rpc.txsync.default", + Usage: "Default timeout for eth_sendRawTransactionSync (e.g. 2s, 500ms)", + Value: ethconfig.Defaults.TxSyncDefaultTimeout, + Category: flags.APICategory, + } + RPCTxSyncMaxFlag = &cli.DurationFlag{ + Name: "rpc.txsync.max", + 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(RPCTxSyncDefaultFlag.Name) { + cfg.TxSyncDefaultTimeout = ctx.Duration(RPCTxSyncDefaultFlag.Name) + } + if ctx.IsSet(RPCTxSyncMaxFlag.Name) { + cfg.TxSyncMaxTimeout = ctx.Duration(RPCTxSyncMaxFlag.Name) + } if !ctx.Bool(SnapshotFlag.Name) || cfg.SnapshotCache == 0 { // If snap-sync is requested, this flag is also required if cfg.SyncMode == ethconfig.SnapSync { diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index 6020387bcdb..c4a0956b3b4 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -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. From 16008617fcc92c93acc295cdb097befd48581e4a Mon Sep 17 00:00:00 2001 From: aodhgan Date: Wed, 1 Oct 2025 22:41:01 -0700 Subject: [PATCH 4/8] move to event driven --- internal/ethapi/api.go | 56 ++++++++++++++++++++++++++----------- internal/ethapi/api_test.go | 5 ++-- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 9936328bfe4..9ec4803d6df 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -41,6 +41,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/eth/gasestimator" "github.com/ethereum/go-ethereum/eth/tracers/logger" + "github.com/ethereum/go-ethereum/event" "github.com/ethereum/go-ethereum/internal/ethapi/override" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/p2p" @@ -1637,7 +1638,7 @@ func (api *TransactionAPI) SendRawTransaction(ctx context.Context, input hexutil } // SendRawTransactionSync will add the signed transaction to the transaction pool -// and wait for the transaction to be mined until the timeout (in milliseconds) is reached. +// 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 { @@ -1648,7 +1649,7 @@ func (api *TransactionAPI) SendRawTransactionSync(ctx context.Context, input hex return nil, err } - // compute effective timeout + // effective timeout: min(requested, max), default if none max := api.b.RPCTxSyncMaxTimeout() def := api.b.RPCTxSyncDefaultTimeout() @@ -1658,40 +1659,63 @@ func (api *TransactionAPI) SendRawTransactionSync(ctx context.Context, input hex if req > max { eff = max } else { - eff = req // allow shorter than default + eff = req } } - // Wait for receipt until timeout receiptCtx, cancel := context.WithTimeout(ctx, eff) defer cancel() - ticker := time.NewTicker(time.Millisecond * 100) - defer ticker.Stop() + // Fast path: maybe already mined/indexed + if r, err := api.GetTransactionReceipt(receiptCtx, hash); err == nil && r != nil { + return r, nil + } + + // Subscribe to head changes and re-check on each new block + heads := make(chan core.ChainHeadEvent, 16) + var sub event.Subscription = api.b.SubscribeChainHeadEvent(heads) + if sub == nil { + return nil, errors.New("chain head subscription unavailable") + } + defer sub.Unsubscribe() for { select { - case <-ticker.C: - receipt, getErr := api.GetTransactionReceipt(receiptCtx, hash) - // If tx-index still building, keep polling - if getErr != nil && !errors.Is(getErr, NewTxIndexingError()) { - // transient or other error: just keep polling - } - if receipt != nil { - return receipt, nil - } case <-receiptCtx.Done(): + // Distinguish upstream cancellation from our timeout if err := ctx.Err(); err != nil { - return nil, err // context canceled / deadline exceeded upstream + return nil, err } return nil, &txSyncTimeoutError{ msg: fmt.Sprintf("The transaction was added to the transaction pool but wasn't processed in %v.", eff), hash: hash, } + + case err := <-sub.Err(): + return nil, err + + case <-heads: + receipt, getErr := api.GetTransactionReceipt(receiptCtx, hash) + // If tx-index still building, keep waiting; ignore transient errors + if getErr != nil && !errors.Is(getErr, NewTxIndexingError()) { + // ignore and wait for next head + continue + } + if receipt != nil { + return receipt, nil + } } } } +func (api *TransactionAPI) tryGetReceipt(ctx context.Context, hash common.Hash) (map[string]interface{}, error) { + receipt, err := api.GetTransactionReceipt(ctx, hash) + if err != nil { + return nil, err + } + return receipt, nil +} + // Sign calculates an ECDSA signature for: // keccak256("\x19Ethereum Signed Message:\n" + len(message) + message). // diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index 2b3a9c94dde..a785f18241b 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -448,6 +448,7 @@ type testBackend struct { syncMaxTO time.Duration sentTx *types.Transaction sentTxHash common.Hash + headFeed *event.Feed } func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.Engine, generator func(i int, b *core.BlockGen)) *testBackend { @@ -474,6 +475,7 @@ func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.E acc: acc, pending: blocks[n], pendingReceipts: receipts[n], + headFeed: new(event.Feed), } return backend } @@ -598,7 +600,7 @@ func (b testBackend) SubscribeChainEvent(ch chan<- core.ChainEvent) event.Subscr panic("implement me") } func (b testBackend) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription { - panic("implement me") + return b.headFeed.Subscribe(ch) } func (b *testBackend) SendTx(ctx context.Context, tx *types.Transaction) error { b.sentTx = tx @@ -3955,7 +3957,6 @@ func makeSignedRaw(t *testing.T, api *TransactionAPI, from, to common.Address, v 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{ From c502a287eab836571b8e7b4421f14ba146160915 Mon Sep 17 00:00:00 2001 From: aodhgan Date: Wed, 1 Oct 2025 22:44:39 -0700 Subject: [PATCH 5/8] use configs in api backend --- eth/api_backend.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eth/api_backend.go b/eth/api_backend.go index cbcf428d13e..766a99fc1ef 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -488,9 +488,9 @@ func (b *EthAPIBackend) StateAtTransaction(ctx context.Context, block *types.Blo } func (b *EthAPIBackend) RPCTxSyncDefaultTimeout() time.Duration { - return 2 * time.Second + return b.eth.config.TxSyncDefaultTimeout } func (b *EthAPIBackend) RPCTxSyncMaxTimeout() time.Duration { - return 5 * time.Minute + return b.eth.config.TxSyncMaxTimeout } From ac00f12f6f9091c862d52eb734ddca4e2545766e Mon Sep 17 00:00:00 2001 From: aodhgan Date: Wed, 1 Oct 2025 22:48:31 -0700 Subject: [PATCH 6/8] rm unused function --- internal/ethapi/api.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 9ec4803d6df..9a20fe345a3 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -1708,14 +1708,6 @@ func (api *TransactionAPI) SendRawTransactionSync(ctx context.Context, input hex } } -func (api *TransactionAPI) tryGetReceipt(ctx context.Context, hash common.Hash) (map[string]interface{}, error) { - receipt, err := api.GetTransactionReceipt(ctx, hash) - if err != nil { - return nil, err - } - return receipt, nil -} - // Sign calculates an ECDSA signature for: // keccak256("\x19Ethereum Signed Message:\n" + len(message) + message). // From d57da980f6d4c16d8ad9f425e818462deccd6600 Mon Sep 17 00:00:00 2001 From: aodhgan Date: Thu, 2 Oct 2025 12:32:36 -0700 Subject: [PATCH 7/8] poll after head --- internal/ethapi/api.go | 99 +++++++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 9a20fe345a3..76047cbd067 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -41,7 +41,6 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/eth/gasestimator" "github.com/ethereum/go-ethereum/eth/tracers/logger" - "github.com/ethereum/go-ethereum/event" "github.com/ethereum/go-ethereum/internal/ethapi/override" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/p2p" @@ -1649,61 +1648,111 @@ func (api *TransactionAPI) SendRawTransactionSync(ctx context.Context, input hex return nil, err } - // effective timeout: min(requested, max), default if none - max := api.b.RPCTxSyncMaxTimeout() - def := api.b.RPCTxSyncDefaultTimeout() + maxTimeout := api.b.RPCTxSyncMaxTimeout() + defaultTimeout := api.b.RPCTxSyncDefaultTimeout() - eff := def + timeout := defaultTimeout if timeoutMs != nil && *timeoutMs > 0 { req := time.Duration(*timeoutMs) * time.Millisecond - if req > max { - eff = max + if req > maxTimeout { + timeout = maxTimeout } else { - eff = req + timeout = req } } - receiptCtx, cancel := context.WithTimeout(ctx, eff) + receiptCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - // Fast path: maybe already mined/indexed + // Fast path. if r, err := api.GetTransactionReceipt(receiptCtx, hash); err == nil && r != nil { return r, nil } - // Subscribe to head changes and re-check on each new block + // Subscribe to new block events and check the receipt on each new block. heads := make(chan core.ChainHeadEvent, 16) - var sub event.Subscription = api.b.SubscribeChainHeadEvent(heads) - if sub == nil { - return nil, errors.New("chain head subscription unavailable") - } + sub := api.b.SubscribeChainHeadEvent(heads) defer sub.Unsubscribe() + subErrCh := sub.Err() + + // calculate poll/settle intervals + const ( + pollFraction = 20 + pollMin = 25 * time.Millisecond + pollMax = 500 * time.Millisecond + ) + settleInterval := timeout / pollFraction + if settleInterval < pollMin { + settleInterval = pollMin + } else if settleInterval > pollMax { + settleInterval = pollMax + } + + // Settle: short delay to bridge receipt-indexing lag after a new head. + // resetSettle re-arms a single timer safely (stop+drain+reset). + // On head: check once immediately, then reset; on timer: re-check; repeat until deadline. + var ( + settle *time.Timer + settleCh <-chan time.Time + ) + resetSettle := func(d time.Duration) { + if settle == nil { + settle = time.NewTimer(d) + settleCh = settle.C + return + } + if !settle.Stop() { + select { + case <-settle.C: + default: + } + } + settle.Reset(d) + } + defer func() { + if settle != nil && !settle.Stop() { + select { + case <-settle.C: + default: + } + } + }() + + // Start a settle cycle immediately to cover a missed-head race. + resetSettle(settleInterval) for { select { case <-receiptCtx.Done(): - // Distinguish upstream cancellation from our timeout if err := ctx.Err(); err != nil { return nil, err } return nil, &txSyncTimeoutError{ - msg: fmt.Sprintf("The transaction was added to the transaction pool but wasn't processed in %v.", eff), + msg: fmt.Sprintf("The transaction was added to the transaction pool but wasn't processed in %v.", timeout), hash: hash, } - case err := <-sub.Err(): + case err, ok := <-subErrCh: + if !ok || err == nil { + // subscription closed; disable this case and rely on settle timer + subErrCh = nil + continue + } return nil, err case <-heads: - receipt, getErr := api.GetTransactionReceipt(receiptCtx, hash) - // If tx-index still building, keep waiting; ignore transient errors - if getErr != nil && !errors.Is(getErr, NewTxIndexingError()) { - // ignore and wait for next head - continue + // Immediate re-check on new head, then grace to bridge indexing lag. + if r, err := api.GetTransactionReceipt(receiptCtx, hash); err == nil && r != nil { + return r, nil } - if receipt != nil { - return receipt, nil + resetSettle(settleInterval) + + case <-settleCh: + r, getErr := api.GetTransactionReceipt(receiptCtx, hash) + if r != nil && getErr == nil { + return r, nil } + resetSettle(settleInterval) } } } From 0daa39668005643fe108291647f894b6779968cd Mon Sep 17 00:00:00 2001 From: aodhgan Date: Thu, 2 Oct 2025 12:40:44 -0700 Subject: [PATCH 8/8] rm unneccessary var --- internal/ethapi/api.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 76047cbd067..7c25a9c237a 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -1748,8 +1748,7 @@ func (api *TransactionAPI) SendRawTransactionSync(ctx context.Context, input hex resetSettle(settleInterval) case <-settleCh: - r, getErr := api.GetTransactionReceipt(receiptCtx, hash) - if r != nil && getErr == nil { + if r, err := api.GetTransactionReceipt(receiptCtx, hash); err == nil && r != nil { return r, nil } resetSettle(settleInterval)