diff --git a/CLAUDE.md b/CLAUDE.md index bfe4d77..993d5c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 719ef32..32ac3ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/swarm_provenance_uploader/__init__.py b/swarm_provenance_uploader/__init__.py index 73eec59..6999183 100644 --- a/swarm_provenance_uploader/__init__.py +++ b/swarm_provenance_uploader/__init__.py @@ -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: diff --git a/swarm_provenance_uploader/chain/__init__.py b/swarm_provenance_uploader/chain/__init__.py index 8cb460f..e279736 100644 --- a/swarm_provenance_uploader/chain/__init__.py +++ b/swarm_provenance_uploader/chain/__init__.py @@ -11,6 +11,7 @@ ChainConnectionError, ChainTransactionError, ChainValidationError, + InsufficientFundsError, DataNotRegisteredError, DataAlreadyRegisteredError, TransformationAlreadyExistsError, @@ -29,6 +30,7 @@ "ChainConnectionError", "ChainTransactionError", "ChainValidationError", + "InsufficientFundsError", "DataNotRegisteredError", "DataAlreadyRegisteredError", "TransformationAlreadyExistsError", diff --git a/swarm_provenance_uploader/chain/exceptions.py b/swarm_provenance_uploader/chain/exceptions.py index aa82f7d..82678a1 100644 --- a/swarm_provenance_uploader/chain/exceptions.py +++ b/swarm_provenance_uploader/chain/exceptions.py @@ -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.""" diff --git a/swarm_provenance_uploader/chain/provider.py b/swarm_provenance_uploader/chain/provider.py index 162ebba..b65ec3f 100644 --- a/swarm_provenance_uploader/chain/provider.py +++ b/swarm_provenance_uploader/chain/provider.py @@ -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, @@ -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, @@ -64,6 +68,8 @@ def _import_web3(): "contract_address": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9", "deploy_block": 0, "rpc_fallbacks": [], + "faucet_url": None, + "bridge_url": None, }, } diff --git a/swarm_provenance_uploader/cli.py b/swarm_provenance_uploader/cli.py index 91eb64a..ab592b3 100644 --- a/swarm_provenance_uploader/cli.py +++ b/swarm_provenance_uploader/cli.py @@ -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, @@ -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") @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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) @@ -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) @@ -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: diff --git a/swarm_provenance_uploader/core/chain_client.py b/swarm_provenance_uploader/core/chain_client.py index 9ce04d0..59fb88f 100644 --- a/swarm_provenance_uploader/core/chain_client.py +++ b/swarm_provenance_uploader/core/chain_client.py @@ -14,6 +14,7 @@ from ..chain.contract import DataStatus from ..chain.exceptions import ( ChainTransactionError, + InsufficientFundsError, DataAlreadyRegisteredError, DataNotRegisteredError, TransformationAlreadyExistsError, @@ -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. @@ -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. @@ -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 @@ -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) @@ -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) diff --git a/swarm_provenance_uploader/exceptions.py b/swarm_provenance_uploader/exceptions.py index 5d0f4ec..42002d6 100644 --- a/swarm_provenance_uploader/exceptions.py +++ b/swarm_provenance_uploader/exceptions.py @@ -193,6 +193,7 @@ def __init__(self, message: str, reason: str = None): ChainConnectionError, ChainTransactionError, ChainValidationError, + InsufficientFundsError, DataNotRegisteredError, DataAlreadyRegisteredError, TransformationAlreadyExistsError, diff --git a/tests/test_chain_client.py b/tests/test_chain_client.py index ebd1268..111a279 100644 --- a/tests/test_chain_client.py +++ b/tests/test_chain_client.py @@ -10,6 +10,7 @@ ChainConnectionError, ChainTransactionError, ChainValidationError, + InsufficientFundsError, DataAlreadyRegisteredError, DataNotRegisteredError, TransformationAlreadyExistsError, @@ -55,6 +56,7 @@ def mock_chain_deps(): mock_web3_instance.eth.chain_id = 84532 mock_web3_instance.eth.block_number = 12345678 mock_web3_instance.eth.get_balance.return_value = 1_000_000_000_000_000_000 # 1 ETH + mock_web3_instance.eth.gas_price = 1_000_000_000 # 1 gwei mock_web3_instance.eth.get_transaction_count.return_value = 0 mock_web3_instance.eth.estimate_gas.return_value = 100_000 mock_web3_instance.eth.send_raw_transaction.return_value = DUMMY_TX_HASH_BYTES @@ -2174,3 +2176,154 @@ def test_abi_has_data_merged_event(self): event_names = [e["name"] for e in abi if e.get("type") == "event"] assert "DataMerged" in event_names + + +class TestInsufficientFundsError: + """Tests for the InsufficientFundsError exception class.""" + + def test_inherits_from_chain_transaction_error(self): + """Tests that InsufficientFundsError is a subclass of ChainTransactionError.""" + err = InsufficientFundsError("low funds") + assert isinstance(err, ChainTransactionError) + assert isinstance(err, InsufficientFundsError) + + def test_carries_structured_data(self): + """Tests that exception carries wallet, balance, cost, and chain info.""" + err = InsufficientFundsError( + "low funds", + wallet_address=DUMMY_ADDRESS, + balance_wei=500, + estimated_cost_wei=1000, + chain_name="base-sepolia", + ) + assert err.wallet_address == DUMMY_ADDRESS + assert err.balance_wei == 500 + assert err.estimated_cost_wei == 1000 + assert err.chain_name == "base-sepolia" + assert err.tx_hash is None + + def test_caught_by_chain_transaction_error_handler(self): + """Tests backward compatibility — caught by existing ChainTransactionError handlers.""" + with pytest.raises(ChainTransactionError): + raise InsufficientFundsError("low funds") + + +class TestPreflightBalanceCheck: + """Tests for the pre-flight balance check in ChainClient.""" + + def test_zero_balance_raises(self, mock_chain_deps): + """Tests that zero balance raises InsufficientFundsError.""" + from swarm_provenance_uploader.core.chain_client import ChainClient + + mock_chain_deps["web3_instance"].eth.get_balance.return_value = 0 + + client = ChainClient(chain="base-sepolia") + with pytest.raises(InsufficientFundsError) as exc_info: + client._check_balance(estimated_cost_wei=100_000_000_000_000) + assert exc_info.value.wallet_address == DUMMY_ADDRESS + assert exc_info.value.balance_wei == 0 + assert exc_info.value.chain_name == "base-sepolia" + + def test_below_cost_raises(self, mock_chain_deps): + """Tests that balance below estimated cost raises InsufficientFundsError.""" + from swarm_provenance_uploader.core.chain_client import ChainClient + + # Balance is 0.0001 ETH, cost is 0.001 ETH + mock_chain_deps["web3_instance"].eth.get_balance.return_value = 100_000_000_000_000 + + client = ChainClient(chain="base-sepolia") + with pytest.raises(InsufficientFundsError) as exc_info: + client._check_balance(estimated_cost_wei=1_000_000_000_000_000) + assert exc_info.value.estimated_cost_wei == 1_000_000_000_000_000 + + def test_above_cost_passes(self, mock_chain_deps): + """Tests that sufficient balance does not raise.""" + from swarm_provenance_uploader.core.chain_client import ChainClient + + # Balance is 1 ETH (default in fixture) + client = ChainClient(chain="base-sepolia") + # Should not raise + client._check_balance(estimated_cost_wei=100_000_000_000_000) + + def test_low_balance_warns(self, mock_chain_deps, capsys): + """Tests that balance above cost but below LOW_BALANCE_WEI logs a warning.""" + from swarm_provenance_uploader.core.chain_client import ChainClient, LOW_BALANCE_WEI + + # Balance is 0.0005 ETH — above MIN but below LOW + mock_chain_deps["web3_instance"].eth.get_balance.return_value = 500_000_000_000_000 + + client = ChainClient(chain="base-sepolia") + # Should not raise, but should warn + client._check_balance(estimated_cost_wei=100_000_000_000_000) + captured = capsys.readouterr() + assert "WARNING" in captured.err or "Low balance" in captured.err + + def test_send_transaction_preflight_raises(self, mock_chain_deps): + """Tests that _send_transaction raises InsufficientFundsError on low balance.""" + from swarm_provenance_uploader.core.chain_client import ChainClient + + # Set balance to 0 + mock_chain_deps["web3_instance"].eth.get_balance.return_value = 0 + + # Pre-check: hash not registered (so anchor proceeds to _send_transaction) + mock_chain_deps["contract"].functions.getDataRecord.return_value.call.return_value = ( + DUMMY_HASH_BYTES, ZERO_ADDRESS, 0, "", [], [], 0, + ) + + client = ChainClient(chain="base-sepolia") + with pytest.raises(InsufficientFundsError) as exc_info: + client.anchor(swarm_hash=DUMMY_HASH) + assert exc_info.value.wallet_address == DUMMY_ADDRESS + + def test_send_transaction_detects_insufficient_funds_string(self, mock_chain_deps): + """Tests that RPC 'insufficient funds' errors are converted to InsufficientFundsError.""" + from swarm_provenance_uploader.core.chain_client import ChainClient + + # Balance is enough for pre-flight check + mock_chain_deps["web3_instance"].eth.get_balance.return_value = 1_000_000_000_000_000_000 + + # Pre-check: hash not registered + mock_chain_deps["contract"].functions.getDataRecord.return_value.call.return_value = ( + DUMMY_HASH_BYTES, ZERO_ADDRESS, 0, "", [], [], 0, + ) + + # Make send_raw_transaction raise with "insufficient funds" message + mock_chain_deps["web3_instance"].eth.send_raw_transaction.side_effect = Exception( + "insufficient funds for gas * price + value" + ) + + client = ChainClient(chain="base-sepolia") + with pytest.raises(InsufficientFundsError) as exc_info: + client.anchor(swarm_hash=DUMMY_HASH) + assert exc_info.value.wallet_address == DUMMY_ADDRESS + assert exc_info.value.chain_name == "base-sepolia" + + +class TestChainPresetFundUrls: + """Tests that CHAIN_PRESETS include faucet/bridge URLs.""" + + def test_base_sepolia_has_faucet(self): + """Tests that base-sepolia preset has a faucet URL.""" + from swarm_provenance_uploader.chain.provider import CHAIN_PRESETS + + preset = CHAIN_PRESETS["base-sepolia"] + assert preset["faucet_url"] is not None + assert "faucet" in preset["faucet_url"] or "sepolia" in preset["faucet_url"] + assert preset["bridge_url"] is None + + def test_base_has_bridge(self): + """Tests that base mainnet preset has a bridge URL.""" + from swarm_provenance_uploader.chain.provider import CHAIN_PRESETS + + preset = CHAIN_PRESETS["base"] + assert preset["bridge_url"] is not None + assert "bridge" in preset["bridge_url"] + assert preset["faucet_url"] is None + + def test_localhost_has_neither(self): + """Tests that localhost preset has no faucet or bridge URL.""" + from swarm_provenance_uploader.chain.provider import CHAIN_PRESETS + + preset = CHAIN_PRESETS["localhost"] + assert preset["faucet_url"] is None + assert preset["bridge_url"] is None diff --git a/tests/test_cli.py b/tests/test_cli.py index 46a709e..7048c84 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4288,4 +4288,101 @@ def test_free_and_x402_both_set(self, mocker): assert result.exit_code == 0, f"CLI Failed: {result.stdout}" assert _backend_config["free_tier"] is True - assert _x402_config["enabled"] is True \ No newline at end of file + assert _x402_config["enabled"] is True + + +# ============================================================================= +# Chain Insufficient Funds Tests +# ============================================================================= + + +class TestChainInsufficientFunds: + """Tests for actionable insufficient funds error handling.""" + + def test_anchor_insufficient_funds_shows_actionable_error(self, mocker): + """Tests that anchor shows wallet, balance, and faucet link on insufficient funds.""" + from swarm_provenance_uploader.exceptions import InsufficientFundsError + + mock_client = mocker.MagicMock() + mock_client.anchor.side_effect = InsufficientFundsError( + "Insufficient funds", + wallet_address=DUMMY_ADDRESS, + balance_wei=50_000_000_000_000, # 0.00005 ETH + estimated_cost_wei=120_000_000_000_000, # 0.00012 ETH + chain_name="base-sepolia", + ) + + mocker.patch("swarm_provenance_uploader.cli._get_chain_client", return_value=mock_client) + + result = runner.invoke(app, ["chain", "anchor", DUMMY_SWARM_REF]) + + assert result.exit_code == 1 + assert "Insufficient funds" in result.output + assert DUMMY_ADDRESS in result.output + assert "0.000050" in result.output # balance + assert "faucet" in result.output.lower() or "alchemy" in result.output.lower() + + def test_anchor_insufficient_funds_json_output(self, mocker): + """Tests that --json outputs structured JSON error on insufficient funds.""" + from swarm_provenance_uploader.exceptions import InsufficientFundsError + import json + + mock_client = mocker.MagicMock() + mock_client.anchor.side_effect = InsufficientFundsError( + "Insufficient funds", + wallet_address=DUMMY_ADDRESS, + balance_wei=50_000_000_000_000, + estimated_cost_wei=120_000_000_000_000, + chain_name="base-sepolia", + ) + + mocker.patch("swarm_provenance_uploader.cli._get_chain_client", return_value=mock_client) + + result = runner.invoke(app, ["chain", "anchor", DUMMY_SWARM_REF, "--json"]) + + assert result.exit_code == 1 + output = json.loads(result.output) + assert output["error"] == "insufficient_funds" + assert output["wallet_address"] == DUMMY_ADDRESS + assert output["chain"] == "base-sepolia" + assert "faucet_url" in output + + def test_mainnet_shows_bridge_url(self, mocker): + """Tests that mainnet chain shows bridge URL instead of faucet.""" + from swarm_provenance_uploader.exceptions import InsufficientFundsError + + mock_client = mocker.MagicMock() + mock_client.anchor.side_effect = InsufficientFundsError( + "Insufficient funds", + wallet_address=DUMMY_ADDRESS, + balance_wei=0, + estimated_cost_wei=100_000_000_000_000, + chain_name="base", + ) + + mocker.patch("swarm_provenance_uploader.cli._get_chain_client", return_value=mock_client) + + result = runner.invoke(app, ["chain", "anchor", DUMMY_SWARM_REF]) + + assert result.exit_code == 1 + assert "bridge" in result.output.lower() + + def test_balance_command_uses_preset_faucet_url(self, mocker): + """Tests that chain balance command uses faucet URL from CHAIN_PRESETS.""" + from swarm_provenance_uploader.models import ChainWalletInfo + + mock_client = mocker.MagicMock() + mock_client.balance.return_value = ChainWalletInfo( + address=DUMMY_ADDRESS, + balance_wei=1_000_000_000_000_000_000, + balance_eth="1.0", + chain="base-sepolia", + contract_address="0xD4a724CD7f5C4458cD2d884C2af6f011aC3Af80a", + ) + + mocker.patch("swarm_provenance_uploader.cli._get_chain_client", return_value=mock_client) + + result = runner.invoke(app, ["chain", "balance"]) + + assert result.exit_code == 0 + assert "alchemy.com/faucets/base-sepolia" in result.output \ No newline at end of file