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
13 changes: 12 additions & 1 deletion src/pumpfun_cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions src/pumpfun_cli/commands/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>")
if slippage < 0 or slippage > 100:
Expand Down
12 changes: 12 additions & 0 deletions src/pumpfun_cli/commands/wallet.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Wallet management commands."""

import asyncio
import math
import os
import sys
from pathlib import Path
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions src/pumpfun_cli/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/pumpfun_cli/core/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions tests/test_commands/test_config_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == ""
29 changes: 29 additions & 0 deletions tests/test_commands/test_human_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
23 changes: 23 additions & 0 deletions tests/test_commands/test_wallet_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
from unittest.mock import patch

import pytest
from typer.testing import CliRunner

from pumpfun_cli.cli import app
Expand Down Expand Up @@ -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()
22 changes: 22 additions & 0 deletions tests/test_core/test_wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading