From a10e11c9694423958d1c78e7bafd507c7a5eeb1a Mon Sep 17 00:00:00 2001 From: Arkadiy Kukarkin Date: Sat, 2 May 2026 11:56:29 +0100 Subject: [PATCH] txutil: drop unused retry helpers and nonce accessories --- pdp/manager.go | 9 +- pkg/txutil/confirmation.go | 123 +++++---------- pkg/txutil/confirmation_test.go | 70 +++++++++ pkg/txutil/nonce.go | 60 +------- pkg/txutil/nonce_test.go | 48 ------ pkg/txutil/retry.go | 179 ---------------------- pkg/txutil/retry_test.go | 264 -------------------------------- 7 files changed, 120 insertions(+), 633 deletions(-) create mode 100644 pkg/txutil/confirmation_test.go delete mode 100644 pkg/txutil/retry.go delete mode 100644 pkg/txutil/retry_test.go diff --git a/pdp/manager.go b/pdp/manager.go index 3bbe65d..899a2ce 100644 --- a/pdp/manager.go +++ b/pdp/manager.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math/big" + "time" "github.com/data-preservation-programs/go-synapse/constants" "github.com/data-preservation-programs/go-synapse/contracts" @@ -20,6 +21,8 @@ import ( // createDataSet. defined in Fees.sol as SYBIL_FEE. var SybilFee = big.NewInt(100000000000000000) // 0.1 FIL in attoFIL +const defaultReceiptTimeout = 90 * time.Second + // ProofSetManager provides high-level operations for managing PDP proof sets type ProofSetManager interface { // CreateProofSet creates a new proof set on-chain @@ -229,7 +232,7 @@ func (m *Manager) CreateProofSet(ctx context.Context, opts CreateProofSetOptions // Mark as sent only after successful contract call txSent = true - receipt, err := txutil.WaitForReceipt(ctx, m.client, tx.Hash(), txutil.DefaultRetryConfig().MaxBackoff*3) + receipt, err := txutil.WaitForReceipt(ctx, m.client, tx.Hash(), defaultReceiptTimeout) if err != nil { // Error waiting for receipt - transaction may be pending, don't release nonce return nil, fmt.Errorf("failed to wait for receipt: %w", err) @@ -355,7 +358,7 @@ func (m *Manager) AddRoots(ctx context.Context, proofSetID *big.Int, roots []Roo // Mark as sent only after successful contract call txSent = true - receipt, err := txutil.WaitForReceipt(ctx, m.client, tx.Hash(), txutil.DefaultRetryConfig().MaxBackoff*3) + receipt, err := txutil.WaitForReceipt(ctx, m.client, tx.Hash(), defaultReceiptTimeout) if err != nil { // Error waiting for receipt - transaction may be pending, don't release nonce return nil, fmt.Errorf("failed to wait for receipt: %w", err) @@ -436,7 +439,7 @@ func (m *Manager) DeleteProofSet(ctx context.Context, proofSetID *big.Int, extra // Mark as sent only after successful contract call txSent = true - _, err = txutil.WaitForReceipt(ctx, m.client, tx.Hash(), txutil.DefaultRetryConfig().MaxBackoff*3) + _, err = txutil.WaitForReceipt(ctx, m.client, tx.Hash(), defaultReceiptTimeout) if err != nil { // Error waiting for receipt - transaction may be pending, don't release nonce return fmt.Errorf("failed to wait for receipt: %w", err) diff --git a/pkg/txutil/confirmation.go b/pkg/txutil/confirmation.go index 0c96b41..09c7173 100644 --- a/pkg/txutil/confirmation.go +++ b/pkg/txutil/confirmation.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/ethereum/go-ethereum" @@ -12,22 +13,17 @@ import ( "github.com/ethereum/go-ethereum/ethclient" ) -// Error types for receipt waiting var ( - // ErrReceiptTimeout is returned when waiting for a receipt times out - ErrReceiptTimeout = errors.New("timeout waiting for transaction receipt") - // ErrReceiptRPCFailure is returned when too many consecutive RPC errors occur + ErrReceiptTimeout = errors.New("timeout waiting for transaction receipt") ErrReceiptRPCFailure = errors.New("receipt fetch failed due to repeated RPC errors") ) -// ReceiptWaitConfig configures the WaitForReceipt behavior type ReceiptWaitConfig struct { - Timeout time.Duration // Total timeout for waiting (default: 5 minutes) - PollInterval time.Duration // How often to poll (default: 1 second) - MaxConsecutiveErrors int // Max consecutive RPC errors before failing (default: 5) + Timeout time.Duration + PollInterval time.Duration + MaxConsecutiveErrors int } -// DefaultReceiptWaitConfig returns the default configuration func DefaultReceiptWaitConfig() ReceiptWaitConfig { return ReceiptWaitConfig{ Timeout: 5 * time.Minute, @@ -36,78 +32,8 @@ func DefaultReceiptWaitConfig() ReceiptWaitConfig { } } -// WaitForConfirmation waits for a transaction to be confirmed with the specified number of confirmations -func WaitForConfirmation(ctx context.Context, client *ethclient.Client, txHash common.Hash, confirmations uint64) (*types.Receipt, error) { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - consecutiveErrors := 0 - pollCount := 0 - var lastErr error - - for { - select { - case <-ctx.Done(): - if lastErr != nil { - return nil, fmt.Errorf("%w after %d polls: %v (last error: %v)", ErrReceiptTimeout, pollCount, ctx.Err(), lastErr) - } - return nil, fmt.Errorf("%w after %d polls: %v", ErrReceiptTimeout, pollCount, ctx.Err()) - case <-ticker.C: - pollCount++ - receipt, err := client.TransactionReceipt(ctx, txHash) - if err != nil { - // Distinguish between "not found yet" and actual RPC errors - if errors.Is(err, ethereum.NotFound) { - // Transaction not mined yet - this is expected, reset error counter - consecutiveErrors = 0 - continue - } - if !IsRetryableError(err) { - return nil, fmt.Errorf("%w: non-retryable error: %v", ErrReceiptRPCFailure, err) - } - // Actual RPC error - consecutiveErrors++ - lastErr = err - if consecutiveErrors >= 5 { - return nil, fmt.Errorf("%w: %d consecutive errors, last error: %v", ErrReceiptRPCFailure, consecutiveErrors, lastErr) - } - continue - } - - consecutiveErrors = 0 - - if receipt.Status != types.ReceiptStatusSuccessful { - return receipt, fmt.Errorf("transaction failed with status %d", receipt.Status) - } - - if confirmations == 0 { - return receipt, nil - } - - currentBlock, err := client.BlockNumber(ctx) - if err != nil { - if !IsRetryableError(err) { - return nil, fmt.Errorf("%w: non-retryable error: %v", ErrReceiptRPCFailure, err) - } - consecutiveErrors++ - lastErr = err - if consecutiveErrors >= 5 { - return nil, fmt.Errorf("%w: %d consecutive errors, last error: %v", ErrReceiptRPCFailure, consecutiveErrors, lastErr) - } - continue - } - - consecutiveErrors = 0 - - if receipt.BlockNumber.Uint64()+confirmations <= currentBlock { - return receipt, nil - } - } - } -} - -// WaitForReceipt waits for a transaction receipt without confirmation requirements. -// Uses a default timeout of 5 minutes. For custom configuration, use WaitForReceiptWithConfig. +// WaitForReceipt polls until the receipt for txHash is available or timeout +// elapses. Default timeout is 5 minutes when timeout is zero. func WaitForReceipt(ctx context.Context, client *ethclient.Client, txHash common.Hash, timeout time.Duration) (*types.Receipt, error) { config := DefaultReceiptWaitConfig() if timeout > 0 { @@ -116,7 +42,6 @@ func WaitForReceipt(ctx context.Context, client *ethclient.Client, txHash common return WaitForReceiptWithConfig(ctx, client, txHash, config) } -// WaitForReceiptWithConfig waits for a transaction receipt with custom configuration func WaitForReceiptWithConfig(ctx context.Context, client *ethclient.Client, txHash common.Hash, config ReceiptWaitConfig) (*types.Receipt, error) { ctx, cancel := context.WithTimeout(ctx, config.Timeout) defer cancel() @@ -148,16 +73,14 @@ func WaitForReceiptWithConfig(ctx context.Context, client *ethclient.Client, txH pollCount++ receipt, err := client.TransactionReceipt(ctx, txHash) if err != nil { - // Distinguish between "not found yet" and actual RPC errors if errors.Is(err, ethereum.NotFound) { - // Transaction not mined yet - this is expected, reset error counter + // not mined yet -- expected, reset error counter consecutiveErrors = 0 continue } - if !IsRetryableError(err) { + if !isRetryableError(err) { return nil, fmt.Errorf("%w: non-retryable error: %v", ErrReceiptRPCFailure, err) } - // Actual RPC error consecutiveErrors++ lastErr = err if consecutiveErrors >= maxErrors { @@ -173,3 +96,31 @@ func WaitForReceiptWithConfig(ctx context.Context, client *ethclient.Client, txH } } } + +// isRetryableError returns true for transient RPC errors worth retrying. +// Matches by string fragment because go-ethereum surfaces these as plain errors. +func isRetryableError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + + errStr := strings.ToLower(err.Error()) + for _, retryable := range []string{ + "nonce too low", + "replacement transaction underpriced", + "already known", + "timeout", + "connection refused", + "connection reset", + "broken pipe", + "i/o timeout", + } { + if strings.Contains(errStr, retryable) { + return true + } + } + return false +} diff --git a/pkg/txutil/confirmation_test.go b/pkg/txutil/confirmation_test.go new file mode 100644 index 0000000..04224b8 --- /dev/null +++ b/pkg/txutil/confirmation_test.go @@ -0,0 +1,70 @@ +package txutil + +import ( + "context" + "errors" + "testing" +) + +func TestIsRetryableError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "nonce too low", + err: errors.New("nonce too low"), + expected: true, + }, + { + name: "replacement transaction underpriced", + err: errors.New("replacement transaction underpriced"), + expected: true, + }, + { + name: "already known", + err: errors.New("already known"), + expected: true, + }, + { + name: "timeout error", + err: errors.New("timeout occurred"), + expected: true, + }, + { + name: "connection refused", + err: errors.New("connection refused"), + expected: true, + }, + { + name: "non-retryable error", + err: errors.New("insufficient funds"), + expected: false, + }, + { + name: "context deadline exceeded", + err: context.DeadlineExceeded, + expected: false, + }, + { + name: "context canceled", + err: context.Canceled, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isRetryableError(tt.err) + if result != tt.expected { + t.Errorf("isRetryableError() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/txutil/nonce.go b/pkg/txutil/nonce.go index 8bded20..9b8a2dd 100644 --- a/pkg/txutil/nonce.go +++ b/pkg/txutil/nonce.go @@ -3,14 +3,13 @@ package txutil import ( "context" "fmt" - "math/big" "sync" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" ) -// NonceManager manages nonces for transaction sending +// NonceManager allocates and tracks transaction nonces for a single sender. type NonceManager struct { client *ethclient.Client address common.Address @@ -19,7 +18,6 @@ type NonceManager struct { pendingTxs map[uint64]bool } -// NewNonceManager creates a new nonce manager func NewNonceManager(client *ethclient.Client, address common.Address) *NonceManager { return &NonceManager{ client: client, @@ -28,7 +26,8 @@ func NewNonceManager(client *ethclient.Client, address common.Address) *NonceMan } } -// GetNonce returns the next available nonce +// GetNonce returns the next available nonce, fetching from the network on +// first call (or after MarkFailed clears the cache). func (nm *NonceManager) GetNonce(ctx context.Context) (uint64, error) { nm.mu.Lock() defer nm.mu.Unlock() @@ -48,7 +47,6 @@ func (nm *NonceManager) GetNonce(ctx context.Context) (uint64, error) { return currentNonce, nil } -// MarkConfirmed marks a nonce as confirmed (transaction mined) func (nm *NonceManager) MarkConfirmed(nonce uint64) { nm.mu.Lock() defer nm.mu.Unlock() @@ -56,57 +54,13 @@ func (nm *NonceManager) MarkConfirmed(nonce uint64) { } // MarkFailed releases a nonce that was never successfully sent to the network. -// This should be called when a transaction fails before being sent (e.g., gas estimation -// failure, signing error) to prevent nonce leaks that would block future transactions. -// -// IMPORTANT: Only call this for local failures before the transaction is sent. -// Do NOT call this for network errors after sending - those transactions may still -// be pending in the mempool and should be tracked until confirmed or replaced. -// -// The cached nonce is cleared to force a refresh from the network on the next GetNonce. +// Call only for local failures before send (gas estimation, signing); not for +// network errors after sending, since those tx may still be pending in the +// mempool. The cached nonce is cleared so the next GetNonce refreshes from +// the network. func (nm *NonceManager) MarkFailed(nonce uint64) { nm.mu.Lock() defer nm.mu.Unlock() delete(nm.pendingTxs, nonce) nm.nonce = nil } - -// Reset resets the nonce manager (fetches fresh nonce from network) -func (nm *NonceManager) Reset(ctx context.Context) error { - nm.mu.Lock() - defer nm.mu.Unlock() - - nonce, err := nm.client.PendingNonceAt(ctx, nm.address) - if err != nil { - return fmt.Errorf("failed to reset nonce: %w", err) - } - - nm.nonce = &nonce - nm.pendingTxs = make(map[uint64]bool) - return nil -} - -// GetPendingCount returns the number of pending transactions -func (nm *NonceManager) GetPendingCount() int { - nm.mu.Lock() - defer nm.mu.Unlock() - return len(nm.pendingTxs) -} - -// GetFreshNonce gets a fresh nonce from the network without caching -func GetFreshNonce(ctx context.Context, client *ethclient.Client, address common.Address) (uint64, error) { - nonce, err := client.PendingNonceAt(ctx, address) - if err != nil { - return 0, fmt.Errorf("failed to get nonce: %w", err) - } - return nonce, nil -} - -// GetChainID returns the chain ID from the client -func GetChainID(ctx context.Context, client *ethclient.Client) (*big.Int, error) { - chainID, err := client.ChainID(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get chain ID: %w", err) - } - return chainID, nil -} diff --git a/pkg/txutil/nonce_test.go b/pkg/txutil/nonce_test.go index a1ff04d..f49be40 100644 --- a/pkg/txutil/nonce_test.go +++ b/pkg/txutil/nonce_test.go @@ -104,51 +104,3 @@ func TestNonceManager_MarkConfirmed(t *testing.T) { t.Error("nonce 12 should still be pending") } } - -func TestNonceManager_GetPendingCount(t *testing.T) { - address := common.HexToAddress("0x1234567890123456789012345678901234567890") - nm := &NonceManager{ - client: (*ethclient.Client)(nil), - address: address, - pendingTxs: make(map[uint64]bool), - } - - if nm.GetPendingCount() != 0 { - t.Errorf("expected 0 pending, got %d", nm.GetPendingCount()) - } - - nm.pendingTxs[1] = true - nm.pendingTxs[2] = true - nm.pendingTxs[3] = true - - if nm.GetPendingCount() != 3 { - t.Errorf("expected 3 pending, got %d", nm.GetPendingCount()) - } -} - -func TestNonceManager_ResetClearsPending(t *testing.T) { - address := common.HexToAddress("0x1234567890123456789012345678901234567890") - - nm := &NonceManager{ - client: (*ethclient.Client)(nil), - address: address, - pendingTxs: make(map[uint64]bool), - } - currentNonce := uint64(100) - nm.nonce = ¤tNonce - nm.pendingTxs[60] = true - - // Manually simulate reset (without network call) - nm.mu.Lock() - nm.pendingTxs = make(map[uint64]bool) - nm.nonce = nil - nm.mu.Unlock() - - // Verify pendingTxs cleared and cached nonce reset - if nm.nonce != nil { - t.Errorf("expected cached nonce to be nil, got %v", nm.nonce) - } - if len(nm.pendingTxs) != 0 { - t.Errorf("expected pendingTxs to be empty, got %d", len(nm.pendingTxs)) - } -} diff --git a/pkg/txutil/retry.go b/pkg/txutil/retry.go deleted file mode 100644 index bdc7497..0000000 --- a/pkg/txutil/retry.go +++ /dev/null @@ -1,179 +0,0 @@ -package txutil - -import ( - "context" - cryptorand "crypto/rand" - "encoding/binary" - "errors" - "fmt" - "math" - "strings" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/ethclient" -) - -// RetryConfig holds configuration for transaction retry logic -type RetryConfig struct { - MaxRetries int - InitialBackoff time.Duration - MaxBackoff time.Duration - BackoffMultiple float64 -} - -// DefaultRetryConfig returns a default retry configuration -func DefaultRetryConfig() RetryConfig { - return RetryConfig{ - MaxRetries: 3, - InitialBackoff: time.Second, - MaxBackoff: 30 * time.Second, - BackoffMultiple: 2.0, - } -} - -// IsRetryableError checks if an error is retryable -func IsRetryableError(err error) bool { - if err == nil { - return false - } - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return false - } - - errStr := strings.ToLower(err.Error()) - - retryableErrors := []string{ - "nonce too low", - "replacement transaction underpriced", - "already known", - "timeout", - "connection refused", - "connection reset", - "broken pipe", - "i/o timeout", - } - - for _, retryable := range retryableErrors { - if strings.Contains(errStr, retryable) { - return true - } - } - - return false -} - -// ErrNonRetryable is returned when a non-retryable error occurs. -var ErrNonRetryable = errors.New("non-retryable error") - -// SendTransactionWithRetry sends a transaction with retry logic -func SendTransactionWithRetry(ctx context.Context, client *ethclient.Client, tx *types.Transaction, config RetryConfig) (common.Hash, error) { - var lastErr error - - for attempt := 0; attempt <= config.MaxRetries; attempt++ { - if attempt > 0 { - backoff := CalculateBackoff(attempt-1, config.InitialBackoff, config.MaxBackoff, config.BackoffMultiple) - select { - case <-ctx.Done(): - return common.Hash{}, ctx.Err() - case <-time.After(backoff): - } - - } - - err := client.SendTransaction(ctx, tx) - if err == nil { - return tx.Hash(), nil - } - - lastErr = err - if !IsRetryableError(err) { - return common.Hash{}, fmt.Errorf("%w: %v", ErrNonRetryable, err) - } - } - - return common.Hash{}, fmt.Errorf("max retries exceeded: %w", lastErr) -} - -// WaitForTransactionWithRetry sends a transaction and waits for it to be mined with retry logic -func WaitForTransactionWithRetry(ctx context.Context, client *ethclient.Client, tx *types.Transaction, confirmations uint64, config RetryConfig) (*types.Receipt, error) { - txHash, err := SendTransactionWithRetry(ctx, client, tx, config) - if err != nil { - return nil, fmt.Errorf("failed to send transaction: %w", err) - } - - receipt, err := WaitForConfirmation(ctx, client, txHash, confirmations) - if err != nil { - return nil, fmt.Errorf("failed to wait for confirmation: %w", err) - } - - return receipt, nil -} - -// CalculateBackoff calculates exponential backoff with decorrelated jitter. -// Jitter prevents thundering herd issues when multiple clients retry simultaneously. -// Uses decorrelated jitter: returns backoff/2 + random(0, backoff/2) -func CalculateBackoff(attempt int, initialBackoff, maxBackoff time.Duration, multiplier float64) time.Duration { - backoff := time.Duration(float64(initialBackoff) * math.Pow(multiplier, float64(attempt))) - if backoff > maxBackoff { - backoff = maxBackoff - } - - // Apply decorrelated jitter to prevent synchronized retry storms - // Returns backoff/2 + random(0, backoff/2) - halfBackoff := backoff / 2 - jitter := time.Duration(secureRandomInt64n(int64(halfBackoff) + 1)) - return halfBackoff + jitter -} - -// secureRandomInt64n returns a cryptographically secure random int64 in [0, n). -// This is goroutine-safe and suitable for security-sensitive contexts. -func secureRandomInt64n(n int64) int64 { - if n <= 0 { - return 0 - } - var buf [8]byte - _, err := cryptorand.Read(buf[:]) - if err != nil { - // Fallback to 0 if crypto/rand fails (extremely rare) - return 0 - } - // Use modulo for simplicity - bias is negligible for our use case (jitter) - return int64(binary.BigEndian.Uint64(buf[:]) % uint64(n)) -} - -// IsNonceError checks if an error is related to nonce issues -func IsNonceError(err error) bool { - if err == nil { - return false - } - errStr := strings.ToLower(err.Error()) - return strings.Contains(errStr, "nonce too low") || - strings.Contains(errStr, "nonce too high") || - strings.Contains(errStr, "invalid nonce") -} - -// IsGasError checks if an error is related to gas issues -func IsGasError(err error) bool { - if err == nil { - return false - } - errStr := strings.ToLower(err.Error()) - return strings.Contains(errStr, "gas") || - strings.Contains(errStr, "fee") -} - -// WrapError wraps an error with context -func WrapError(operation string, err error) error { - if err == nil { - return nil - } - return fmt.Errorf("%s: %w", operation, err) -} - -// ErrTxFailed is returned when a transaction fails on-chain -var ErrTxFailed = errors.New("transaction failed") - -// ErrTxTimeout is returned when waiting for a transaction times out -var ErrTxTimeout = errors.New("transaction timeout") diff --git a/pkg/txutil/retry_test.go b/pkg/txutil/retry_test.go deleted file mode 100644 index da92658..0000000 --- a/pkg/txutil/retry_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package txutil - -import ( - "context" - "errors" - "testing" - "time" -) - -func TestIsRetryableError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "nil error", - err: nil, - expected: false, - }, - { - name: "nonce too low", - err: errors.New("nonce too low"), - expected: true, - }, - { - name: "replacement transaction underpriced", - err: errors.New("replacement transaction underpriced"), - expected: true, - }, - { - name: "already known", - err: errors.New("already known"), - expected: true, - }, - { - name: "timeout error", - err: errors.New("timeout occurred"), - expected: true, - }, - { - name: "connection refused", - err: errors.New("connection refused"), - expected: true, - }, - { - name: "non-retryable error", - err: errors.New("insufficient funds"), - expected: false, - }, - { - name: "context deadline exceeded", - err: context.DeadlineExceeded, - expected: false, - }, - { - name: "context canceled", - err: context.Canceled, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsRetryableError(tt.err) - if result != tt.expected { - t.Errorf("IsRetryableError() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestIsNonceError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "nil error", - err: nil, - expected: false, - }, - { - name: "nonce too low", - err: errors.New("nonce too low"), - expected: true, - }, - { - name: "nonce too high", - err: errors.New("nonce too high"), - expected: true, - }, - { - name: "invalid nonce", - err: errors.New("invalid nonce"), - expected: true, - }, - { - name: "non-nonce error", - err: errors.New("insufficient funds"), - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsNonceError(tt.err) - if result != tt.expected { - t.Errorf("IsNonceError() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestIsGasError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "nil error", - err: nil, - expected: false, - }, - { - name: "gas too low", - err: errors.New("intrinsic gas too low"), - expected: true, - }, - { - name: "max fee per gas", - err: errors.New("max fee per gas less than block base fee"), - expected: true, - }, - { - name: "non-gas error", - err: errors.New("insufficient funds"), - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsGasError(tt.err) - if result != tt.expected { - t.Errorf("IsGasError() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestDefaultRetryConfig(t *testing.T) { - config := DefaultRetryConfig() - - if config.MaxRetries != 3 { - t.Errorf("MaxRetries = %d, want 3", config.MaxRetries) - } - if config.InitialBackoff != time.Second { - t.Errorf("InitialBackoff = %v, want 1s", config.InitialBackoff) - } - if config.MaxBackoff != 30*time.Second { - t.Errorf("MaxBackoff = %v, want 30s", config.MaxBackoff) - } - if config.BackoffMultiple != 2.0 { - t.Errorf("BackoffMultiple = %f, want 2.0", config.BackoffMultiple) - } -} - -func TestCalculateBackoff(t *testing.T) { - tests := []struct { - name string - attempt int - initialBackoff time.Duration - maxBackoff time.Duration - multiplier float64 - minExpected time.Duration // With jitter, we get a range - maxExpected time.Duration - }{ - { - name: "first attempt", - attempt: 0, - initialBackoff: time.Second, - maxBackoff: 30 * time.Second, - multiplier: 2.0, - minExpected: 500 * time.Millisecond, // base/2 - maxExpected: time.Second, // base/2 + base/2 - }, - { - name: "second attempt", - attempt: 1, - initialBackoff: time.Second, - maxBackoff: 30 * time.Second, - multiplier: 2.0, - minExpected: time.Second, // 2s/2 - maxExpected: 2 * time.Second, // 2s/2 + 2s/2 - }, - { - name: "third attempt", - attempt: 2, - initialBackoff: time.Second, - maxBackoff: 30 * time.Second, - multiplier: 2.0, - minExpected: 2 * time.Second, // 4s/2 - maxExpected: 4 * time.Second, // 4s/2 + 4s/2 - }, - { - name: "exceeds max backoff", - attempt: 10, - initialBackoff: time.Second, - maxBackoff: 30 * time.Second, - multiplier: 2.0, - minExpected: 15 * time.Second, // 30s/2 - maxExpected: 30 * time.Second, // 30s/2 + 30s/2 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Run multiple times to ensure jitter is working - for i := 0; i < 10; i++ { - result := CalculateBackoff(tt.attempt, tt.initialBackoff, tt.maxBackoff, tt.multiplier) - if result < tt.minExpected || result > tt.maxExpected { - t.Errorf("CalculateBackoff() = %v, want between %v and %v", result, tt.minExpected, tt.maxExpected) - } - } - }) - } -} - -func TestWrapError(t *testing.T) { - tests := []struct { - name string - operation string - err error - expected string - }{ - { - name: "nil error", - operation: "test", - err: nil, - expected: "", - }, - { - name: "wrap error", - operation: "create transaction", - err: errors.New("insufficient funds"), - expected: "create transaction: insufficient funds", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := WrapError(tt.operation, tt.err) - if result == nil && tt.expected != "" { - t.Errorf("WrapError() = nil, want error with message %q", tt.expected) - } - if result != nil && result.Error() != tt.expected { - t.Errorf("WrapError() = %q, want %q", result.Error(), tt.expected) - } - }) - } -}