Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/

# Abstra
# Abstra is an AI-powered process automation framework.
Expand Down
8 changes: 8 additions & 0 deletions src/decibel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
BaseSDKOptions,
BaseSDKOptionsSync,
)
from decibel._exceptions import (
TxnConfirmError,
TxnSubmitError,
)
from decibel._constants import (
DEFAULT_COMPAT_VERSION,
DEFAULT_TXN_CONFIRM_TIMEOUT,
DEFAULT_TXN_SUBMIT_TIMEOUT,
DOCKER_CONFIG,
LOCAL_CONFIG,
MAINNET_CONFIG,
Expand Down Expand Up @@ -143,6 +149,8 @@
"DecibelAdminDex",
"DecibelAdminDexSync",
"ABIErrorEntry",
"TxnConfirmError",
"TxnSubmitError",
"ABISummary",
"AbiRegistry",
"amount_to_chain_units",
Expand Down
113 changes: 93 additions & 20 deletions src/decibel/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import httpx
from aptos_sdk.async_client import RestClient

from ._exceptions import TxnConfirmError, TxnSubmitError
from aptos_sdk.authenticator import (
AccountAuthenticator,
Authenticator,
Expand All @@ -18,6 +20,7 @@
from aptos_sdk.ed25519 import Signature as Ed25519Signature
from aptos_sdk.transactions import FeePayerRawTransaction, SignedTransaction

from ._constants import DEFAULT_TXN_CONFIRM_TIMEOUT, DEFAULT_TXN_SUBMIT_TIMEOUT
from ._fee_pay import (
PendingTransactionResponse,
submit_fee_paid_transaction,
Expand Down Expand Up @@ -170,19 +173,27 @@ async def submit_tx(
self,
transaction: SimpleTransaction,
sender_authenticator: AccountAuthenticator,
*,
txn_submit_timeout: float | None = None,
) -> PendingTransactionResponse:
if self._no_fee_payer:
return await self._submit_direct(transaction, sender_authenticator)
return await self._submit_direct(
transaction, sender_authenticator, txn_submit_timeout
)
return await submit_fee_paid_transaction(
self._config,
transaction,
sender_authenticator,
txn_submit_timeout=txn_submit_timeout,
)

async def _send_tx(
self,
payload: InputEntryFunctionData,
account_override: Account | None = None,
*,
txn_submit_timeout: float | None = None,
txn_confirm_timeout: float | None = None,
) -> dict[str, Any]:
signer = account_override if account_override is not None else self._account
sender = signer.address()
Expand Down Expand Up @@ -216,9 +227,35 @@ async def _send_tx(

sender_authenticator = self._sign_transaction(signer, transaction)

pending_tx = await self.submit_tx(transaction, sender_authenticator)
if txn_submit_timeout is None:
txn_submit_timeout = DEFAULT_TXN_SUBMIT_TIMEOUT

try:
pending_tx = await self.submit_tx(
transaction, sender_authenticator, txn_submit_timeout=txn_submit_timeout
)
except httpx.ConnectTimeout as e:
raise TxnSubmitError(
f"Failed to submit transaction: connection timeout to {self._config.fullnode_url}",
original_exception=e,
)
except httpx.ConnectError as e:
raise TxnSubmitError(
f"Failed to submit transaction: connection error - {e}",
original_exception=e,
)
except httpx.HTTPStatusError as e:
raise TxnSubmitError(
f"Failed to submit transaction: HTTP {e.response.status_code}",
original_exception=e,
)
except Exception as e:
raise TxnSubmitError(
f"Failed to submit transaction: {e}",
original_exception=e,
)

return await self._wait_for_transaction(pending_tx.hash)
return await self._wait_for_transaction(pending_tx.hash, txn_confirm_timeout=txn_confirm_timeout)

def _sign_transaction(
self,
Expand Down Expand Up @@ -283,6 +320,7 @@ async def _submit_direct(
self,
transaction: SimpleTransaction,
sender_authenticator: AccountAuthenticator,
txn_submit_timeout: float | None = None,
) -> PendingTransactionResponse:
url = f"{self._config.fullnode_url}/transactions"
headers = self._build_node_headers()
Expand All @@ -291,7 +329,7 @@ async def _submit_direct(
bcs_bytes = self._serialize_signed_transaction(transaction, sender_authenticator)

async with httpx.AsyncClient() as client:
response = await client.post(url, content=bcs_bytes, headers=headers)
response = await client.post(url, content=bcs_bytes, headers=headers, timeout=txn_submit_timeout)

if not response.is_success:
raise ValueError(
Expand All @@ -313,9 +351,11 @@ async def _submit_direct(
async def _wait_for_transaction(
self,
tx_hash: str,
timeout_secs: float = 30.0,
txn_confirm_timeout: float | None = None, # Uses DEFAULT_TXN_CONFIRM_TIMEOUT if None
poll_interval_secs: float = 1.0,
) -> dict[str, Any]:
if txn_confirm_timeout is None:
txn_confirm_timeout = DEFAULT_TXN_CONFIRM_TIMEOUT
url = f"{self._config.fullnode_url}/transactions/by_hash/{tx_hash}"
headers = self._build_node_headers()
start_time = time.time()
Expand All @@ -333,12 +373,10 @@ async def _wait_for_transaction(
return data
elif data.get("success") is False:
vm_status = data.get("vm_status", "Unknown error")
raise ValueError(f"Transaction failed: {vm_status}")
raise TxnConfirmError(tx_hash, f"failed: {vm_status}")

if time.time() - start_time > timeout_secs:
raise TimeoutError(
f"Transaction {tx_hash} did not complete within {timeout_secs}s"
)
if time.time() - start_time > txn_confirm_timeout:
raise TxnConfirmError(tx_hash, f"did not confirm within {txn_confirm_timeout}s")

await self._async_sleep(poll_interval_secs)

Expand Down Expand Up @@ -495,19 +533,27 @@ def submit_tx(
self,
transaction: SimpleTransaction,
sender_authenticator: AccountAuthenticator,
*,
txn_submit_timeout: float | None = None,
) -> PendingTransactionResponse:
if self._no_fee_payer:
return self._submit_direct(transaction, sender_authenticator)
return self._submit_direct(
transaction, sender_authenticator, txn_submit_timeout
)
return submit_fee_paid_transaction_sync(
self._config,
transaction,
sender_authenticator,
txn_submit_timeout=txn_submit_timeout,
)

def _send_tx(
self,
payload: InputEntryFunctionData,
account_override: Account | None = None,
*,
txn_submit_timeout: float | None = None,
txn_confirm_timeout: float | None = None,
) -> dict[str, Any]:
signer = account_override if account_override is not None else self._account
sender = signer.address()
Expand Down Expand Up @@ -541,9 +587,35 @@ def _send_tx(

sender_authenticator = self._sign_transaction(signer, transaction)

pending_tx = self.submit_tx(transaction, sender_authenticator)
if txn_submit_timeout is None:
txn_submit_timeout = DEFAULT_TXN_SUBMIT_TIMEOUT

try:
pending_tx = self.submit_tx(
transaction, sender_authenticator, txn_submit_timeout=txn_submit_timeout
)
except httpx.ConnectTimeout as e:
raise TxnSubmitError(
f"Failed to submit transaction: connection timeout to {self._config.fullnode_url}",
original_exception=e,
)
except httpx.ConnectError as e:
raise TxnSubmitError(
f"Failed to submit transaction: connection error - {e}",
original_exception=e,
)
except httpx.HTTPStatusError as e:
raise TxnSubmitError(
f"Failed to submit transaction: HTTP {e.response.status_code}",
original_exception=e,
)
except Exception as e:
raise TxnSubmitError(
f"Failed to submit transaction: {e}",
original_exception=e,
)

return self._wait_for_transaction(pending_tx.hash)
return self._wait_for_transaction(pending_tx.hash, txn_confirm_timeout=txn_confirm_timeout)

def _sign_transaction(
self,
Expand Down Expand Up @@ -614,14 +686,15 @@ def _submit_direct(
self,
transaction: SimpleTransaction,
sender_authenticator: AccountAuthenticator,
txn_submit_timeout: float | None = None,
) -> PendingTransactionResponse:
url = f"{self._config.fullnode_url}/transactions"
headers = self._build_node_headers()
headers["Content-Type"] = "application/x.aptos.signed_transaction+bcs"
bcs_bytes = self._serialize_signed_transaction(transaction, sender_authenticator)

def make_request(client: httpx.Client) -> PendingTransactionResponse:
response = client.post(url, content=bcs_bytes, headers=headers)
response = client.post(url, content=bcs_bytes, headers=headers, timeout=txn_submit_timeout)
if not response.is_success:
raise ValueError(
f"Transaction submission failed: {response.status_code} - {response.text}"
Expand All @@ -645,9 +718,11 @@ def make_request(client: httpx.Client) -> PendingTransactionResponse:
def _wait_for_transaction(
self,
tx_hash: str,
timeout_secs: float = 30.0,
txn_confirm_timeout: float | None = None, # Uses DEFAULT_TXN_CONFIRM_TIMEOUT if None
poll_interval_secs: float = 1.0,
) -> dict[str, Any]:
if txn_confirm_timeout is None:
txn_confirm_timeout = DEFAULT_TXN_CONFIRM_TIMEOUT
url = f"{self._config.fullnode_url}/transactions/by_hash/{tx_hash}"
headers = self._build_node_headers()
start_time = time.time()
Expand All @@ -664,11 +739,9 @@ def poll_loop(client: httpx.Client) -> dict[str, Any]:
return data
elif data.get("success") is False:
vm_status = data.get("vm_status", "Unknown error")
raise ValueError(f"Transaction failed: {vm_status}")
if time.time() - start_time > timeout_secs:
raise TimeoutError(
f"Transaction {tx_hash} did not complete within {timeout_secs}s"
)
raise TxnConfirmError(tx_hash, f"failed: {vm_status}")
if time.time() - start_time > txn_confirm_timeout:
raise TxnConfirmError(tx_hash, f"did not confirm within {txn_confirm_timeout}s")
time.sleep(poll_interval_secs)

if self._http_client is not None:
Expand Down
14 changes: 12 additions & 2 deletions src/decibel/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"Deployment",
"DecibelConfig",
"DEFAULT_COMPAT_VERSION",
"DEFAULT_TXN_CONFIRM_TIMEOUT",
"DEFAULT_TXN_SUBMIT_TIMEOUT",
"MAINNET_CONFIG",
"NETNA_CONFIG",
"TESTNET_CONFIG",
Expand All @@ -22,6 +24,14 @@
"get_perp_engine_global_address",
]

# Configurable timeout for transaction confirmation
# Default is 30 seconds
DEFAULT_TXN_CONFIRM_TIMEOUT = 30.0

# Configurable timeout for transaction submission
# Default is 10 seconds (should be shorter than confirmation timeout)
DEFAULT_TXN_SUBMIT_TIMEOUT = 10.0


class Network(str, Enum):
MAINNET = "mainnet"
Expand Down Expand Up @@ -81,10 +91,10 @@ def _create_deployment(package: str) -> Deployment:
)


_MAINNET_PACKAGE = "0xe6683d451db246750f180fb78d9b5e0a855dacba64ddf5810dffdaeb221e46bf"
_MAINNET_PACKAGE = "0x50ead22afd6ffd9769e3b3d6e0e64a2a350d68e8b102c4e72e33d0b8cfdfdb06"
_MAINNET_USDC = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
_NETNA_PACKAGE = "0xb8a5788314451ce4d2fbbad32e1bad88d4184b73943b7fe5166eab93cf1a5a95"
_TESTNET_PACKAGE = "0x952535c3049e52f195f26798c2f1340d7dd5100edbe0f464e520a974d16fbe9f"
_TESTNET_PACKAGE = "0xe7da2794b1d8af76532ed95f38bfdf1136abfd8ea3a240189971988a83101b7f"
_LOCAL_PACKAGE = "0xb8a5788314451ce4d2fbbad32e1bad88d4184b73943b7fe5166eab93cf1a5a95"
_DOCKER_PACKAGE = "0xb8a5788314451ce4d2fbbad32e1bad88d4184b73943b7fe5166eab93cf1a5a95"

Expand Down
51 changes: 51 additions & 0 deletions src/decibel/_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Custom exceptions for the Decibel SDK.

These exceptions help callers distinguish between failures that are safe to retry
(submission errors) vs failures that require checking transaction status first
(confirmation errors).
"""

from __future__ import annotations


class TxnConfirmError(Exception):
"""
Transaction was submitted but confirmation failed.

Causes:
- Transaction did not confirm within timeout (still pending or dropped)
- Transaction executed but reverted (VM error)
- Transaction failed during execution

CRITICAL: The transaction MAY be on-chain. Check tx_hash status before retrying
to avoid duplicate transactions.

Attributes:
tx_hash: The transaction hash that was submitted
message: Description of what went wrong
"""

def __init__(self, tx_hash: str, message: str) -> None:
self.tx_hash = tx_hash
super().__init__(f"Transaction {tx_hash}: {message}")


class TxnSubmitError(Exception):
"""
Transaction submission failed before reaching the blockchain.

Causes:
- Network connectivity issues (timeout, connection refused)
- RPC endpoint unavailable
- HTTP errors (5xx, 429 rate limit)
- Serialization errors

SAFE TO RETRY: The transaction was never submitted to the blockchain.

Attributes:
original_exception: The underlying exception that caused the failure
"""

def __init__(self, message: str, original_exception: Exception | None = None) -> None:
self.original_exception = original_exception
super().__init__(message)
Loading