From 540c1cfb14cc0d7f3e5fda5f57566ae2cbadc013 Mon Sep 17 00:00:00 2001 From: vesperhex Date: Tue, 17 Mar 2026 13:52:33 +0300 Subject: [PATCH] feat: validate slippage range (0-100) in buy and sell commands Co-Authored-By: Claude Opus 4.6 --- src/pumpfun_cli/commands/trade.py | 8 ++ tests/test_commands/test_trade_cmd.py | 164 ++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/src/pumpfun_cli/commands/trade.py b/src/pumpfun_cli/commands/trade.py index e72e012..40b4376 100644 --- a/src/pumpfun_cli/commands/trade.py +++ b/src/pumpfun_cli/commands/trade.py @@ -43,6 +43,12 @@ def _validate_mint(mint: str): error("Invalid mint address.", hint="Provide a valid base58 Solana address.") +def _validate_slippage(slippage: int) -> None: + """Validate slippage is between 0 and 100, or exit with error.""" + if slippage < 0 or slippage > 100: + error("Slippage must be between 0 and 100.", hint=f"Got: {slippage}") + + def _require_rpc_and_wallet(ctx: typer.Context) -> tuple: """Return (rpc, keyfile, password) or exit with error.""" state = ctx.obj @@ -76,6 +82,7 @@ def buy( ): """Buy tokens with SOL.""" _validate_mint(mint) + _validate_slippage(slippage) rpc, keyfile, password = _require_rpc_and_wallet(ctx) overrides = _get_overrides(ctx) try: @@ -164,6 +171,7 @@ def sell( ): """Sell tokens for SOL.""" _validate_mint(mint) + _validate_slippage(slippage) rpc, keyfile, password = _require_rpc_and_wallet(ctx) overrides = _get_overrides(ctx) try: diff --git a/tests/test_commands/test_trade_cmd.py b/tests/test_commands/test_trade_cmd.py index c16db8b..f4e6805 100644 --- a/tests/test_commands/test_trade_cmd.py +++ b/tests/test_commands/test_trade_cmd.py @@ -451,6 +451,170 @@ def test_sell_insufficient_balance_json(tmp_path, monkeypatch): assert "Insufficient" in result.output +def test_buy_slippage_negative(): + """Buy with negative slippage exits with error.""" + result = runner.invoke( + app, ["--rpc", "https://fake.rpc", "buy", "--slippage", "-5", _FAKE_MINT, "0.01"] + ) + assert result.exit_code != 0 + assert "Slippage must be between 0 and 100" in result.output + + +def test_buy_slippage_above_100(): + """Buy with slippage above 100 exits with error.""" + result = runner.invoke( + app, ["--rpc", "https://fake.rpc", "buy", "--slippage", "999", _FAKE_MINT, "0.01"] + ) + assert result.exit_code != 0 + assert "Slippage must be between 0 and 100" in result.output + + +def test_sell_slippage_negative(): + """Sell with negative slippage exits with error.""" + result = runner.invoke( + app, ["--rpc", "https://fake.rpc", "sell", "--slippage", "-5", _FAKE_MINT, "all"] + ) + assert result.exit_code != 0 + assert "Slippage must be between 0 and 100" in result.output + + +def test_sell_slippage_above_100(): + """Sell with slippage above 100 exits with error.""" + result = runner.invoke( + app, ["--rpc", "https://fake.rpc", "sell", "--slippage", "999", _FAKE_MINT, "all"] + ) + assert result.exit_code != 0 + assert "Slippage must be between 0 and 100" in result.output + + +def test_buy_slippage_zero(tmp_path, monkeypatch): + """Buy with slippage=0 is valid (boundary).""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("PUMPFUN_PASSWORD", "testpass") + + from solders.keypair import Keypair + + from pumpfun_cli.crypto import encrypt_keypair + + config_dir = tmp_path / "pumpfun-cli" + config_dir.mkdir() + encrypt_keypair(Keypair(), "testpass", config_dir / "wallet.enc") + + with patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy: + mock_buy.return_value = { + "action": "buy", + "mint": _FAKE_MINT, + "sol_spent": 0.01, + "tokens_received": 100.0, + "signature": "sig", + "explorer": "https://solscan.io/tx/sig", + } + + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", "buy", "--slippage", "0", _FAKE_MINT, "0.01"], + ) + + assert result.exit_code == 0 + assert "Slippage must be between" not in result.output + + +def test_buy_slippage_100(tmp_path, monkeypatch): + """Buy with slippage=100 is valid (boundary).""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("PUMPFUN_PASSWORD", "testpass") + + from solders.keypair import Keypair + + from pumpfun_cli.crypto import encrypt_keypair + + config_dir = tmp_path / "pumpfun-cli" + config_dir.mkdir() + encrypt_keypair(Keypair(), "testpass", config_dir / "wallet.enc") + + with patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy: + mock_buy.return_value = { + "action": "buy", + "mint": _FAKE_MINT, + "sol_spent": 0.01, + "tokens_received": 100.0, + "signature": "sig", + "explorer": "https://solscan.io/tx/sig", + } + + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", "buy", "--slippage", "100", _FAKE_MINT, "0.01"], + ) + + assert result.exit_code == 0 + assert "Slippage must be between" not in result.output + + +def test_sell_slippage_zero(tmp_path, monkeypatch): + """Sell with slippage=0 is valid (boundary).""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("PUMPFUN_PASSWORD", "testpass") + + from solders.keypair import Keypair + + from pumpfun_cli.crypto import encrypt_keypair + + config_dir = tmp_path / "pumpfun-cli" + config_dir.mkdir() + encrypt_keypair(Keypair(), "testpass", config_dir / "wallet.enc") + + with patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as mock_sell: + mock_sell.return_value = { + "action": "sell", + "mint": _FAKE_MINT, + "sol_received": 0.01, + "tokens_sold": 100.0, + "signature": "sig", + "explorer": "https://solscan.io/tx/sig", + } + + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", "sell", "--slippage", "0", _FAKE_MINT, "all"], + ) + + assert result.exit_code == 0 + assert "Slippage must be between" not in result.output + + +def test_sell_slippage_100(tmp_path, monkeypatch): + """Sell with slippage=100 is valid (boundary).""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setenv("PUMPFUN_PASSWORD", "testpass") + + from solders.keypair import Keypair + + from pumpfun_cli.crypto import encrypt_keypair + + config_dir = tmp_path / "pumpfun-cli" + config_dir.mkdir() + encrypt_keypair(Keypair(), "testpass", config_dir / "wallet.enc") + + with patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as mock_sell: + mock_sell.return_value = { + "action": "sell", + "mint": _FAKE_MINT, + "sol_received": 0.01, + "tokens_sold": 100.0, + "signature": "sig", + "explorer": "https://solscan.io/tx/sig", + } + + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", "sell", "--slippage", "100", _FAKE_MINT, "all"], + ) + + assert result.exit_code == 0 + assert "Slippage must be between" not in result.output + + def test_buy_json_output_has_expected_keys(tmp_path, monkeypatch): """Verify JSON buy output has all expected keys.""" monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))