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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
7 changes: 7 additions & 0 deletions src/pumpfun_cli/commands/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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']}")


Expand Down
32 changes: 32 additions & 0 deletions src/pumpfun_cli/core/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)))

Expand All @@ -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 = []
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions src/pumpfun_cli/protocol/curve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions tests/test_commands/test_human_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_core/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions tests/test_core/test_trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---


Expand Down
134 changes: 134 additions & 0 deletions tests/test_surfpool/test_trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<Q", data, 32, 10_000_000) # real_quote_reserves: 0.01 SOL
async with httpx.AsyncClient() as http:
await http.post(
surfpool_rpc,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "surfnet_setAccount",
"params": [str(curve_pda), {"data": bytes(data).hex()}],
},
)
curve = parse_bonding_curve(bytes((await client.get_account_info(curve_pda)).value.data))
assert curve["real_quote_reserves"] == 10_000_000

preview = await sell_token(
rpc_url=surfpool_rpc,
keystore_path=str(test_keystore),
password=test_password,
mint_str=str(mint),
amount_str="all",
dry_run=True,
)
assert preview["tokens_kept_raw"] > 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
Loading