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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ build/
docs/*
.claude/skills/evals/
.claude/skills/review-pr/
.claude/worktrees/

*.local.*
60 changes: 41 additions & 19 deletions src/pumpfun_cli/core/pumpswap.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey
from pumpfun_cli.crypto import decrypt_keypair
from pumpfun_cli.protocol.address import derive_amm_user_volume_accumulator
from pumpfun_cli.protocol.client import RpcClient
from pumpfun_cli.protocol.client import RpcClient, TransactionFailedError
from pumpfun_cli.protocol.contracts import (
ATA_RENT_LAMPORTS,
LAMPORTS_PER_SOL,
Expand All @@ -16,6 +16,7 @@
PUMPSWAP_BUY_COMPUTE_UNITS,
PUMPSWAP_PRIORITY_FEE,
PUMPSWAP_SELL_COMPUTE_UNITS,
PUMPSWAP_SLIPPAGE_ERROR_CODES,
SOL_RENT_EXEMPT_MIN,
TOKEN_DECIMALS,
)
Expand Down Expand Up @@ -43,6 +44,21 @@ def _estimate_buy_required_lamports(
return sol_lamports + fee_lamports + ATA_RENT_LAMPORTS + SOL_RENT_EXEMPT_MIN


def _handle_tx_error(exc: TransactionFailedError, slippage_codes: set[int]) -> dict:
"""Convert a TransactionFailedError into a structured error dict."""
if exc.error_code in slippage_codes:
return {
"error": "slippage",
"message": "Transaction failed: slippage tolerance exceeded.",
"error_code": exc.error_code,
}
return {
"error": "tx_error",
"message": f"Transaction failed on-chain: {exc.raw_error}",
"error_code": exc.error_code,
}


async def buy_pumpswap(
rpc_url: str,
keystore_path: str,
Expand Down Expand Up @@ -158,15 +174,18 @@ async def buy_pumpswap(
if not vol_resp.value:
ixs.insert(0, build_init_amm_user_volume_accumulator(keypair.pubkey()))

sig = await client.send_tx(
ixs,
[keypair],
compute_units=compute_units
if compute_units is not None
else PUMPSWAP_BUY_COMPUTE_UNITS,
priority_fee=priority_fee if priority_fee is not None else PUMPSWAP_PRIORITY_FEE,
confirm=confirm,
)
try:
sig = await client.send_tx(
ixs,
[keypair],
compute_units=compute_units
if compute_units is not None
else PUMPSWAP_BUY_COMPUTE_UNITS,
priority_fee=priority_fee if priority_fee is not None else PUMPSWAP_PRIORITY_FEE,
confirm=confirm,
)
except TransactionFailedError as exc:
return _handle_tx_error(exc, PUMPSWAP_SLIPPAGE_ERROR_CODES)
result = {
"action": "buy",
"venue": "pumpswap",
Expand Down Expand Up @@ -279,15 +298,18 @@ async def sell_pumpswap(
min_sol_out=min_sol_lamports,
)

sig = await client.send_tx(
ixs,
[keypair],
compute_units=compute_units
if compute_units is not None
else PUMPSWAP_SELL_COMPUTE_UNITS,
priority_fee=priority_fee if priority_fee is not None else PUMPSWAP_PRIORITY_FEE,
confirm=confirm,
)
try:
sig = await client.send_tx(
ixs,
[keypair],
compute_units=compute_units
if compute_units is not None
else PUMPSWAP_SELL_COMPUTE_UNITS,
priority_fee=priority_fee if priority_fee is not None else PUMPSWAP_PRIORITY_FEE,
confirm=confirm,
)
except TransactionFailedError as exc:
return _handle_tx_error(exc, PUMPSWAP_SLIPPAGE_ERROR_CODES)
result = {
"action": "sell",
"venue": "pumpswap",
Expand Down
52 changes: 37 additions & 15 deletions src/pumpfun_cli/core/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
derive_associated_bonding_curve,
derive_bonding_curve,
)
from pumpfun_cli.protocol.client import RpcClient
from pumpfun_cli.protocol.client import RpcClient, TransactionFailedError
from pumpfun_cli.protocol.contracts import (
ATA_RENT_LAMPORTS,
LAMPORTS_PER_SOL,
PUMP_SLIPPAGE_ERROR_CODES,
SOL_RENT_EXEMPT_MIN,
TOKEN_DECIMALS,
)
Expand Down Expand Up @@ -54,6 +55,21 @@ def _estimate_buy_required_lamports(
return sol_lamports + fee_lamports + ATA_RENT_LAMPORTS + SOL_RENT_EXEMPT_MIN


def _handle_tx_error(exc: TransactionFailedError, slippage_codes: set[int]) -> dict:
"""Convert a TransactionFailedError into a structured error dict."""
if exc.error_code in slippage_codes:
return {
"error": "slippage",
"message": "Transaction failed: slippage tolerance exceeded.",
"error_code": exc.error_code,
}
return {
"error": "tx_error",
"message": f"Transaction failed on-chain: {exc.raw_error}",
"error_code": exc.error_code,
}


async def buy_token(
rpc_url: str,
keystore_path: str,
Expand Down Expand Up @@ -153,13 +169,16 @@ async def buy_token(
token_program=token_program,
)

sig = await client.send_tx(
ixs,
[keypair],
confirm=confirm,
priority_fee=priority_fee if priority_fee is not None else 200_000,
compute_units=compute_units if compute_units is not None else 100_000,
)
try:
sig = await client.send_tx(
ixs,
[keypair],
confirm=confirm,
priority_fee=priority_fee if priority_fee is not None else 200_000,
compute_units=compute_units if compute_units is not None else 100_000,
)
except TransactionFailedError as exc:
return _handle_tx_error(exc, PUMP_SLIPPAGE_ERROR_CODES)
result = {
"action": "buy",
"mint": mint_str,
Expand Down Expand Up @@ -274,13 +293,16 @@ async def sell_token(
token_program=token_program,
)

sig = await client.send_tx(
ixs,
[keypair],
confirm=confirm,
priority_fee=priority_fee if priority_fee is not None else 200_000,
compute_units=compute_units if compute_units is not None else 100_000,
)
try:
sig = await client.send_tx(
ixs,
[keypair],
confirm=confirm,
priority_fee=priority_fee if priority_fee is not None else 200_000,
compute_units=compute_units if compute_units is not None else 100_000,
)
except TransactionFailedError as exc:
return _handle_tx_error(exc, PUMP_SLIPPAGE_ERROR_CODES)
result = {
"action": "sell",
"mint": mint_str,
Expand Down
27 changes: 24 additions & 3 deletions src/pumpfun_cli/protocol/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@

DEFAULT_RPC_TIMEOUT = 30.0

_ERR_PATTERN = r"InstructionError\(\((\d+),.*InstructionErrorCustom\((\d+)\)"


class TransactionFailedError(RuntimeError):
"""Raised when a confirmed transaction fails on-chain."""

error_code: int | None
instruction_index: int | None
raw_error: str

def __init__(self, err_obj: object) -> None:
import re

self.raw_error = str(err_obj)
match = re.search(_ERR_PATTERN, self.raw_error)
if match:
self.instruction_index = int(match.group(1))
self.error_code = int(match.group(2))
else:
self.instruction_index = None
self.error_code = None
super().__init__(self.raw_error)


class RpcClient:
"""Simplified Solana RPC client — no background tasks, no lifecycle."""
Expand Down Expand Up @@ -104,9 +127,7 @@ async def send_tx(
resp.value, max_supported_transaction_version=0
)
if tx_resp.value and tx_resp.value.transaction.meta.err:
raise RuntimeError(
f"Transaction confirmed but failed: {tx_resp.value.transaction.meta.err}"
)
raise TransactionFailedError(tx_resp.value.transaction.meta.err)
return str(resp.value)

async def get_transaction(self, signature_str: str) -> dict | None:
Expand Down
4 changes: 4 additions & 0 deletions src/pumpfun_cli/protocol/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
ATA_RENT_LAMPORTS = 2_039_280
BASE_TX_FEE = 5_000

# On-chain slippage error codes
PUMP_SLIPPAGE_ERROR_CODES: set[int] = {6002, 6003, 6042}
PUMPSWAP_SLIPPAGE_ERROR_CODES: set[int] = {6004, 6040}

# PumpSwap compute budgets
PUMPSWAP_BUY_COMPUTE_UNITS = 400_000
PUMPSWAP_SELL_COMPUTE_UNITS = 300_000
Expand Down
89 changes: 89 additions & 0 deletions tests/test_commands/test_trade_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,95 @@ def test_sell_slippage_100(tmp_path, monkeypatch):
assert "Slippage must be between" not in result.output


# --- slippage / tx_error exit code tests ---


def test_buy_slippage_error_exit_code_3(tmp_path, monkeypatch):
"""Core returning slippage error results in exit code 3."""
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 = {
"error": "slippage",
"message": "Transaction failed: slippage tolerance exceeded.",
"error_code": 6002,
}

result = runner.invoke(
app,
["--rpc", "http://rpc", "buy", _FAKE_MINT, "0.01"],
)

assert result.exit_code == 3
assert "slippage" in result.output.lower()


def test_sell_slippage_error_exit_code_3(tmp_path, monkeypatch):
"""Core returning slippage error on sell results in exit code 3."""
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 = {
"error": "slippage",
"message": "Transaction failed: slippage tolerance exceeded.",
"error_code": 6003,
}

result = runner.invoke(
app,
["--rpc", "http://rpc", "sell", _FAKE_MINT, "all"],
)

assert result.exit_code == 3
assert "slippage" in result.output.lower()


def test_buy_tx_error_exit_code_1(tmp_path, monkeypatch):
"""Core returning tx_error results in exit code 1."""
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 = {
"error": "tx_error",
"message": "Transaction failed on-chain: error 6020",
"error_code": 6020,
}

result = runner.invoke(
app,
["--rpc", "http://rpc", "buy", _FAKE_MINT, "0.01"],
)

assert result.exit_code == 1


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))
Expand Down
Loading
Loading