diff --git a/README.md b/README.md index 22292fd..3f8aa39 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ without `tokens_received_raw`, so check for the field. Dry-run fields (`expected_tokens_raw`, `expected_quote_raw`, `min_*_out_raw`) are exact integers of the quote the transaction would be built from, not executed amounts. +A bonding curve pays a sell out of its real reserves, which on some coins hold +less than the price promises. `sell` then sells the most the curve can pay for +and reports the rest as `tokens_kept`; when it can pay nothing it sends nothing +(`curve_short`). A buy bigger than what is left on the curve fails with +`exceeds_curve` and the tokens left in `curve_tokens_left`. + ## Commands ``` diff --git a/src/pumpfun_cli/commands/trade.py b/src/pumpfun_cli/commands/trade.py index 000297f..5ba4bdb 100644 --- a/src/pumpfun_cli/commands/trade.py +++ b/src/pumpfun_cli/commands/trade.py @@ -243,6 +243,8 @@ def sell( f" Min out: {result['min_quote_out']:.6f} {symbol}" f" ({result['slippage_pct']}% slippage)" ) + if "tokens_kept" in result: + typer.echo(f" Kept: {result['tokens_kept']:.6f} (curve can't pay)") return if result.get("error"): code = 3 if result["error"] in ("graduated", "slippage", "no_tokens") else 1 @@ -253,6 +255,11 @@ def sell( f"Sold {result['tokens_sold']:.2f} tokens" f" for {result['quote_received']:.6f} {result['quote_symbol']}" ) + if "tokens_kept" in result: + typer.echo( + f"Kept {result['tokens_kept']:.2f} tokens: the bonding curve's real" + " reserves can't pay for them now." + ) typer.echo(f"TX: {result['explorer']}") diff --git a/src/pumpfun_cli/core/trade.py b/src/pumpfun_cli/core/trade.py index a0b789a..6405a9c 100644 --- a/src/pumpfun_cli/core/trade.py +++ b/src/pumpfun_cli/core/trade.py @@ -41,6 +41,7 @@ calculate_buy_tokens_out, calculate_sell_quote_out, get_token_price, + max_sell_within_real_reserves, parse_bonding_curve, ) from pumpfun_cli.protocol.instructions import ( @@ -62,6 +63,8 @@ ) DEFAULT_PRIORITY_FEE = 200_000 +# pump.fun NotEnoughTokensToBuy: the buy asks for more tokens than the curve has left. +_NOT_ENOUGH_TOKENS_TO_BUY = 6021 def _estimate_buy_required_lamports( @@ -250,6 +253,19 @@ async def buy_token( compute_units=eff_compute_units, ) except TransactionFailedError as exc: + if exc.error_code == _NOT_ENOUGH_TOKENS_TO_BUY: + left = state["real_token_reserves"] / 10**TOKEN_DECIMALS + return { + "error": "exceeds_curve", + "message": ( + f"The buy asks for more than the {left:.6f} tokens left on the" + f" bonding curve. Spend less {symbol}." + ), + "error_code": exc.error_code, + **amount_fields( + "curve_tokens_left", state["real_token_reserves"], TOKEN_DECIMALS + ), + } return _handle_tx_error(exc, PUMP_SLIPPAGE_ERROR_CODES) received = await confirmed_token_delta(client, sig, user, mint) if confirm else None return _tx_result( @@ -324,6 +340,20 @@ async def sell_token( if token_amount <= 0: return {"error": "no_tokens", "message": "No tokens to sell."} + # The curve pays a sell out of its real reserves; sell what they cover. + sellable = max_sell_within_real_reserves(state, token_amount) + if sellable == 0: + return { + "error": "curve_short", + "message": ( + "The bonding curve's real reserves can't pay anything for these" + " tokens now. Nothing was sent." + ), + } + kept = token_amount - sellable + token_amount = sellable + partial = amount_fields("tokens_kept", kept, TOKEN_DECIMALS) if kept else {} + quote_out = calculate_sell_quote_out(state, token_amount) min_quote_out = max(1, int(quote_out * (1 - slippage / 100))) @@ -349,6 +379,7 @@ async def sell_token( "price_impact_pct": round(price_impact, 4), **amount_fields("min_quote_out", min_quote_out, quote_decimals), "slippage_pct": slippage, + **partial, } ixs = [] @@ -397,6 +428,7 @@ async def sell_token( quote_symbol=symbol, **amount_fields("tokens_sold", token_amount, TOKEN_DECIMALS), quote_received=quote_out / 10**quote_decimals, + **partial, ) finally: await client.close() diff --git a/src/pumpfun_cli/protocol/curve.py b/src/pumpfun_cli/protocol/curve.py index 4b65f8a..d2a5278 100644 --- a/src/pumpfun_cli/protocol/curve.py +++ b/src/pumpfun_cli/protocol/curve.py @@ -102,6 +102,31 @@ def calculate_sell_quote_out(state: dict, token_amount: int) -> int: ) +def max_sell_within_real_reserves(state: dict, token_amount: int) -> int: + """The largest part of `token_amount` the curve has the quote to pay for. + + The program prices a sell on the virtual reserves but pays it, fees + included, out of `real_quote_reserves`, and a payout above that reverts. + On a mayhem-mode coin the two drift apart, so the curve can hold far less + than its price promises. Returns `token_amount` when the curve can pay for + all of it, else the most raw tokens whose payout still fits, and 0 when + the payout would be nothing either way. + """ + virtual_tokens = state["virtual_token_reserves"] + virtual_quote = state["virtual_quote_reserves"] + real_quote = state["real_quote_reserves"] + payout = calculate_sell_quote_out(state, token_amount) + if payout <= real_quote: + # A sell paid nothing reverts on its floor of at least one unit. + return token_amount if payout > 0 else 0 + # The payout rounds down, so it fits while + # amount * virtual_quote < (real_quote + 1) * (virtual_tokens + amount). + # virtual_quote > real_quote + 1 here, or the full sell would have fit. + bound = (real_quote + 1) * virtual_tokens - 1 + sellable = min(token_amount, bound // (virtual_quote - real_quote - 1)) + return sellable if calculate_sell_quote_out(state, sellable) > 0 else 0 + + def get_token_price(state: dict, quote_decimals: int) -> float: """Current price in whole quote units per whole token.""" if state["virtual_token_reserves"] == 0: diff --git a/tests/test_commands/test_human_output.py b/tests/test_commands/test_human_output.py index e4fa205..3fc1ebd 100644 --- a/tests/test_commands/test_human_output.py +++ b/tests/test_commands/test_human_output.py @@ -52,6 +52,23 @@ def test_buy_and_sell_name_the_quote_asset(wallet): assert sold.exit_code == 0 and "4.500000 USDC" in sold.output +def test_a_capped_sell_says_what_it_kept(wallet): + with ( + _human("trade"), + patch("pumpfun_cli.commands.trade.sell_token", new_callable=AsyncMock) as sell, + ): + sell.return_value = { + "action": "sell", + "quote_symbol": "SOL", + "quote_received": 0.001, + "tokens_sold": 6.0, + "tokens_kept": 4.0, + "explorer": "x", + } + sold = runner.invoke(app, ["--rpc", "http://rpc", "sell", MINT, "all"]) + assert sold.exit_code == 0 and "Kept 4.00 tokens" in sold.output + + def test_dry_runs(wallet): common = { "dry_run": True, diff --git a/tests/test_core/helpers.py b/tests/test_core/helpers.py index 13371e1..973f2fb 100644 --- a/tests/test_core/helpers.py +++ b/tests/test_core/helpers.py @@ -44,7 +44,7 @@ def build_curve_data( virtual_token: int = 1_000_000_000_000_000, virtual_quote: int = 30_000_000_000, real_token: int = 793_100_000_000_000, - real_quote: int = 0, + real_quote: int = 1_000_000_000, supply: int = 1_000_000_000_000_000, complete: bool = False, creator: Pubkey = CREATOR, diff --git a/tests/test_core/test_trade.py b/tests/test_core/test_trade.py index f2bba2f..87dbe56 100644 --- a/tests/test_core/test_trade.py +++ b/tests/test_core/test_trade.py @@ -263,6 +263,69 @@ async def test_sell_dry_run(tmp_keystore): assert client.sent == [] +async def test_buy_past_the_curve_reports_tokens_left(tmp_keystore): + client = chain_with_coin() + client.send_error = TransactionFailedError({"InstructionError": [2, {"Custom": 6021}]}) + with _patched(client): + result = await buy_token( + "http://rpc", tmp_keystore, "testpass", MINT_STR, 0.01, confirm=True + ) + assert result["error"] == "exceeds_curve" + assert result["curve_tokens_left_raw"] == 793_100_000_000_000 + assert "793100000.000000 tokens left" in result["message"] + + +# A mayhem-style curve whose real reserves fall short of what its price promises. +_SHORT_CURVE = {"real_quote": 1_000_000, "mayhem": True} +_HELD = 10**14 + + +async def test_sell_caps_at_what_the_curve_can_pay(tmp_keystore): + user = _user_from(tmp_keystore) + client = chain_with_coin(curve=build_curve_data(**_SHORT_CURVE)) + client.token_balances[find_ata(user, COIN, TOKEN_2022_PROGRAM)] = _HELD + with _patched(client): + result = await sell_token("http://rpc", tmp_keystore, "testpass", MINT_STR, "all") + sold = int.from_bytes(bytes(_pump_ix(client.sent[0]).data[8:16]), "little") + assert 0 < sold < _HELD + assert result["tokens_sold_raw"] == sold + assert result["tokens_kept_raw"] == _HELD - sold + # The payout fits the real reserves, and one more token would not. + assert sold * 30_000_000_000 // (10**15 + sold) <= 1_000_000 + assert (sold + 1) * 30_000_000_000 // (10**15 + sold + 1) > 1_000_000 + + +async def test_sell_dry_run_shows_what_the_curve_cant_pay(tmp_keystore): + user = _user_from(tmp_keystore) + client = chain_with_coin(curve=build_curve_data(**_SHORT_CURVE)) + client.token_balances[find_ata(user, COIN, TOKEN_2022_PROGRAM)] = _HELD + with _patched(client): + result = await sell_token( + "http://rpc", tmp_keystore, "testpass", MINT_STR, "all", dry_run=True + ) + assert result["tokens_in_raw"] + result["tokens_kept_raw"] == _HELD + assert client.sent == [] + + +async def test_sell_within_reserves_keeps_nothing(tmp_keystore): + user = _user_from(tmp_keystore) + client = chain_with_coin() + client.token_balances[find_ata(user, COIN, TOKEN_2022_PROGRAM)] = 5_000_000 + with _patched(client): + result = await sell_token("http://rpc", tmp_keystore, "testpass", MINT_STR, "all") + assert "tokens_kept" not in result + + +async def test_sell_refused_when_the_curve_can_pay_nothing(tmp_keystore): + user = _user_from(tmp_keystore) + client = chain_with_coin(curve=build_curve_data(real_quote=0, mayhem=True)) + client.token_balances[find_ata(user, COIN, TOKEN_2022_PROGRAM)] = 5_000_000 + with _patched(client): + result = await sell_token("http://rpc", tmp_keystore, "testpass", MINT_STR, "all") + assert result["error"] == "curve_short" + assert client.sent == [] + + # --- cashback, accumulator, fees, migration --- diff --git a/tests/test_surfpool/test_trade.py b/tests/test_surfpool/test_trade.py index ddbc20f..96856a5 100644 --- a/tests/test_surfpool/test_trade.py +++ b/tests/test_surfpool/test_trade.py @@ -142,3 +142,137 @@ async def test_buy_without_confirm( assert "error" not in result, f"Buy failed: {result}" assert result["signature"] assert "confirmed" not in result + + +@pytest.mark.asyncio +async def test_sell_caps_at_the_curves_real_reserves( + surfpool_rpc, funded_keypair, test_keystore, test_password +): + """A curve holding less than its price promises takes the most it can pay for. + + The curve's real reserves are lowered with surfnet_setAccount, the way they + drift on a mayhem coin. The capped sell lands; one more token reverts. + """ + import struct + + import httpx + from solders.pubkey import Pubkey + + from pumpfun_cli.core.launch import launch_token + from pumpfun_cli.protocol.address import derive_bonding_curve + from pumpfun_cli.protocol.client import RpcClient, TransactionFailedError + from pumpfun_cli.protocol.curve import parse_bonding_curve + from pumpfun_cli.protocol.instructions import build_sell_v2_instruction + + launched = await launch_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + name="Short", + ticker="SHRT", + description="short reserves", + uri="https://example.com/short.json", + initial_buy=1, + confirm=True, + ) + assert "error" not in launched, launched + mint = Pubkey.from_string(launched["mint"]) + curve_pda = derive_bonding_curve(mint) + + client = RpcClient(surfpool_rpc) + try: + data = bytearray((await client.get_account_info(curve_pda)).value.data) + struct.pack_into(" 0, preview + sellable = preview["tokens_in_raw"] + + # One token more than the cap is more than the curve can pay. + with pytest.raises(TransactionFailedError) as over: + await client.send_tx( + [ + build_sell_v2_instruction( + mint=mint, + creator=curve["creator"], + user=funded_keypair.pubkey(), + token_amount=sellable + 1, + min_quote_out=1, + ) + ], + [funded_keypair], + compute_units=200_000, + confirm=True, + ) + assert over.value.error_code == 6024 # Overflow: the payout exceeds real reserves + finally: + await client.close() + + sold = await sell_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=str(mint), + amount_str="all", + slippage=50, + confirm=True, + ) + assert "error" not in sold, sold + assert sold["tokens_sold_raw"] == sellable + assert sold["tokens_kept_raw"] > 0 + + +@pytest.mark.asyncio +async def test_buy_past_the_curve_is_reported( + surfpool_rpc, funded_keypair, test_keystore, test_password +): + """Spending more than the curve has left fails with how many tokens remain.""" + from solana.rpc.async_api import AsyncClient + + from pumpfun_cli.core.launch import launch_token + + client = AsyncClient(surfpool_rpc) + try: + await client.request_airdrop(funded_keypair.pubkey(), 200_000_000_000) + finally: + await client.close() + launched = await launch_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + name="Big", + ticker="BIG", + description="buy past the curve", + uri="https://example.com/big.json", + confirm=True, + ) + assert "error" not in launched, launched + result = await buy_token( + rpc_url=surfpool_rpc, + keystore_path=str(test_keystore), + password=test_password, + mint_str=launched["mint"], + amount=150, + confirm=True, + ) + assert result["error"] == "exceeds_curve", result + assert result["curve_tokens_left_raw"] > 0