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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ but a coin can be priced in USDC or another admitted mint; `buy`, `sell` and
`info` read it from the coin and label their output with it. Buying such a coin
spends that asset, so the wallet has to hold it.

In `--json` output an exact amount comes twice: in whole units for reading
(`tokens_sold`) and as the integer in raw units (`tokens_sold_raw`). Use the
`_raw` field for anything that has to add up; the float can drop a unit. An
amount known only as an estimate has no `_raw` twin: `quote_received`, the
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
`collect-creator-fee` totals, and `tokens_received` unless the buy ran with
`--confirm`, in which case it is read from the confirmed transaction. If that
transaction cannot be read back, a confirmed buy also reports the estimate
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.

## Commands

```
Expand Down
69 changes: 69 additions & 0 deletions src/pumpfun_cli/core/amounts.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
"""Exact conversion of user-entered amounts into raw on-chain units."""

import asyncio
from decimal import ROUND_DOWN, Decimal

from solders.pubkey import Pubkey

from pumpfun_cli.protocol.client import RpcClient

# A load-balanced endpoint can briefly miss a transaction another node confirmed.
_TX_READ_ATTEMPTS = 3


def to_raw(amount: float | str, decimals: int) -> int:
"""Whole units to raw units, exact for the decimal text the user typed.
Expand All @@ -11,3 +19,64 @@ def to_raw(amount: float | str, decimals: int) -> int:
"""
scaled = Decimal(str(amount)) * (Decimal(10) ** decimals)
return int(scaled.to_integral_value(ROUND_DOWN))


def amount_fields(name: str, raw: int, decimals: int) -> dict:
"""An amount for output: `name` in whole units for reading, `name_raw` exact.

The float can lose precision on large raw counts; `name_raw` is what was
actually used on chain.
"""
return {name: raw / 10**decimals, f"{name}_raw": raw}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


def received_fields(name: str, actual: int | None, estimate: int, decimals: int) -> dict:
"""A trade's receipt: exact, with `name_raw`, when read from the confirmed
transaction; otherwise the pre-trade estimate alone, without `name_raw`."""
if actual is None:
return {name: estimate / 10**decimals}
return amount_fields(name, actual, decimals)


def token_delta(tx: dict, owner: Pubkey, mint: Pubkey) -> int | None:
"""Raw change in `owner`'s balance of `mint` across a transaction.

None when it cannot be established: the balances were not recorded, or a
row for the mint does not name its owner.
"""

def total(rows: list | None) -> int | None:
if rows is None:
return None
amount = 0
for row in rows:
if row.get("mint") != str(mint):
continue
if not row.get("owner"):
return None
if row["owner"] == str(owner):
amount += int(row["uiTokenAmount"]["amount"])
return amount

post, pre = total(tx["post_token_balances"]), total(tx["pre_token_balances"])
return None if post is None or pre is None else post - pre


async def confirmed_token_delta(
client: RpcClient, signature: str, owner: Pubkey, mint: Pubkey
) -> int | None:
"""Raw tokens a confirmed buy moved into `owner`'s account, or None when
the transaction cannot be read back or shows no tokens received."""
for attempt in range(_TX_READ_ATTEMPTS):
try:
tx = await client.get_transaction(signature)
except RuntimeError:
return None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if tx is not None:
if tx["err"]:
return None
delta = token_delta(tx, owner, mint)
return delta if delta is not None and delta > 0 else None
if attempt < _TX_READ_ATTEMPTS - 1:
await asyncio.sleep(1)
return None
5 changes: 3 additions & 2 deletions src/pumpfun_cli/core/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from solders.keypair import Keypair
from spl.token.instructions import create_idempotent_associated_token_account

from pumpfun_cli.core.amounts import to_raw
from pumpfun_cli.core.amounts import amount_fields, to_raw
from pumpfun_cli.core.validate import parse_pubkey
from pumpfun_cli.crypto import decrypt_keypair
from pumpfun_cli.protocol.address import derive_bonding_curve, find_ata, find_holder_rewards
Expand Down Expand Up @@ -248,7 +248,8 @@ async def launch_token(
"is_holder_reward": is_holder_reward,
"creator_fee_bps": creator_fee_bps or 0,
"initial_buy": initial_buy,
"expected_tokens": expected_tokens / 10**TOKEN_DECIMALS,
"initial_buy_raw": amount_raw,
**amount_fields("expected_tokens", expected_tokens, TOKEN_DECIMALS),
"signature": sig,
"explorer": f"https://solscan.io/tx/{sig}",
"pump_url": f"https://pump.fun/coin/{mint}",
Expand Down
26 changes: 16 additions & 10 deletions src/pumpfun_cli/core/pumpswap.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@

from solders.pubkey import Pubkey

from pumpfun_cli.core.amounts import to_raw
from pumpfun_cli.core.amounts import (
amount_fields,
confirmed_token_delta,
received_fields,
to_raw,
)
from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey
from pumpfun_cli.crypto import decrypt_keypair
from pumpfun_cli.protocol.address import (
Expand Down Expand Up @@ -167,12 +172,12 @@ async def buy_pumpswap(
"mint": mint_str,
"quote_mint": str(quote_mint),
"quote_symbol": symbol,
"quote_in": amount,
"expected_tokens": expected_tokens,
**amount_fields("quote_in", amount_raw, quote_decimals),
**amount_fields("expected_tokens", estimated_tokens, base_decimals),
"effective_price": effective_price,
"spot_price": spot_price,
"price_impact_pct": round(price_impact, 4),
"min_tokens_out": min_base_amount_out / 10**base_decimals,
**amount_fields("min_tokens_out", min_base_amount_out, base_decimals),
"slippage_pct": slippage,
}
if shortfall:
Expand Down Expand Up @@ -215,14 +220,15 @@ async def buy_pumpswap(
)
except TransactionFailedError as exc:
return _handle_tx_error(exc, PUMPSWAP_SLIPPAGE_ERROR_CODES)
received = await confirmed_token_delta(client, sig, user, mint) if confirm else None
result = {
"action": "buy",
"venue": "pumpswap",
"mint": mint_str,
"quote_mint": str(quote_mint),
"quote_symbol": symbol,
"quote_spent": amount,
"tokens_received": estimated_tokens / 10**base_decimals,
**amount_fields("quote_spent", amount_raw, quote_decimals),
**received_fields("tokens_received", received, estimated_tokens, base_decimals),
"signature": sig,
"explorer": f"https://solscan.io/tx/{sig}",
}
Expand Down Expand Up @@ -306,12 +312,12 @@ async def sell_pumpswap(
"mint": mint_str,
"quote_mint": str(quote_mint),
"quote_symbol": symbol,
"tokens_in": tokens_human,
"expected_quote": quote_out_human,
**amount_fields("tokens_in", token_amount, base_decimals),
**amount_fields("expected_quote", expected_quote, quote_decimals),
"effective_price": effective_price,
"spot_price": spot_price,
"price_impact_pct": round(price_impact, 4),
"min_quote_out": min_quote_out / 10**quote_decimals,
**amount_fields("min_quote_out", min_quote_out, quote_decimals),
"slippage_pct": slippage,
}

Expand Down Expand Up @@ -345,7 +351,7 @@ async def sell_pumpswap(
"mint": mint_str,
"quote_mint": str(quote_mint),
"quote_symbol": symbol,
"tokens_sold": token_amount / 10**base_decimals,
**amount_fields("tokens_sold", token_amount, base_decimals),
"quote_received": expected_quote / 10**quote_decimals,
"signature": sig,
"explorer": f"https://solscan.io/tx/{sig}",
Expand Down
27 changes: 17 additions & 10 deletions src/pumpfun_cli/core/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from solders.pubkey import Pubkey
from spl.token.instructions import create_idempotent_associated_token_account

from pumpfun_cli.core.amounts import to_raw
from pumpfun_cli.core.amounts import (
amount_fields,
confirmed_token_delta,
received_fields,
to_raw,
)
from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey
from pumpfun_cli.crypto import decrypt_keypair
from pumpfun_cli.protocol.address import (
Expand Down Expand Up @@ -201,12 +206,12 @@ async def buy_token(
"mint": mint_str,
"quote_mint": str(quote_mint),
"quote_symbol": symbol,
"quote_in": amount,
"expected_tokens": expected_tokens,
**amount_fields("quote_in", amount_raw, quote_decimals),
**amount_fields("expected_tokens", tokens_out, TOKEN_DECIMALS),
"effective_price": effective_price,
"spot_price": spot_price,
"price_impact_pct": round(price_impact, 4),
"min_tokens_out": min_tokens_out / 10**TOKEN_DECIMALS,
**amount_fields("min_tokens_out", min_tokens_out, TOKEN_DECIMALS),
"slippage_pct": slippage,
}
if shortfall:
Expand Down Expand Up @@ -246,6 +251,7 @@ async def buy_token(
)
except TransactionFailedError as exc:
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(
"buy",
sig,
Expand All @@ -254,8 +260,8 @@ async def buy_token(
mint=mint_str,
quote_mint=str(quote_mint),
quote_symbol=symbol,
quote_spent=amount,
tokens_received=tokens_out / 10**TOKEN_DECIMALS,
**amount_fields("quote_spent", amount_raw, quote_decimals),
**received_fields("tokens_received", received, tokens_out, TOKEN_DECIMALS),
)
finally:
await client.close()
Expand Down Expand Up @@ -336,12 +342,12 @@ async def sell_token(
"mint": mint_str,
"quote_mint": str(quote_mint),
"quote_symbol": symbol,
"tokens_in": tokens_human,
"expected_quote": quote_out_human,
**amount_fields("tokens_in", token_amount, TOKEN_DECIMALS),
**amount_fields("expected_quote", quote_out, quote_decimals),
"effective_price": effective_price,
"spot_price": spot_price,
"price_impact_pct": round(price_impact, 4),
"min_quote_out": min_quote_out / 10**quote_decimals,
**amount_fields("min_quote_out", min_quote_out, quote_decimals),
"slippage_pct": slippage,
}

Expand Down Expand Up @@ -389,7 +395,7 @@ async def sell_token(
mint=mint_str,
quote_mint=str(quote_mint),
quote_symbol=symbol,
tokens_sold=token_amount / 10**TOKEN_DECIMALS,
**amount_fields("tokens_sold", token_amount, TOKEN_DECIMALS),
quote_received=quote_out / 10**quote_decimals,
)
finally:
Expand Down Expand Up @@ -603,6 +609,7 @@ async def collect_creator_fees(
confirm,
quote_mint=str(quote_mint),
quote_symbol=quote_symbol(quote_mint),
# Vault balances read before sending; fees landing in between are swept too.
pump_recovered=pump_balance / 10**quote_decimals,
pumpswap_recovered=pumpswap_balance / 10**quote_decimals,
)
Expand Down
15 changes: 8 additions & 7 deletions src/pumpfun_cli/core/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
from solders.keypair import Keypair
from solders.pubkey import Pubkey

from pumpfun_cli.core.amounts import amount_fields, to_raw
from pumpfun_cli.core.validate import invalid_pubkey_error, parse_pubkey
from pumpfun_cli.crypto import decrypt_keypair, encrypt_keypair, get_pubkey
from pumpfun_cli.protocol.contracts import LAMPORTS_PER_SOL
from pumpfun_cli.protocol.contracts import SOL_DECIMALS


def create_wallet(password: str, keystore_path: str, force: bool = False) -> dict:
Expand Down Expand Up @@ -68,7 +69,7 @@ async def get_balance(rpc_url: str, pubkey_str: str, commitment: str = "confirme
client = RpcClient(rpc_url)
try:
lamports = await client.get_balance(pubkey, commitment=commitment)
return {"pubkey": pubkey_str, "sol_balance": lamports / LAMPORTS_PER_SOL}
return {"pubkey": pubkey_str, **amount_fields("sol_balance", lamports, SOL_DECIMALS)}
finally:
await client.close()

Expand Down Expand Up @@ -118,7 +119,7 @@ async def transfer_sol(
recipient = parse_pubkey(recipient_str, "recipient address")
if recipient is None:
return invalid_pubkey_error(recipient_str, "recipient address")
lamports = int(sol_amount * LAMPORTS_PER_SOL)
lamports = to_raw(sol_amount, SOL_DECIMALS)
client = RpcClient(rpc_url)
try:
ix = transfer(
Expand All @@ -139,7 +140,7 @@ async def transfer_sol(
"action": "transfer",
"from": str(keypair.pubkey()),
"to": recipient_str,
"sol_amount": sol_amount,
**amount_fields("sol_amount", lamports, SOL_DECIMALS),
"signature": sig,
"explorer": f"https://solscan.io/tx/{sig}",
}
Expand Down Expand Up @@ -204,7 +205,7 @@ async def transfer_all_sol(
"action": "transfer",
"from": str(keypair.pubkey()),
"to": recipient_str,
"sol_amount": send_lamports / LAMPORTS_PER_SOL,
**amount_fields("sol_amount", send_lamports, SOL_DECIMALS),
"signature": sig,
"explorer": f"https://solscan.io/tx/{sig}",
}
Expand Down Expand Up @@ -234,7 +235,6 @@ async def transfer_token(
)
from spl.token.models import TransferCheckedParams

from pumpfun_cli.core.amounts import to_raw
from pumpfun_cli.protocol.client import RpcClient
from pumpfun_cli.protocol.quote import get_mint_info

Expand Down Expand Up @@ -291,7 +291,7 @@ async def transfer_token(
"from": str(keypair.pubkey()),
"to": recipient_str,
"mint": mint_str,
"amount": raw_amount / (10**decimals),
**amount_fields("amount", raw_amount, decimals),
"signature": sig,
"explorer": f"https://solscan.io/tx/{sig}",
}
Expand Down Expand Up @@ -427,6 +427,7 @@ async def drain_wallet(
"ata_signature": ata_result.get("signature"),
"ata_error": ata_result.get("ata_error"),
"sol_transferred": sol_result.get("sol_amount", 0),
"sol_transferred_raw": sol_result.get("sol_amount_raw", 0),
"sol_signature": sol_result.get("signature"),
"sol_error": sol_result.get("error"),
}
11 changes: 9 additions & 2 deletions src/pumpfun_cli/protocol/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,8 @@ async def get_transaction(self, signature_str: str) -> dict | None:
Reads the raw JSON-RPC result rather than a typed response, so a
transaction in any version is reported the same way.

Returns a dict with slot, err, fee, block_time — or None if not found.
Returns a dict with slot, err, fee, block_time and the token balances
before and after (None if unrecorded) — or None if not found.
"""
Signature.from_string(signature_str) # reject a malformed signature before any I/O
payload = {
Expand All @@ -238,7 +239,10 @@ async def get_transaction(self, signature_str: str) -> dict | None:
raise RuntimeError(f"getTransaction failed: {type(exc).__name__}") from None
if resp.is_error:
raise RuntimeError(f"getTransaction failed: HTTP {resp.status_code}")
body = resp.json()
try:
body = resp.json()
except ValueError:
raise RuntimeError("getTransaction failed: response is not JSON") from None
if body.get("error"):
raise RuntimeError(f"getTransaction failed: {body['error'].get('message')}")
result = body.get("result")
Expand All @@ -250,6 +254,9 @@ async def get_transaction(self, signature_str: str) -> dict | None:
"err": meta.get("err"),
"fee": meta.get("fee"),
"block_time": result.get("blockTime"),
# None when the node did not record them, distinct from an empty list.
"pre_token_balances": meta.get("preTokenBalances"),
"post_token_balances": meta.get("postTokenBalances"),
}

async def close(self):
Expand Down
1 change: 1 addition & 0 deletions src/pumpfun_cli/protocol/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

# Solana constants
LAMPORTS_PER_SOL = 1_000_000_000
SOL_DECIMALS = 9
TOKEN_DECIMALS = 6 # every pump.fun coin has 6 decimals; other PumpSwap pools may not

# System programs
Expand Down
Loading
Loading