Skip to content
Merged
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ The codebase implements comprehensive error handling:
- Postage stamp validation with retry logic
- File I/O error handling with clear user feedback
- JSON parsing errors with context
- Chain pre-flight balance check: `_send_transaction()` estimates gas cost and raises `InsufficientFundsError` with actionable guidance (wallet address, balance, estimated cost, faucet/bridge URL) before broadcasting

### Configuration Management
Uses python-dotenv for environment configuration:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "swarm-provenance-uploader"
version = "0.9.1"
version = "0.9.2"
description = "A CLI toolkit for wrapping data and uploading to Swarm."
readme = "README.md"
requires-python = ">=3.8"
Expand Down
2 changes: 1 addition & 1 deletion swarm_provenance_uploader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import subprocess
from pathlib import Path

__version_base__ = "0.9.1"
__version_base__ = "0.9.2"


def _get_git_hash() -> str:
Expand Down
2 changes: 2 additions & 0 deletions swarm_provenance_uploader/chain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ChainConnectionError,
ChainTransactionError,
ChainValidationError,
InsufficientFundsError,
DataNotRegisteredError,
DataAlreadyRegisteredError,
TransformationAlreadyExistsError,
Expand All @@ -29,6 +30,7 @@
"ChainConnectionError",
"ChainTransactionError",
"ChainValidationError",
"InsufficientFundsError",
"DataNotRegisteredError",
"DataAlreadyRegisteredError",
"TransformationAlreadyExistsError",
Expand Down
23 changes: 23 additions & 0 deletions swarm_provenance_uploader/chain/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ def __init__(
self.data_type = data_type


class InsufficientFundsError(ChainTransactionError):
"""Wallet balance too low to cover gas for a chain transaction.

Carries structured data so CLI can show actionable guidance
(wallet address, balance, estimated cost, faucet/bridge link).
"""

def __init__(
self,
message: str,
wallet_address: str = None,
balance_wei: int = None,
estimated_cost_wei: int = None,
chain_name: str = None,
tx_hash: str = None,
):
super().__init__(message, tx_hash=tx_hash)
self.wallet_address = wallet_address
self.balance_wei = balance_wei
self.estimated_cost_wei = estimated_cost_wei
self.chain_name = chain_name


class TransformationAlreadyExistsError(ChainError):
"""Transformation (original -> new) pair is already recorded on-chain."""

Expand Down
6 changes: 6 additions & 0 deletions swarm_provenance_uploader/chain/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def _import_web3():
"https://base-sepolia-rpc.publicnode.com",
"https://base-sepolia.drpc.org",
],
"faucet_url": "https://www.alchemy.com/faucets/base-sepolia",
"bridge_url": None,
},
"base": {
"chain_id": 8453,
Expand All @@ -56,6 +58,8 @@ def _import_web3():
"https://base-rpc.publicnode.com",
"https://base.drpc.org",
],
"faucet_url": None,
"bridge_url": "https://bridge.base.org",
},
"localhost": {
"chain_id": 31337,
Expand All @@ -64,6 +68,8 @@ def _import_web3():
"contract_address": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9",
"deploy_block": 0,
"rpc_fallbacks": [],
"faucet_url": None,
"bridge_url": None,
},
}

