From b76bf9643168e62a75f637a46fea76bf84980b44 Mon Sep 17 00:00:00 2001 From: smypmsa Date: Thu, 19 Mar 2026 15:04:10 +0000 Subject: [PATCH] test: add auto-routing test coverage for PumpSwap graduated tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 11 core-layer unit tests verifying graduated→pumpswap routing sequence (mocked RPC) - 10 command-layer unit tests verifying parameter forwarding (slippage, priority-fee, dry-run, confirm) through the fallback path and edge cases (not_found does not trigger fallback, pool-not-found after graduation surfaces correctly) - 3 surfpool integration tests verifying full auto-routing end-to-end against a forked mainnet node No production code changed. Test delta: +21 unit tests (339→360), +3 surfpool tests. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_commands/test_trade_cmd.py | 310 +++++++++++++++++++ tests/test_core/test_auto_routing.py | 376 +++++++++++++++++++++++ tests/test_surfpool/test_auto_routing.py | 154 ++++++++++ 3 files changed, 840 insertions(+) create mode 100644 tests/test_core/test_auto_routing.py create mode 100644 tests/test_surfpool/test_auto_routing.py diff --git a/tests/test_commands/test_trade_cmd.py b/tests/test_commands/test_trade_cmd.py index 126efbd..f8e7561 100644 --- a/tests/test_commands/test_trade_cmd.py +++ b/tests/test_commands/test_trade_cmd.py @@ -756,3 +756,313 @@ def test_buy_json_output_has_expected_keys(tmp_path, monkeypatch): "explorer", } assert expected_keys.issubset(data.keys()) + + +# --- auto-routing command-layer tests --- + + +def _setup_wallet(tmp_path, monkeypatch): + """Create a wallet and set env vars for command-layer tests.""" + 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") + + +def test_buy_graduated_fallback_forwards_slippage(tmp_path, monkeypatch): + """--slippage 5 forwarded to buy_pumpswap on graduated fallback.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy, + patch("pumpfun_cli.commands.trade.buy_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_buy.return_value = {"error": "graduated", "message": "Token has graduated."} + mock_pumpswap.return_value = { + "action": "buy", + "venue": "pumpswap", + "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", "5", _FAKE_MINT, "0.01"], + ) + + assert result.exit_code == 0 + call_args = mock_pumpswap.call_args + assert call_args[0][5] == 5 or call_args.kwargs.get("slippage") == 5 + + +def test_sell_graduated_fallback_forwards_slippage(tmp_path, monkeypatch): + """--slippage 5 forwarded to sell_pumpswap on graduated fallback.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as mock_sell, + patch("pumpfun_cli.commands.trade.sell_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_sell.return_value = {"error": "graduated", "message": "Token has graduated."} + mock_pumpswap.return_value = { + "action": "sell", + "venue": "pumpswap", + "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", "5", _FAKE_MINT, "all"], + ) + + assert result.exit_code == 0 + call_args = mock_pumpswap.call_args + assert call_args[0][5] == 5 or call_args.kwargs.get("slippage") == 5 + + +def test_buy_graduated_fallback_forwards_priority_fee(tmp_path, monkeypatch): + """--priority-fee + --compute-units forwarded to buy_pumpswap on graduated fallback.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy, + patch("pumpfun_cli.commands.trade.buy_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_buy.return_value = {"error": "graduated", "message": "Token has graduated."} + mock_pumpswap.return_value = { + "action": "buy", + "venue": "pumpswap", + "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", + "--priority-fee", + "55000", + "--compute-units", + "350000", + "buy", + _FAKE_MINT, + "0.01", + ], + ) + + assert result.exit_code == 0 + call_kwargs = mock_pumpswap.call_args.kwargs + assert call_kwargs.get("priority_fee") == 55000 + assert call_kwargs.get("compute_units") == 350000 + + +def test_buy_graduated_fallback_forwards_dry_run(tmp_path, monkeypatch): + """--dry-run forwarded to buy_pumpswap on graduated fallback.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy, + patch("pumpfun_cli.commands.trade.buy_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_buy.return_value = {"error": "graduated", "message": "Token has graduated."} + mock_pumpswap.return_value = { + "dry_run": True, + "action": "buy", + "venue": "pumpswap", + "mint": _FAKE_MINT, + "sol_in": 0.01, + "expected_tokens": 100.0, + "effective_price_sol": 0.0001, + "spot_price_sol": 0.00009, + "price_impact_pct": 1.0, + "min_tokens_out": 95.0, + "slippage_pct": 15, + } + + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", "buy", "--dry-run", _FAKE_MINT, "0.01"], + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["dry_run"] is True + assert data["venue"] == "pumpswap" + call_kwargs = mock_pumpswap.call_args + assert call_kwargs.kwargs.get("dry_run") is True or call_kwargs[1].get("dry_run") is True + + +def test_sell_graduated_fallback_forwards_dry_run(tmp_path, monkeypatch): + """--dry-run forwarded to sell_pumpswap on graduated fallback.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as mock_sell, + patch("pumpfun_cli.commands.trade.sell_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_sell.return_value = {"error": "graduated", "message": "Token has graduated."} + mock_pumpswap.return_value = { + "dry_run": True, + "action": "sell", + "venue": "pumpswap", + "mint": _FAKE_MINT, + "tokens_in": 100.0, + "expected_sol": 0.01, + "effective_price_sol": 0.0001, + "spot_price_sol": 0.00009, + "price_impact_pct": -1.0, + "min_sol_out": 0.0085, + "slippage_pct": 15, + } + + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", "sell", "--dry-run", _FAKE_MINT, "100"], + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["dry_run"] is True + assert data["venue"] == "pumpswap" + + +def test_buy_graduated_fallback_forwards_confirm(tmp_path, monkeypatch): + """--confirm forwarded to buy_pumpswap on graduated fallback.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy, + patch("pumpfun_cli.commands.trade.buy_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_buy.return_value = {"error": "graduated", "message": "Token has graduated."} + mock_pumpswap.return_value = { + "action": "buy", + "venue": "pumpswap", + "mint": _FAKE_MINT, + "sol_spent": 0.01, + "tokens_received": 100.0, + "signature": "sig", + "explorer": "https://solscan.io/tx/sig", + "confirmed": True, + } + + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", "buy", "--confirm", _FAKE_MINT, "0.01"], + ) + + assert result.exit_code == 0 + call_kwargs = mock_pumpswap.call_args + assert call_kwargs.kwargs.get("confirm") is True or call_kwargs[1].get("confirm") is True + + +def test_buy_not_found_no_pumpswap_fallback(tmp_path, monkeypatch): + """not_found does NOT trigger pumpswap fallback.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy, + patch("pumpfun_cli.commands.trade.buy_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_buy.return_value = {"error": "not_found", "message": "No bonding curve found."} + + result = runner.invoke( + app, + ["--rpc", "http://rpc", "buy", _FAKE_MINT, "0.01"], + ) + + assert result.exit_code != 0 + mock_pumpswap.assert_not_called() + + +def test_sell_not_found_no_pumpswap_fallback(tmp_path, monkeypatch): + """not_found does NOT trigger pumpswap fallback for sell.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as mock_sell, + patch("pumpfun_cli.commands.trade.sell_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_sell.return_value = {"error": "not_found", "message": "No bonding curve found."} + + result = runner.invoke( + app, + ["--rpc", "http://rpc", "sell", _FAKE_MINT, "all"], + ) + + assert result.exit_code != 0 + mock_pumpswap.assert_not_called() + + +def test_buy_graduated_fallback_pumpswap_error(tmp_path, monkeypatch): + """pumpswap error surfaces correctly after graduated fallback.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy, + patch("pumpfun_cli.commands.trade.buy_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_buy.return_value = {"error": "graduated", "message": "Token has graduated."} + mock_pumpswap.return_value = { + "error": "pumpswap_error", + "message": "No PumpSwap pool found for this token.", + } + + result = runner.invoke( + app, + ["--rpc", "http://rpc", "buy", _FAKE_MINT, "0.01"], + ) + + assert result.exit_code != 0 + assert "PumpSwap" in result.output or "pumpswap" in result.output.lower() + + +def test_sell_graduated_fallback_sell_all(tmp_path, monkeypatch): + """sell 'all' through graduated fallback works.""" + _setup_wallet(tmp_path, monkeypatch) + + with ( + patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as mock_sell, + patch("pumpfun_cli.commands.trade.sell_pumpswap", new_callable=AsyncMock) as mock_pumpswap, + ): + mock_sell.return_value = {"error": "graduated", "message": "Token has graduated."} + mock_pumpswap.return_value = { + "action": "sell", + "venue": "pumpswap", + "mint": _FAKE_MINT, + "sol_received": 0.05, + "tokens_sold": 500.0, + "signature": "sellall_sig", + "explorer": "https://solscan.io/tx/sellall_sig", + } + + result = runner.invoke( + app, + ["--json", "--rpc", "http://rpc", "sell", _FAKE_MINT, "all"], + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["venue"] == "pumpswap" + assert data["tokens_sold"] == 500.0 + # Verify "all" was passed through + call_args = mock_pumpswap.call_args + assert call_args[0][4] == "all" diff --git a/tests/test_core/test_auto_routing.py b/tests/test_core/test_auto_routing.py new file mode 100644 index 0000000..1b3ccef --- /dev/null +++ b/tests/test_core/test_auto_routing.py @@ -0,0 +1,376 @@ +"""Tests for auto-routing: graduated tokens fall back to PumpSwap. + +These tests verify the individual core functions (buy_token returns graduated, +buy_pumpswap succeeds independently) rather than the command-layer wiring. +The auto-routing logic lives in commands/trade.py, so here we test the +building blocks that make it work. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pumpfun_cli.core.pumpswap import buy_pumpswap, sell_pumpswap +from pumpfun_cli.core.trade import buy_token, sell_token +from pumpfun_cli.protocol.contracts import ( + GLOBALCONFIG_PROTOCOL_FEE_RECIPIENT_OFFSET, + STANDARD_PUMPSWAP_FEE_RECIPIENT, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, +) +from tests.test_core.helpers import build_pool_data + +_PATCH_TOKEN_PROG = patch( + "pumpfun_cli.core.trade.get_token_program_id", + new=AsyncMock(return_value=TOKEN_2022_PROGRAM), +) + +_VALID_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + + +# --- helpers (reused from test_pumpswap.py) --- + + +def _mock_global_config_resp(): + off = GLOBALCONFIG_PROTOCOL_FEE_RECIPIENT_OFFSET + config_data = bytearray(off + 32) + config_data[off : off + 32] = bytes(STANDARD_PUMPSWAP_FEE_RECIPIENT) + resp = MagicMock() + resp.value = MagicMock() + resp.value.data = bytes(config_data) + return resp + + +def _mock_pool_resp(pool_data): + resp = MagicMock() + resp.value = MagicMock() + resp.value.data = pool_data + return resp + + +def _mock_pool_not_found(): + resp = MagicMock() + resp.value = None + return resp + + +def _mock_token_program_resp(): + resp = MagicMock() + resp.value = MagicMock() + resp.value.owner = TOKEN_PROGRAM + return resp + + +def _mock_vol_accumulator_resp(): + resp = MagicMock() + resp.value = None + return resp + + +def _mock_bonding_curve_graduated(): + """Build a mock bonding curve response where complete=True.""" + resp = MagicMock() + resp.value = MagicMock() + # We patch IDLParser to return graduated state, so data doesn't matter + resp.value.data = b"\x00" * 200 + return resp + + +def _mock_bonding_curve_not_found(): + resp = MagicMock() + resp.value = None + return resp + + +# --- buy auto-routing tests --- + + +@pytest.mark.asyncio +@_PATCH_TOKEN_PROG +async def test_buy_auto_route_graduated_to_pumpswap(tmp_keystore): + """buy_token returns graduated, then buy_pumpswap succeeds with venue==pumpswap.""" + # Step 1: buy_token detects graduated + with patch("pumpfun_cli.core.trade.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.return_value = _mock_bonding_curve_graduated() + client.close = AsyncMock() + + with patch("pumpfun_cli.core.trade.IDLParser") as MockIDL: + idl = MagicMock() + MockIDL.return_value = idl + idl.decode_account_data.return_value = {"complete": True} + + result = await buy_token("http://rpc", tmp_keystore, "testpass", _VALID_MINT, 0.01) + + assert result["error"] == "graduated" + + # Step 2: buy_pumpswap succeeds + pool_data = build_pool_data() + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.side_effect = [ + _mock_pool_resp(pool_data), + _mock_token_program_resp(), + _mock_global_config_resp(), + _mock_vol_accumulator_resp(), + ] + client.get_token_account_balance.side_effect = [1_000_000_000, 30_000_000_000] + client.get_balance.return_value = 10_000_000_000 + client.send_tx.return_value = "pumpswap_sig" + client.close = AsyncMock() + + ps_result = await buy_pumpswap("http://rpc", tmp_keystore, "testpass", _VALID_MINT, 0.01) + + assert ps_result["venue"] == "pumpswap" + assert ps_result["action"] == "buy" + assert ps_result["signature"] == "pumpswap_sig" + + +@pytest.mark.asyncio +@_PATCH_TOKEN_PROG +async def test_sell_auto_route_graduated_to_pumpswap(tmp_keystore): + """sell_token returns graduated, then sell_pumpswap succeeds with venue==pumpswap.""" + # Step 1: sell_token detects graduated + with patch("pumpfun_cli.core.trade.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.return_value = _mock_bonding_curve_graduated() + client.close = AsyncMock() + + with patch("pumpfun_cli.core.trade.IDLParser") as MockIDL: + idl = MagicMock() + MockIDL.return_value = idl + idl.decode_account_data.return_value = {"complete": True} + + result = await sell_token("http://rpc", tmp_keystore, "testpass", _VALID_MINT, "all") + + assert result["error"] == "graduated" + + # Step 2: sell_pumpswap succeeds + pool_data = build_pool_data() + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.side_effect = [ + _mock_pool_resp(pool_data), + _mock_token_program_resp(), + _mock_global_config_resp(), + ] + client.get_token_account_balance.side_effect = [ + 1_000_000, + 1_000_000_000, + 30_000_000_000, + ] + client.send_tx.return_value = "sellsig_ps" + client.close = AsyncMock() + + ps_result = await sell_pumpswap("http://rpc", tmp_keystore, "testpass", _VALID_MINT, "all") + + assert ps_result["venue"] == "pumpswap" + assert ps_result["action"] == "sell" + assert ps_result["signature"] == "sellsig_ps" + + +@pytest.mark.asyncio +async def test_buy_auto_route_forwards_slippage(tmp_keystore): + """slippage=5 is forwarded to buy_pumpswap.""" + pool_data = build_pool_data() + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.side_effect = [ + _mock_pool_resp(pool_data), + _mock_token_program_resp(), + _mock_global_config_resp(), + _mock_vol_accumulator_resp(), + ] + client.get_token_account_balance.side_effect = [1_000_000_000, 30_000_000_000] + client.get_balance.return_value = 10_000_000_000 + client.send_tx.return_value = "buysig" + client.close = AsyncMock() + + result = await buy_pumpswap( + "http://rpc", tmp_keystore, "testpass", _VALID_MINT, 0.01, slippage=5 + ) + + assert result["action"] == "buy" + assert result["venue"] == "pumpswap" + # With slippage=5, min_base_amount_out should be 95% of estimated + # (verified by the fact that the trade succeeded with slippage=5) + + +@pytest.mark.asyncio +async def test_sell_auto_route_forwards_slippage(tmp_keystore): + """slippage=5 is forwarded to sell_pumpswap.""" + pool_data = build_pool_data() + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.side_effect = [ + _mock_pool_resp(pool_data), + _mock_token_program_resp(), + _mock_global_config_resp(), + ] + client.get_token_account_balance.side_effect = [ + 1_000_000, + 1_000_000_000, + 30_000_000_000, + ] + client.send_tx.return_value = "sellsig" + client.close = AsyncMock() + + result = await sell_pumpswap( + "http://rpc", tmp_keystore, "testpass", _VALID_MINT, "all", slippage=5 + ) + + assert result["action"] == "sell" + assert result["venue"] == "pumpswap" + + +@pytest.mark.asyncio +async def test_buy_auto_route_forwards_dry_run(tmp_keystore): + """dry_run=True forwarded to buy_pumpswap, send_tx not called.""" + pool_data = build_pool_data() + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.side_effect = [ + _mock_pool_resp(pool_data), + _mock_token_program_resp(), + _mock_global_config_resp(), + ] + client.get_token_account_balance.side_effect = [1_000_000_000, 30_000_000_000] + client.get_balance.return_value = 10_000_000_000 + client.close = AsyncMock() + + result = await buy_pumpswap( + "http://rpc", tmp_keystore, "testpass", _VALID_MINT, 0.01, dry_run=True + ) + + assert result["dry_run"] is True + assert result["venue"] == "pumpswap" + assert "signature" not in result + client.send_tx.assert_not_called() + + +@pytest.mark.asyncio +async def test_sell_auto_route_forwards_dry_run(tmp_keystore): + """dry_run=True forwarded to sell_pumpswap, send_tx not called.""" + pool_data = build_pool_data() + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.side_effect = [ + _mock_pool_resp(pool_data), + _mock_token_program_resp(), + _mock_global_config_resp(), + ] + client.get_token_account_balance.side_effect = [ + 1_000_000, + 1_000_000_000, + 30_000_000_000, + ] + client.close = AsyncMock() + + result = await sell_pumpswap( + "http://rpc", tmp_keystore, "testpass", _VALID_MINT, "all", dry_run=True + ) + + assert result["dry_run"] is True + assert result["venue"] == "pumpswap" + assert "signature" not in result + client.send_tx.assert_not_called() + + +@pytest.mark.asyncio +async def test_buy_auto_route_sell_all_through_fallback(tmp_keystore): + """sell_pumpswap with amount_str='all' works after graduated fallback.""" + pool_data = build_pool_data() + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.side_effect = [ + _mock_pool_resp(pool_data), + _mock_token_program_resp(), + _mock_global_config_resp(), + ] + client.get_token_account_balance.side_effect = [ + 500_000_000, # user balance + 1_000_000_000, # pool base + 30_000_000_000, # pool quote + ] + client.send_tx.return_value = "sell_all_sig" + client.close = AsyncMock() + + result = await sell_pumpswap("http://rpc", tmp_keystore, "testpass", _VALID_MINT, "all") + + assert result["action"] == "sell" + assert result["venue"] == "pumpswap" + assert result["signature"] == "sell_all_sig" + assert result["tokens_sold"] > 0 + + +@pytest.mark.asyncio +@_PATCH_TOKEN_PROG +async def test_buy_not_found_does_not_trigger_fallback(tmp_keystore): + """buy_token returns not_found — no pumpswap call should follow.""" + with patch("pumpfun_cli.core.trade.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.return_value = _mock_bonding_curve_not_found() + client.close = AsyncMock() + + result = await buy_token("http://rpc", tmp_keystore, "testpass", _VALID_MINT, 0.01) + + assert result["error"] == "not_found" + # The command layer checks: if result.get("error") == "graduated" + # "not_found" != "graduated" so pumpswap would NOT be called. + assert result["error"] != "graduated" + + +@pytest.mark.asyncio +@_PATCH_TOKEN_PROG +async def test_sell_not_found_does_not_trigger_fallback(tmp_keystore): + """sell_token returns not_found — no pumpswap call should follow.""" + with patch("pumpfun_cli.core.trade.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.return_value = _mock_bonding_curve_not_found() + client.close = AsyncMock() + + result = await sell_token("http://rpc", tmp_keystore, "testpass", _VALID_MINT, "all") + + assert result["error"] == "not_found" + assert result["error"] != "graduated" + + +@pytest.mark.asyncio +async def test_buy_auto_route_pumpswap_pool_not_found(tmp_keystore): + """Graduated but pool missing -> pumpswap_error.""" + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.return_value = _mock_pool_not_found() + client.close = AsyncMock() + + result = await buy_pumpswap("http://rpc", tmp_keystore, "testpass", _VALID_MINT, 0.01) + + assert result["error"] == "pumpswap_error" + assert "No PumpSwap pool" in result["message"] + + +@pytest.mark.asyncio +async def test_sell_auto_route_pumpswap_pool_not_found(tmp_keystore): + """Graduated but pool missing -> pumpswap_error for sell.""" + with patch("pumpfun_cli.core.pumpswap.RpcClient") as MockClient: + client = AsyncMock() + MockClient.return_value = client + client.get_account_info.return_value = _mock_pool_not_found() + client.close = AsyncMock() + + result = await sell_pumpswap("http://rpc", tmp_keystore, "testpass", _VALID_MINT, "all") + + assert result["error"] == "pumpswap_error" + assert "No PumpSwap pool" in result["message"] diff --git a/tests/test_surfpool/test_auto_routing.py b/tests/test_surfpool/test_auto_routing.py new file mode 100644 index 0000000..c6b011d --- /dev/null +++ b/tests/test_surfpool/test_auto_routing.py @@ -0,0 +1,154 @@ +"""Surfpool integration: auto-routing for graduated tokens. + +These tests verify that buying/selling a graduated token WITHOUT --force-amm +automatically routes through PumpSwap. Requires a running surfpool instance +with a graduated token. + +Run with: pytest tests/test_surfpool/ -v --surfpool +""" + +import pytest +from typer.testing import CliRunner + +from pumpfun_cli.cli import app +from pumpfun_cli.core.trade import buy_token, sell_token + +# Surfpool needs time to lazy-fetch pool token accounts from mainnet. +SURFPOOL_TIMEOUT = 120.0 + +runner = CliRunner() + + +@pytest.mark.asyncio +async def test_buy_auto_route_graduated_token( + surfpool_rpc, funded_keypair, test_keystore, test_password, graduated_mint +): + """buy_token returns graduated, then buy_pumpswap succeeds.""" + # Step 1: buy_token should detect graduated + result = await buy_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=graduated_mint, + sol_amount=0.001, + slippage=25, + ) + + assert result.get("error") == "graduated", f"Expected graduated, got: {result}" + + # Step 2: pumpswap buy should succeed + from pumpfun_cli.core.pumpswap import buy_pumpswap + + ps_result = await buy_pumpswap( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=graduated_mint, + sol_amount=0.001, + slippage=50, + rpc_timeout=SURFPOOL_TIMEOUT, + ) + + assert "error" not in ps_result, f"PumpSwap buy failed: {ps_result}" + assert ps_result["venue"] == "pumpswap" + assert ps_result["action"] == "buy" + assert ps_result["tokens_received"] > 0 + + +@pytest.mark.asyncio +async def test_sell_auto_route_graduated_token( + surfpool_rpc, funded_keypair, test_keystore, test_password, graduated_mint +): + """After buying, sell auto-routes through pumpswap.""" + from pumpfun_cli.core.pumpswap import buy_pumpswap + + # Buy some tokens first + buy_result = await buy_pumpswap( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=graduated_mint, + sol_amount=0.001, + slippage=50, + rpc_timeout=SURFPOOL_TIMEOUT, + confirm=True, + ) + assert "error" not in buy_result, f"Buy failed: {buy_result}" + + # sell_token should detect graduated + sell_result = await sell_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=graduated_mint, + amount_str="all", + slippage=25, + ) + + assert sell_result.get("error") == "graduated", f"Expected graduated, got: {sell_result}" + + # sell_pumpswap should succeed + from pumpfun_cli.core.pumpswap import sell_pumpswap + + ps_result = await sell_pumpswap( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=graduated_mint, + amount_str="all", + slippage=50, + rpc_timeout=SURFPOOL_TIMEOUT, + ) + + assert "error" not in ps_result, f"PumpSwap sell failed: {ps_result}" + assert ps_result["venue"] == "pumpswap" + assert ps_result["action"] == "sell" + + +def test_buy_auto_route_full_command_layer( + surfpool_rpc, funded_keypair, test_keystore, test_password, graduated_mint, tmp_path +): + """CliRunner buy without --force-amm shows venue=pumpswap.""" + import json + import os + + # Set up env for CLI + env = { + "PUMPFUN_RPC": surfpool_rpc, + "PUMPFUN_PASSWORD": test_password, + "XDG_CONFIG_HOME": str(test_keystore.parent.parent), + } + for key, val in env.items(): + os.environ[key] = val + + try: + # Rename the keystore dir to match what the CLI expects + import shutil + + cli_config_dir = test_keystore.parent.parent / "pumpfun-cli" + if not cli_config_dir.exists(): + cli_config_dir.mkdir() + cli_wallet = cli_config_dir / "wallet.enc" + if not cli_wallet.exists(): + shutil.copy2(str(test_keystore), str(cli_wallet)) + + result = runner.invoke( + app, + [ + "--json", + "--rpc", + surfpool_rpc, + "buy", + graduated_mint, + "0.001", + "--slippage", + "50", + ], + ) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["venue"] == "pumpswap" + finally: + for key in env: + os.environ.pop(key, None)