Skip to content

Commit bef50b1

Browse files
smypmsaclaude
andauthored
fix: catch slippage and on-chain tx errors in trade functions (#15)
* fix: catch slippage and on-chain errors from send_tx in trade functions Implements B-06 (Slippage rejection is dead code). - Add TransactionFailedError in protocol/client.py that parses on-chain error codes from Solana transaction failures - Add slippage error code constants for both pump.fun bonding curve (6002, 6003, 6042) and PumpSwap AMM (6004, 6040) - Wrap send_tx calls in buy_token, sell_token, buy_pumpswap, sell_pumpswap with structured error handling returning {"error": "slippage"} or {"error": "tx_error"} - The existing dead code in commands/trade.py (exit code 3 for slippage) is now live - Add 15 new unit tests covering all error paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: stop gitignoring idl/ so protocol definitions stay versioned Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b74b635 commit bef50b1

File tree

9 files changed

+590
-37
lines changed

9 files changed

+590
-37
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ build/
1111
docs/*
1212
.claude/skills/evals/
1313
.claude/skills/review-pr/
14+
.claude/worktrees/
1415

1516
*.local.*

src/pumpfun_cli/core/pumpswap.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey
88
from pumpfun_cli.crypto import decrypt_keypair
99
from pumpfun_cli.protocol.address import derive_amm_user_volume_accumulator
10-
from pumpfun_cli.protocol.client import RpcClient
10+
from pumpfun_cli.protocol.client import RpcClient, TransactionFailedError
1111
from pumpfun_cli.protocol.contracts import (
1212
ATA_RENT_LAMPORTS,
1313
LAMPORTS_PER_SOL,
@@ -16,6 +16,7 @@
1616
PUMPSWAP_BUY_COMPUTE_UNITS,
1717
PUMPSWAP_PRIORITY_FEE,
1818
PUMPSWAP_SELL_COMPUTE_UNITS,
19+
PUMPSWAP_SLIPPAGE_ERROR_CODES,
1920
SOL_RENT_EXEMPT_MIN,
2021
TOKEN_DECIMALS,
2122
)
@@ -43,6 +44,21 @@ def _estimate_buy_required_lamports(
4344
return sol_lamports + fee_lamports + ATA_RENT_LAMPORTS + SOL_RENT_EXEMPT_MIN
4445

4546

47+
def _handle_tx_error(exc: TransactionFailedError, slippage_codes: set[int]) -> dict:
48+
"""Convert a TransactionFailedError into a structured error dict."""
49+
if exc.error_code in slippage_codes:
50+
return {
51+
"error": "slippage",
52+
"message": "Transaction failed: slippage tolerance exceeded.",
53+
"error_code": exc.error_code,
54+
}
55+
return {
56+
"error": "tx_error",
57+
"message": f"Transaction failed on-chain: {exc.raw_error}",
58+
"error_code": exc.error_code,
59+
}
60+
61+
4662
async def buy_pumpswap(
4763
rpc_url: str,
4864
keystore_path: str,
@@ -158,15 +174,18 @@ async def buy_pumpswap(
158174
if not vol_resp.value:
159175
ixs.insert(0, build_init_amm_user_volume_accumulator(keypair.pubkey()))
160176

161-
sig = await client.send_tx(
162-
ixs,
163-
[keypair],
164-
compute_units=compute_units
165-
if compute_units is not None
166-
else PUMPSWAP_BUY_COMPUTE_UNITS,
167-
priority_fee=priority_fee if priority_fee is not None else PUMPSWAP_PRIORITY_FEE,
168-
confirm=confirm,
169-
)
177+
try:
178+
sig = await client.send_tx(
179+
ixs,
180+
[keypair],
181+
compute_units=compute_units
182+
if compute_units is not None
183+
else PUMPSWAP_BUY_COMPUTE_UNITS,
184+
priority_fee=priority_fee if priority_fee is not None else PUMPSWAP_PRIORITY_FEE,
185+
confirm=confirm,
186+
)
187+
except TransactionFailedError as exc:
188+
return _handle_tx_error(exc, PUMPSWAP_SLIPPAGE_ERROR_CODES)
170189
result = {
171190
"action": "buy",
172191
"venue": "pumpswap",
@@ -279,15 +298,18 @@ async def sell_pumpswap(
279298
min_sol_out=min_sol_lamports,
280299
)
281300

282-
sig = await client.send_tx(
283-
ixs,
284-
[keypair],
285-
compute_units=compute_units
286-
if compute_units is not None
287-
else PUMPSWAP_SELL_COMPUTE_UNITS,
288-
priority_fee=priority_fee if priority_fee is not None else PUMPSWAP_PRIORITY_FEE,
289-
confirm=confirm,
290-
)
301+
try:
302+
sig = await client.send_tx(
303+
ixs,
304+
[keypair],
305+
compute_units=compute_units
306+
if compute_units is not None
307+
else PUMPSWAP_SELL_COMPUTE_UNITS,
308+
priority_fee=priority_fee if priority_fee is not None else PUMPSWAP_PRIORITY_FEE,
309+
confirm=confirm,
310+
)
311+
except TransactionFailedError as exc:
312+
return _handle_tx_error(exc, PUMPSWAP_SLIPPAGE_ERROR_CODES)
291313
result = {
292314
"action": "sell",
293315
"venue": "pumpswap",

src/pumpfun_cli/core/trade.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
derive_associated_bonding_curve,
1111
derive_bonding_curve,
1212
)
13-
from pumpfun_cli.protocol.client import RpcClient
13+
from pumpfun_cli.protocol.client import RpcClient, TransactionFailedError
1414
from pumpfun_cli.protocol.contracts import (
1515
ATA_RENT_LAMPORTS,
1616
LAMPORTS_PER_SOL,
17+
PUMP_SLIPPAGE_ERROR_CODES,
1718
SOL_RENT_EXEMPT_MIN,
1819
TOKEN_DECIMALS,
1920
)
@@ -54,6 +55,21 @@ def _estimate_buy_required_lamports(
5455
return sol_lamports + fee_lamports + ATA_RENT_LAMPORTS + SOL_RENT_EXEMPT_MIN
5556

5657

58+
def _handle_tx_error(exc: TransactionFailedError, slippage_codes: set[int]) -> dict:
59+
"""Convert a TransactionFailedError into a structured error dict."""
60+
if exc.error_code in slippage_codes:
61+
return {
62+
"error": "slippage",
63+
"message": "Transaction failed: slippage tolerance exceeded.",
64+
"error_code": exc.error_code,
65+
}
66+
return {
67+
"error": "tx_error",
68+
"message": f"Transaction failed on-chain: {exc.raw_error}",
69+
"error_code": exc.error_code,
70+
}
71+
72+
5773
async def buy_token(
5874
rpc_url: str,
5975
keystore_path: str,
@@ -153,13 +169,16 @@ async def buy_token(
153169
token_program=token_program,
154170
)
155171

156-
sig = await client.send_tx(
157-
ixs,
158-
[keypair],
159-
confirm=confirm,
160-
priority_fee=priority_fee if priority_fee is not None else 200_000,
161-
compute_units=compute_units if compute_units is not None else 100_000,
162-
)
172+
try:
173+
sig = await client.send_tx(
174+
ixs,
175+
[keypair],
176+
confirm=confirm,
177+
priority_fee=priority_fee if priority_fee is not None else 200_000,
178+
compute_units=compute_units if compute_units is not None else 100_000,
179+
)
180+
except TransactionFailedError as exc:
181+
return _handle_tx_error(exc, PUMP_SLIPPAGE_ERROR_CODES)
163182
result = {
164183
"action": "buy",
165184
"mint": mint_str,
@@ -274,13 +293,16 @@ async def sell_token(
274293
token_program=token_program,
275294
)
276295

277-
sig = await client.send_tx(
278-
ixs,
279-
[keypair],
280-
confirm=confirm,
281-
priority_fee=priority_fee if priority_fee is not None else 200_000,
282-
compute_units=compute_units if compute_units is not None else 100_000,
283-
)
296+
try:
297+
sig = await client.send_tx(
298+
ixs,
299+
[keypair],
300+
confirm=confirm,
301+
priority_fee=priority_fee if priority_fee is not None else 200_000,
302+
compute_units=compute_units if compute_units is not None else 100_000,
303+
)
304+
except TransactionFailedError as exc:
305+
return _handle_tx_error(exc, PUMP_SLIPPAGE_ERROR_CODES)
284306
result = {
285307
"action": "sell",
286308
"mint": mint_str,

src/pumpfun_cli/protocol/client.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@
1212

1313
DEFAULT_RPC_TIMEOUT = 30.0
1414

15+
_ERR_PATTERN = r"InstructionError\(\((\d+),.*InstructionErrorCustom\((\d+)\)"
16+
17+
18+
class TransactionFailedError(RuntimeError):
19+
"""Raised when a confirmed transaction fails on-chain."""
20+
21+
error_code: int | None
22+
instruction_index: int | None
23+
raw_error: str
24+
25+
def __init__(self, err_obj: object) -> None:
26+
import re
27+
28+
self.raw_error = str(err_obj)
29+
match = re.search(_ERR_PATTERN, self.raw_error)
30+
if match:
31+
self.instruction_index = int(match.group(1))
32+
self.error_code = int(match.group(2))
33+
else:
34+
self.instruction_index = None
35+
self.error_code = None
36+
super().__init__(self.raw_error)
37+
1538

1639
class RpcClient:
1740
"""Simplified Solana RPC client — no background tasks, no lifecycle."""
@@ -104,9 +127,7 @@ async def send_tx(
104127
resp.value, max_supported_transaction_version=0
105128
)
106129
if tx_resp.value and tx_resp.value.transaction.meta.err:
107-
raise RuntimeError(
108-
f"Transaction confirmed but failed: {tx_resp.value.transaction.meta.err}"
109-
)
130+
raise TransactionFailedError(tx_resp.value.transaction.meta.err)
110131
return str(resp.value)
111132

112133
async def get_transaction(self, signature_str: str) -> dict | None:

src/pumpfun_cli/protocol/contracts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
ATA_RENT_LAMPORTS = 2_039_280
6161
BASE_TX_FEE = 5_000
6262

63+
# On-chain slippage error codes
64+
PUMP_SLIPPAGE_ERROR_CODES: set[int] = {6002, 6003, 6042}
65+
PUMPSWAP_SLIPPAGE_ERROR_CODES: set[int] = {6004, 6040}
66+
6367
# PumpSwap compute budgets
6468
PUMPSWAP_BUY_COMPUTE_UNITS = 400_000
6569
PUMPSWAP_SELL_COMPUTE_UNITS = 300_000

tests/test_commands/test_trade_cmd.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,95 @@ def test_sell_slippage_100(tmp_path, monkeypatch):
615615
assert "Slippage must be between" not in result.output
616616

617617

618+
# --- slippage / tx_error exit code tests ---
619+
620+
621+
def test_buy_slippage_error_exit_code_3(tmp_path, monkeypatch):
622+
"""Core returning slippage error results in exit code 3."""
623+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
624+
monkeypatch.setenv("PUMPFUN_PASSWORD", "testpass")
625+
626+
from solders.keypair import Keypair
627+
628+
from pumpfun_cli.crypto import encrypt_keypair
629+
630+
config_dir = tmp_path / "pumpfun-cli"
631+
config_dir.mkdir()
632+
encrypt_keypair(Keypair(), "testpass", config_dir / "wallet.enc")
633+
634+
with patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy:
635+
mock_buy.return_value = {
636+
"error": "slippage",
637+
"message": "Transaction failed: slippage tolerance exceeded.",
638+
"error_code": 6002,
639+
}
640+
641+
result = runner.invoke(
642+
app,
643+
["--rpc", "http://rpc", "buy", _FAKE_MINT, "0.01"],
644+
)
645+
646+
assert result.exit_code == 3
647+
assert "slippage" in result.output.lower()
648+
649+
650+
def test_sell_slippage_error_exit_code_3(tmp_path, monkeypatch):
651+
"""Core returning slippage error on sell results in exit code 3."""
652+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
653+
monkeypatch.setenv("PUMPFUN_PASSWORD", "testpass")
654+
655+
from solders.keypair import Keypair
656+
657+
from pumpfun_cli.crypto import encrypt_keypair
658+
659+
config_dir = tmp_path / "pumpfun-cli"
660+
config_dir.mkdir()
661+
encrypt_keypair(Keypair(), "testpass", config_dir / "wallet.enc")
662+
663+
with patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as mock_sell:
664+
mock_sell.return_value = {
665+
"error": "slippage",
666+
"message": "Transaction failed: slippage tolerance exceeded.",
667+
"error_code": 6003,
668+
}
669+
670+
result = runner.invoke(
671+
app,
672+
["--rpc", "http://rpc", "sell", _FAKE_MINT, "all"],
673+
)
674+
675+
assert result.exit_code == 3
676+
assert "slippage" in result.output.lower()
677+
678+
679+
def test_buy_tx_error_exit_code_1(tmp_path, monkeypatch):
680+
"""Core returning tx_error results in exit code 1."""
681+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
682+
monkeypatch.setenv("PUMPFUN_PASSWORD", "testpass")
683+
684+
from solders.keypair import Keypair
685+
686+
from pumpfun_cli.crypto import encrypt_keypair
687+
688+
config_dir = tmp_path / "pumpfun-cli"
689+
config_dir.mkdir()
690+
encrypt_keypair(Keypair(), "testpass", config_dir / "wallet.enc")
691+
692+
with patch("pumpfun_cli.commands.trade.buy_token", new_callable=AsyncMock) as mock_buy:
693+
mock_buy.return_value = {
694+
"error": "tx_error",
695+
"message": "Transaction failed on-chain: error 6020",
696+
"error_code": 6020,
697+
}
698+
699+
result = runner.invoke(
700+
app,
701+
["--rpc", "http://rpc", "buy", _FAKE_MINT, "0.01"],
702+
)
703+
704+
assert result.exit_code == 1
705+
706+
618707
def test_buy_json_output_has_expected_keys(tmp_path, monkeypatch):
619708
"""Verify JSON buy output has all expected keys."""
620709
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))

0 commit comments

Comments
 (0)