Expand Down
80 changes: 78 additions & 2 deletions swarm_provenance_uploader/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1663,6 +1663,48 @@ def notary_verify(

# --- Chain Subcommands ---


def _handle_insufficient_funds(
e: "exceptions.InsufficientFundsError",
output_json: bool = False,
) -> None:
"""Show actionable guidance when wallet balance is too low for gas."""
from .chain.provider import CHAIN_PRESETS

balance_eth = (e.balance_wei or 0) / 1e18
cost_eth = (e.estimated_cost_wei or 0) / 1e18

if output_json:
data = {
"error": "insufficient_funds",
"wallet_address": e.wallet_address,
"balance_eth": f"{balance_eth:.6f}",
"estimated_cost_eth": f"{cost_eth:.6f}",
"chain": e.chain_name,
}
preset = CHAIN_PRESETS.get(e.chain_name or "", {})
if preset.get("faucet_url"):
data["faucet_url"] = preset["faucet_url"]
if preset.get("bridge_url"):
data["bridge_url"] = preset["bridge_url"]
typer.echo(json.dumps(data, indent=2))
return

typer.secho("ERROR: Insufficient funds for gas.", fg=typer.colors.RED, err=True)
typer.echo(f" Wallet: {e.wallet_address}", err=True)
typer.echo(f" Balance: {balance_eth:.6f} ETH", err=True)
if e.estimated_cost_wei:
typer.echo(f" Estimated cost: {cost_eth:.6f} ETH", err=True)

preset = CHAIN_PRESETS.get(e.chain_name or "", {})
faucet = preset.get("faucet_url")
bridge = preset.get("bridge_url")
if faucet:
typer.echo(f"\n Get testnet ETH: {faucet}", err=True)
elif bridge:
typer.echo(f"\n Bridge ETH to Base: {bridge}", err=True)


@chain_app.command("balance")
def chain_balance(
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output.")] = False,
Expand Down Expand Up @@ -1696,9 +1738,16 @@ def chain_balance(
typer.echo(f" Chain: {result.chain}")
typer.echo(f" Contract: {result.contract_address}")

if result.chain == "base-sepolia":
from .chain.provider import CHAIN_PRESETS
preset = CHAIN_PRESETS.get(result.chain, {})
faucet = preset.get("faucet_url")
bridge = preset.get("bridge_url")
if faucet:
typer.echo("")
typer.echo(f"Get testnet ETH: {faucet}")
elif bridge:
typer.echo("")
typer.echo("Get testnet ETH: https://www.alchemy.com/faucets/base-sepolia")
typer.echo(f"Bridge ETH: {bridge}")


@chain_app.command("verify")
Expand Down Expand Up @@ -1896,6 +1945,9 @@ def chain_anchor(
typer.echo(f" Type: {e.data_type}")
typer.echo(f" Time: {ts_str}")
raise typer.Exit(code=1)
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainTransactionError as e:
typer.secho(f"ERROR: Transaction failed: {e}", fg=typer.colors.RED, err=True)
if e.tx_hash:
Expand Down Expand Up @@ -1946,6 +1998,9 @@ def chain_access(
result = client.access(swarm_hash, verbose=verbose)
except typer.Exit:
raise
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainTransactionError as e:
typer.secho(f"ERROR: Transaction failed: {e}", fg=typer.colors.RED, err=True)
if e.tx_hash:
Expand Down Expand Up @@ -2009,6 +2064,9 @@ def chain_status(
except exceptions.DataNotRegisteredError:
typer.secho(f"ERROR: {swarm_hash} is not registered on-chain.", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainTransactionError as e:
typer.secho(f"ERROR: Transaction failed: {e}", fg=typer.colors.RED, err=True)
if e.tx_hash:
Expand Down Expand Up @@ -2081,6 +2139,9 @@ def chain_transfer(
result = client.transfer_ownership(swarm_hash, new_owner=to, verbose=verbose)
except typer.Exit:
raise
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainTransactionError as e:
typer.secho(f"ERROR: Transaction failed: {e}", fg=typer.colors.RED, err=True)
if e.tx_hash:
Expand Down Expand Up @@ -2139,6 +2200,9 @@ def chain_delegate(
result = client.set_delegate(delegate=address, authorized=authorize, verbose=verbose)
except typer.Exit:
raise
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainTransactionError as e:
typer.secho(f"ERROR: Transaction failed: {e}", fg=typer.colors.RED, err=True)
if e.tx_hash:
Expand Down Expand Up @@ -2195,6 +2259,9 @@ def chain_transform(
typer.secho(f"ERROR: Original hash is not registered on-chain.", fg=typer.colors.RED, err=True)
typer.echo(f" Anchor it first: swarm-prov-upload chain anchor {e.data_hash or original_hash}")
raise typer.Exit(code=1)
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainTransactionError as e:
typer.secho(f"ERROR: Transaction failed: {e}", fg=typer.colors.RED, err=True)
if e.tx_hash:
Expand Down Expand Up @@ -2305,6 +2372,9 @@ def chain_protect(
# Already anchored is fine for protect — continue with transform
if not output_json:
typer.secho(f"New hash already anchored, continuing.", fg=typer.colors.YELLOW)
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainError as e:
typer.secho(f"ERROR: Failed to anchor new hash: {e}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
Expand All @@ -2315,6 +2385,9 @@ def chain_protect(
results["transform"] = transform_result
if not output_json:
typer.secho(f"Transformation recorded.", fg=typer.colors.GREEN)
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainError as e:
typer.secho(f"ERROR: Failed to record transformation: {e}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
Expand Down Expand Up @@ -2405,6 +2478,9 @@ def chain_merge(
except exceptions.ChainValidationError as e:
typer.secho(f"ERROR: Validation failed: {e}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
except exceptions.InsufficientFundsError as e:
_handle_insufficient_funds(e, output_json=output_json)
raise typer.Exit(code=1)
except exceptions.ChainTransactionError as e:
typer.secho(f"ERROR: Transaction failed: {e}", fg=typer.colors.RED, err=True)
if e.tx_hash:
Expand Down
66 changes: 65 additions & 1 deletion swarm_provenance_uploader/core/chain_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ..chain.contract import DataStatus
from ..chain.exceptions import (
ChainTransactionError,
InsufficientFundsError,
DataAlreadyRegisteredError,
DataNotRegisteredError,
TransformationAlreadyExistsError,
Expand All @@ -31,6 +32,10 @@

logger = logging.getLogger(__name__)

# Balance thresholds for pre-flight checks
MIN_BALANCE_WEI = 100_000_000_000_000 # 0.0001 ETH — hard floor
LOW_BALANCE_WEI = 1_000_000_000_000_000 # 0.001 ETH — warning threshold


class ChainClient:
"""High-level client for DataProvenance smart contract operations.
Expand Down Expand Up @@ -108,6 +113,44 @@ def contract_address(self) -> str:

# --- Internal helpers ---

def _check_balance(self, estimated_cost_wei: int = 0) -> None:
"""
Pre-flight balance check before sending a transaction.

Args:
estimated_cost_wei: Estimated transaction cost in wei.

Raises:
InsufficientFundsError: If balance is below the estimated cost
or below the hard minimum (MIN_BALANCE_WEI).
"""
import sys

balance_wei = self._wallet.get_balance(self._provider.web3)
threshold = max(estimated_cost_wei, MIN_BALANCE_WEI)

if balance_wei < threshold:
balance_eth = balance_wei / 1e18
cost_eth = estimated_cost_wei / 1e18
raise InsufficientFundsError(
f"Insufficient funds: balance {balance_eth:.6f} ETH, "
f"estimated cost {cost_eth:.6f} ETH",
wallet_address=self._wallet.address,
balance_wei=balance_wei,
estimated_cost_wei=estimated_cost_wei,
chain_name=self._provider.chain,
)

if balance_wei < LOW_BALANCE_WEI:
import typer
balance_eth = balance_wei / 1e18
typer.secho(
f"WARNING: Low balance ({balance_eth:.6f} ETH). "
f"Transaction may fail if gas prices spike.",
fg=typer.colors.YELLOW,
err=True,
)

def _send_transaction(self, tx: dict, verbose: bool = False) -> dict:
"""
Estimate gas, sign, broadcast, and wait for receipt.
Expand All @@ -120,6 +163,7 @@ def _send_transaction(self, tx: dict, verbose: bool = False) -> dict:
Transaction receipt dict.

Raises:
InsufficientFundsError: If wallet balance is too low.
ChainTransactionError: If transaction fails.
"""
web3 = self._provider.web3
Expand All @@ -141,6 +185,11 @@ def _send_transaction(self, tx: dict, verbose: bool = False) -> dict:
if verbose:
print(f"DEBUG: Gas limit: {tx['gas']}")

# Pre-flight balance check
gas_price = tx.get("gasPrice") or web3.eth.gas_price
estimated_cost = tx["gas"] * gas_price
self._check_balance(estimated_cost)

# Sign and send
raw_tx = self._wallet.sign_transaction(tx)
tx_hash = web3.eth.send_raw_transaction(raw_tx)
Expand Down Expand Up @@ -168,9 +217,24 @@ def _send_transaction(self, tx: dict, verbose: bool = False) -> dict:

return receipt

except ChainTransactionError:
except (ChainTransactionError, InsufficientFundsError):
raise
except Exception as e:
# Detect "insufficient funds" in RPC error messages and convert
if "insufficient funds" in str(e).lower():
try:
balance_wei = self._wallet.get_balance(self._provider.web3)
raise InsufficientFundsError(
f"Insufficient funds for gas: {e}",
wallet_address=self._wallet.address,
balance_wei=balance_wei,
estimated_cost_wei=0,
chain_name=self._provider.chain,
) from e
except InsufficientFundsError:
raise
except Exception:
pass # Fall through to generic handler
tx_hash_str = None
if "tx_hash" in dir():
tx_hash_str = tx_hash.hex() if hasattr(tx_hash, "hex") else str(tx_hash)
Expand Down
1 change: 1 addition & 0 deletions swarm_provenance_uploader/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ def __init__(self, message: str, reason: str = None):
ChainConnectionError,
ChainTransactionError,
ChainValidationError,
InsufficientFundsError,
DataNotRegisteredError,
DataAlreadyRegisteredError,
TransformationAlreadyExistsError,
Expand Down
Loading
Loading