Skip to content

Commit

Permalink
Add TransactionManager with improved nonce handling and pending tx cl…
Browse files Browse the repository at this point in the history
…eanup (#45)
  • Loading branch information
volod-vana authored Nov 22, 2024
1 parent ddf65e9 commit ac0e466
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 66 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "vana"
version = "0.27.0"
version = "0.28.0"
description = ""
authors = ["Tim Nunamaker <[email protected]>", "Volodymyr Isai <[email protected]>", "Kahtaf Alam <[email protected]>"]
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion vana/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.

__version__ = "0.27.0"
__version__ = "0.28.0"

import rich

Expand Down
76 changes: 12 additions & 64 deletions vana/chain_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from web3.middleware import geth_poa_middleware

import vana
from vana.utils.misc import get_block_explorer_url
from vana.utils.transaction import TransactionManager
from vana.utils.web3 import decode_custom_error

logger = native_logging.getLogger("opendata")
Expand Down Expand Up @@ -202,70 +202,18 @@ def state(

def send_transaction(self, function: ContractFunction, account: LocalAccount, value=0, max_retries=3, base_gas_multiplier=1.5):
"""
Send a transaction with retry logic for nonce issues.
Args:
function: Contract function to call
account: Account to send from
value: ETH value to send
max_retries: Maximum number of retries for nonce issues
Send a transaction using the TransactionManager
"""
retry_count = 0
while retry_count < max_retries:
try:
# Estimate gas with 2x buffer
gas_limit = function.estimate_gas({
'from': account.address,
'value': self.web3.to_wei(value, 'ether')
}) * 2

# Start with a higher base gas price and increase aggressively on retries
base_gas_price = self.web3.eth.gas_price

# Start at 1.5x (default) and increase by 0.5x per retry
gas_multiplier = base_gas_multiplier + (retry_count * 0.5)
gas_price = int(base_gas_price * gas_multiplier)

# Get the latest nonce right before sending
nonce = self.web3.eth.get_transaction_count(account.address, 'pending')

tx = function.build_transaction({
'from': account.address,
'value': self.web3.to_wei(value, 'ether'),
'gas': gas_limit,
'gasPrice': gas_price,
'nonce': nonce
})

signed_tx = self.web3.eth.account.sign_transaction(tx, private_key=account.key)
vana.logging.info(f"Sending transaction with nonce {nonce}, gas price {gas_price} ({gas_multiplier}x base) (retry {retry_count})")

tx_hash = self.web3.eth.send_raw_transaction(signed_tx.rawTransaction)
vana.logging.info(f"Transaction hash: {tx_hash.hex()}")

tx_receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
url = get_block_explorer_url(self.config.chain.network, tx_hash.hex())
vana.logging.info(f"Transaction successful. View on block explorer: {url}")

return tx_hash, tx_receipt

except Exception as e:
error_msg = str(e)
if any(msg in error_msg.lower() for msg in ["underpriced", "timeout"]) and retry_count < max_retries - 1:
retry_count += 1
vana.logging.warning(f"Transaction failed, retrying with higher gas price (attempt {retry_count}/{max_retries})")
# Small delay before retry to allow pending transactions to clear
time.sleep(1)
continue
else:
if isinstance(e, ContractCustomError):
decoded_error = decode_custom_error(function.contract_abi, e.data)
vana.logging.error(f"Contract error: {decoded_error}")
else:
vana.logging.error(f"Transaction failed: {error_msg}")
raise

raise Exception(f"Failed to send transaction after {max_retries} attempts")
"""
Send a transaction using the TransactionManager
"""
tx_manager = TransactionManager(self.web3, account)
return tx_manager.send_transaction(
function=function,
value=self.web3.to_wei(value, 'ether'),
max_retries=max_retries,
base_gas_multiplier=base_gas_multiplier
)

def read_contract_fn(self, function: ContractFunction):
try:
Expand Down
168 changes: 168 additions & 0 deletions vana/utils/transaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
from typing import Optional, Tuple, Dict, Any
from web3 import Web3
from web3.types import TxReceipt, HexBytes
from eth_account.signers.local import LocalAccount
import time
import vana

class TransactionManager:
def __init__(self, web3: Web3, account: LocalAccount):
self.web3 = web3
self.account = account
self._nonce_cache: Dict[str, int] = {}
self._last_nonce_refresh = 0
self.nonce_refresh_interval = 60
self.chain_id = self.web3.eth.chain_id

def _get_safe_nonce(self) -> int:
"""
Get the next safe nonce, accounting for pending transactions
"""
current_time = time.time()
cache_key = self.account.address

# Refresh nonce cache if expired
if current_time - self._last_nonce_refresh > self.nonce_refresh_interval:
# Get the latest confirmed nonce
confirmed_nonce = self.web3.eth.get_transaction_count(self.account.address, 'latest')
# Get pending nonce
pending_nonce = self.web3.eth.get_transaction_count(self.account.address, 'pending')
# Use the higher of the two to avoid nonce conflicts
self._nonce_cache[cache_key] = max(confirmed_nonce, pending_nonce)
self._last_nonce_refresh = current_time

# Get and increment the cached nonce
nonce = self._nonce_cache.get(cache_key, 0)
self._nonce_cache[cache_key] = nonce + 1
return nonce

def _clear_pending_transactions(self):
"""
Clear pending transactions by sending zero-value transactions with higher gas price
"""
try:
# Get all pending transactions for the account
pending_nonce = self.web3.eth.get_transaction_count(self.account.address, 'pending')
confirmed_nonce = self.web3.eth.get_transaction_count(self.account.address, 'latest')
eth_transfer_gas = 21000 # Standard gas cost for basic ETH transfer

if pending_nonce > confirmed_nonce:
vana.logging.info(f"Clearing {pending_nonce - confirmed_nonce} pending transactions")

# Send replacement transactions with higher gas price
for nonce in range(confirmed_nonce, pending_nonce):
replacement_tx = {
'from': self.account.address,
'to': self.account.address,
'value': 0,
'nonce': nonce,
'gas': eth_transfer_gas,
'gasPrice': self.web3.eth.gas_price * 2, # Double the current gas price
'chainId': self.chain_id # Add chain ID for EIP-155
}

signed_tx = self.web3.eth.account.sign_transaction(replacement_tx, self.account.key)
try:
self.web3.eth.send_raw_transaction(signed_tx.rawTransaction)
vana.logging.info(f"Sent replacement transaction for nonce {nonce}")
except Exception as e:
vana.logging.warning(f"Failed to replace transaction with nonce {nonce}: {str(e)}")

# Wait for transactions to be processed
time.sleep(30)

except Exception as e:
vana.logging.error(f"Error clearing pending transactions: {str(e)}")

def send_transaction(
self,
function: Any,
value: int = 0,
max_retries: int = 3,
base_gas_multiplier: float = 1.5,
timeout: int = 180
) -> Tuple[HexBytes, TxReceipt]:
"""
Send a transaction with improved retry logic and gas price management
"""
retry_count = 0
last_error = None

# Check for too many pending transactions
pending_count = (self.web3.eth.get_transaction_count(self.account.address, 'pending') -
self.web3.eth.get_transaction_count(self.account.address, 'latest'))

if pending_count > 5:
vana.logging.warning(f"Found {pending_count} pending transactions, attempting to clear...")
self._clear_pending_transactions()

while retry_count < max_retries:
try:
# Estimate gas with buffer
gas_limit = function.estimate_gas({
'from': self.account.address,
'value': value,
'chainId': self.chain_id # Add chain ID for estimation
}) * 2

# Calculate gas price with exponential backoff
base_gas_price = self.web3.eth.gas_price
gas_multiplier = base_gas_multiplier * (1.5 ** retry_count)
gas_price = int(base_gas_price * gas_multiplier)

# Get a safe nonce
nonce = self._get_safe_nonce()

tx = function.build_transaction({
'from': self.account.address,
'value': value,
'gas': gas_limit,
'gasPrice': gas_price,
'nonce': nonce,
'chainId': self.chain_id # Add chain ID for EIP-155
})

signed_tx = self.web3.eth.account.sign_transaction(tx, self.account.key)
vana.logging.info(
f"Sending transaction with nonce {nonce}, "
f"gas price {gas_price} ({gas_multiplier:.1f}x base) "
f"(retry {retry_count})"
)

tx_hash = self.web3.eth.send_raw_transaction(signed_tx.rawTransaction)

# Wait for transaction with timeout
start_time = time.time()
while True:
try:
tx_receipt = self.web3.eth.get_transaction_receipt(tx_hash)
if tx_receipt is not None:
if tx_receipt.status == 1: # Check if transaction was successful
vana.logging.info(f"Transaction successful in block {tx_receipt['blockNumber']}")
return tx_hash, tx_receipt
else:
raise Exception("Transaction failed")
except Exception:
pass

if time.time() - start_time > timeout:
raise TimeoutError(f"Transaction not mined within {timeout} seconds")

time.sleep(2)

except Exception as e:
last_error = e
retry_count += 1

if retry_count < max_retries:
wait_time = 2 ** retry_count # Exponential backoff
vana.logging.warning(
f"Transaction failed, waiting {wait_time} seconds before retry "
f"(attempt {retry_count}/{max_retries}): {str(e)}"
)
time.sleep(wait_time)
else:
vana.logging.error(f"Transaction failed after {max_retries} attempts: {str(last_error)}")
raise

raise Exception(f"Failed to send transaction after {max_retries} attempts: {str(last_error)}")

0 comments on commit ac0e466

Please sign in to comment.