From e08b80d8ebc7bcf68b148d86dae3339bb87b487f Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Mon, 9 Nov 2026 11:07:50 +0100 Subject: [PATCH] fix: validate transfer amounts, launch name and ticker, config keys wallet transfer sent a 0-lamport transfer for 0 and crashed on negative or infinite amounts; it now refuses anything that isn't a positive number, and a SOL amount below one lamport. launch refuses a blank name or ticker, and config set refuses keys it doesn't know. Co-Authored-By: Claude Opus 5.5 --- src/pumpfun_cli/commands/config.py | 13 ++++++++++- src/pumpfun_cli/commands/launch.py | 4 ++++ src/pumpfun_cli/commands/wallet.py | 12 ++++++++++ src/pumpfun_cli/core/config.py | 2 ++ src/pumpfun_cli/core/wallet.py | 2 ++ tests/test_commands/test_config_cmd.py | 21 +++++++++++++++++ tests/test_commands/test_human_output.py | 29 ++++++++++++++++++++++++ tests/test_commands/test_wallet_cmd.py | 23 +++++++++++++++++++ tests/test_core/test_wallet.py | 22 ++++++++++++++++++ 9 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/pumpfun_cli/commands/config.py b/src/pumpfun_cli/commands/config.py index 4723fc1..3a867e7 100644 --- a/src/pumpfun_cli/commands/config.py +++ b/src/pumpfun_cli/commands/config.py @@ -2,7 +2,12 @@ import typer -from pumpfun_cli.core.config import delete_config_value, load_config, save_config_value +from pumpfun_cli.core.config import ( + KNOWN_KEYS, + delete_config_value, + load_config, + save_config_value, +) from pumpfun_cli.group import JsonAwareGroup from pumpfun_cli.output import error, render @@ -21,6 +26,12 @@ def _config_callback(ctx: typer.Context): @app.command("set") def config_set(ctx: typer.Context, key: str, value: str): """Set a config value.""" + if key not in KNOWN_KEYS: + error( + f"Unknown config key: {key}", + hint=f"Valid keys: {', '.join(sorted(KNOWN_KEYS))}", + exit_code=2, + ) save_config_value(key, value) json_mode = ctx.obj.json_mode if ctx.obj else False if not render({"key": key, "value": value, "status": "saved"}, json_mode): diff --git a/src/pumpfun_cli/commands/launch.py b/src/pumpfun_cli/commands/launch.py index a1e960a..1136603 100644 --- a/src/pumpfun_cli/commands/launch.py +++ b/src/pumpfun_cli/commands/launch.py @@ -45,6 +45,10 @@ def launch( ): """Launch a new token on pump.fun (create_v2 + extend_account).""" state = ctx.obj + if not name.strip(): + error("Token name cannot be empty.", exit_code=2) + if not ticker.strip(): + error("Token ticker cannot be empty.", exit_code=2) if not state or not state.rpc: error("RPC endpoint not configured.", hint="Run: pumpfun config set rpc ") if slippage < 0 or slippage > 100: diff --git a/src/pumpfun_cli/commands/wallet.py b/src/pumpfun_cli/commands/wallet.py index ef0982b..280157a 100644 --- a/src/pumpfun_cli/commands/wallet.py +++ b/src/pumpfun_cli/commands/wallet.py @@ -1,6 +1,7 @@ """Wallet management commands.""" import asyncio +import math import os import sys from pathlib import Path @@ -215,6 +216,17 @@ def wallet_transfer( confirm: bool = typer.Option(False, "--confirm", help="Wait for transaction confirmation"), ): """Transfer SOL or tokens to another wallet.""" + if amount.lower() != "all": + try: + value = float(amount) + except ValueError: + value = math.nan + if not math.isfinite(value) or value <= 0: + error( + "Amount must be a number greater than 0, or 'all'.", + hint=f"Got: {amount}", + exit_code=2, + ) path = _get_keystore_path(ctx) if not Path(path).exists(): error("No wallet found.", hint="Run: pumpfun wallet create") diff --git a/src/pumpfun_cli/core/config.py b/src/pumpfun_cli/core/config.py index 7b0a43c..86d596c 100644 --- a/src/pumpfun_cli/core/config.py +++ b/src/pumpfun_cli/core/config.py @@ -57,6 +57,8 @@ def delete_config_value(key: str): "compute_units": "PUMPFUN_COMPUTE_UNITS", } +KNOWN_KEYS = frozenset(ENV_MAP) + def resolve_value(key: str, flag: str | None = None) -> str | None: if flag is not None: diff --git a/src/pumpfun_cli/core/wallet.py b/src/pumpfun_cli/core/wallet.py index 4842dae..3fbfe48 100644 --- a/src/pumpfun_cli/core/wallet.py +++ b/src/pumpfun_cli/core/wallet.py @@ -120,6 +120,8 @@ async def transfer_sol( if recipient is None: return invalid_pubkey_error(recipient_str, "recipient address") lamports = to_raw(sol_amount, SOL_DECIMALS) + if lamports <= 0: + return {"error": "invalid_amount", "message": "Amount is less than 1 lamport."} client = RpcClient(rpc_url) try: ix = transfer( diff --git a/tests/test_commands/test_config_cmd.py b/tests/test_commands/test_config_cmd.py index f2a3b41..79d9970 100644 --- a/tests/test_commands/test_config_cmd.py +++ b/tests/test_commands/test_config_cmd.py @@ -80,3 +80,24 @@ def test_config_get_json_trailing(tmp_path, monkeypatch): assert result.exit_code == 0 data = json.loads(result.output) assert data == {"key": "rpc", "value": "https://example.com"} + + +def test_config_set_rejects_unknown_key(tmp_path, monkeypatch): + """A typo'd key is refused, lists the valid keys, and is not saved.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + result = runner.invoke(app, ["config", "set", "rpcc", "https://example.com"]) + assert result.exit_code == 2 + assert "Unknown config key: rpcc" in result.output + assert "compute_units, keyfile, priority_fee, rpc" in result.output + assert not (tmp_path / "pumpfun-cli" / "config.toml").exists() + + +def test_config_delete_still_removes_unknown_key(tmp_path, monkeypatch): + """A typo saved by an older version can still be deleted.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + config_dir = tmp_path / "pumpfun-cli" + config_dir.mkdir() + (config_dir / "config.toml").write_text('rpcc = "https://example.com"\n') + result = runner.invoke(app, ["config", "delete", "rpcc"]) + assert result.exit_code == 0 + assert (config_dir / "config.toml").read_text().strip() == "" diff --git a/tests/test_commands/test_human_output.py b/tests/test_commands/test_human_output.py index d3999e7..c5ff852 100644 --- a/tests/test_commands/test_human_output.py +++ b/tests/test_commands/test_human_output.py @@ -236,6 +236,35 @@ def test_launch_rejects_negative_buy(wallet): launch.assert_not_called() +@pytest.mark.parametrize( + ("name", "ticker", "message"), + [ + ("", "T", "Token name cannot be empty."), + (" ", "T", "Token name cannot be empty."), + ("N", "", "Token ticker cannot be empty."), + ("N", " ", "Token ticker cannot be empty."), + ], +) +def test_launch_rejects_blank_name_or_ticker(wallet, name, ticker, message): + with patch("pumpfun_cli.commands.launch.launch_token", new_callable=AsyncMock) as launch: + result = runner.invoke( + app, + ["--rpc", "http://rpc", "launch", "--name", name, "--ticker", ticker, "--uri", "u"], + ) + assert result.exit_code == 2 + assert message in result.output + launch.assert_not_called() + + +def test_launch_with_uri_needs_no_desc(wallet): + with patch("pumpfun_cli.commands.launch.launch_token", new_callable=AsyncMock) as launch: + launch.return_value = {"error": "stop", "message": "stop"} + runner.invoke( + app, ["--rpc", "http://rpc", "launch", "--name", "N", "--ticker", "T", "--uri", "u"] + ) + launch.assert_called_once() + + def test_tokens_table_names_quote_and_survives_missing_chain_data(): rows = [ { diff --git a/tests/test_commands/test_wallet_cmd.py b/tests/test_commands/test_wallet_cmd.py index ab6cf73..b751f62 100644 --- a/tests/test_commands/test_wallet_cmd.py +++ b/tests/test_commands/test_wallet_cmd.py @@ -3,6 +3,7 @@ import os from unittest.mock import patch +import pytest from typer.testing import CliRunner from pumpfun_cli.cli import app @@ -345,3 +346,25 @@ def test_wallet_drain_no_wallet(tmp_path): ], ) assert result.exit_code != 0 + + +@pytest.mark.parametrize("amount", ["0", "0.0", "-1", "abc", "nan", "inf"]) +@pytest.mark.parametrize("mint", [None, "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"]) +def test_wallet_transfer_rejects_bad_amount(tmp_path, amount, mint): + """Zero, negative or non-numeric amounts never reach core.""" + keyfile = _make_wallet(tmp_path) + env = {"PUMPFUN_PASSWORD": "testpass", "PUMPFUN_RPC": "http://fake-rpc"} + args = ["--keyfile", keyfile, "wallet", "transfer"] + if mint: + args += ["--mint", mint] + args += ["--", "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", amount] + with ( + patch.dict(os.environ, env), + patch("pumpfun_cli.commands.wallet.transfer_sol") as sol, + patch("pumpfun_cli.commands.wallet.transfer_token") as token, + ): + result = runner.invoke(app, args) + assert result.exit_code == 2 + assert "greater than 0" in result.output + sol.assert_not_called() + token.assert_not_called() diff --git a/tests/test_core/test_wallet.py b/tests/test_core/test_wallet.py index 61184b8..cbaf85b 100644 --- a/tests/test_core/test_wallet.py +++ b/tests/test_core/test_wallet.py @@ -733,3 +733,25 @@ def test_drain_wallet_wrong_password(tmp_path): "http://rpc", keyfile, "wrongpass", "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" ) ) + + +@pytest.mark.asyncio +async def test_transfer_sol_below_one_lamport_sends_nothing(tmp_path): + """An amount that rounds to 0 lamports is refused before any RPC call.""" + from pumpfun_cli.core.wallet import transfer_sol + from pumpfun_cli.crypto import encrypt_keypair + + keyfile = tmp_path / "wallet.enc" + encrypt_keypair(Keypair(), "testpass", keyfile) + + with patch("pumpfun_cli.protocol.client.RpcClient") as client_cls: + result = await transfer_sol( + "http://rpc", + str(keyfile), + "testpass", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + 0.0000000001, + ) + + assert result["error"] == "invalid_amount" + client_cls.assert_not_called()