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
4 changes: 4 additions & 0 deletions src/pumpfun_cli/commands/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def launch(
image: str | None = typer.Option(None, "--image", help="Path to token image"),
buy: float | None = typer.Option(None, "--buy", help="Initial buy amount in SOL"),
mayhem: bool = typer.Option(False, "--mayhem", help="Enable mayhem mode"),
cashback: bool = typer.Option(False, "--cashback", help="Enable cashback for the token"),
):
"""Launch a new token on pump.fun (create_v2 + extend_account)."""
state = ctx.obj
Expand Down Expand Up @@ -49,6 +50,7 @@ def launch(
image,
buy,
mayhem,
cashback,
**overrides,
)
)
Expand All @@ -63,5 +65,7 @@ def launch(
typer.echo(f" Mint: {result['mint']}")
typer.echo(f" TX: {result['explorer']}")
typer.echo(f" Pump.fun: {result['pump_url']}")
if result.get("is_cashback"):
typer.echo(" Cashback: enabled")
if result.get("initial_buy_sol"):
typer.echo(f" Buy: {result['initial_buy_sol']} SOL")
3 changes: 3 additions & 0 deletions src/pumpfun_cli/core/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ async def launch_token(
image_path: str | None = None,
initial_buy_sol: float | None = None,
is_mayhem: bool = False,
is_cashback: bool = False,
priority_fee: int | None = None,
compute_units: int | None = None,
) -> dict:
Expand All @@ -84,6 +85,7 @@ async def launch_token(
symbol=ticker,
uri=uri,
is_mayhem=is_mayhem,
is_cashback=is_cashback,
)

# 4. Add extend_account instruction (required for frontend visibility)
Expand Down Expand Up @@ -121,6 +123,7 @@ async def launch_token(
"ticker": ticker,
"mint": str(mint),
"metadata_uri": uri,
"is_cashback": is_cashback,
"initial_buy_sol": initial_buy_sol,
"signature": sig,
"explorer": f"https://solscan.io/tx/{sig}",
Expand Down
25 changes: 12 additions & 13 deletions src/pumpfun_cli/protocol/instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ def build_create_instructions(
symbol: str,
uri: str,
is_mayhem: bool = False,
is_cashback: bool = False,
token_program: Pubkey = TOKEN_2022_PROGRAM,
) -> list[Instruction]:
"""Build create_v2 token instruction for pump.fun (Token2022).
Expand All @@ -307,6 +308,9 @@ def build_create_instructions(
assoc_bc = derive_associated_bonding_curve(mint, bonding_curve, token_program)
mint_auth = _derive_mint_authority()

mayhem_state = derive_mayhem_state(mint)
mayhem_token_vault = derive_mayhem_token_vault(mint)

create_accounts = [
AccountMeta(pubkey=mint, is_signer=True, is_writable=True),
AccountMeta(pubkey=mint_auth, is_signer=False, is_writable=False),
Expand All @@ -317,21 +321,15 @@ def build_create_instructions(
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=TOKEN_2022_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=ASSOCIATED_TOKEN_PROGRAM, is_signer=False, is_writable=False),
# Mayhem accounts are always required by create_v2 per IDL,
# regardless of is_mayhem_mode flag value.
AccountMeta(pubkey=MAYHEM_PROGRAM_ID, is_signer=False, is_writable=False),
AccountMeta(pubkey=MAYHEM_GLOBAL_PARAMS, is_signer=False, is_writable=False),
AccountMeta(pubkey=MAYHEM_SOL_VAULT, is_signer=False, is_writable=True),
AccountMeta(pubkey=mayhem_state, is_signer=False, is_writable=True),
AccountMeta(pubkey=mayhem_token_vault, is_signer=False, is_writable=True),
]

if is_mayhem:
mayhem_state = derive_mayhem_state(mint)
mayhem_token_vault = derive_mayhem_token_vault(mint)
create_accounts.extend(
[
AccountMeta(pubkey=MAYHEM_PROGRAM_ID, is_signer=False, is_writable=True),
AccountMeta(pubkey=MAYHEM_GLOBAL_PARAMS, is_signer=False, is_writable=False),
AccountMeta(pubkey=MAYHEM_SOL_VAULT, is_signer=False, is_writable=True),
AccountMeta(pubkey=mayhem_state, is_signer=False, is_writable=True),
AccountMeta(pubkey=mayhem_token_vault, is_signer=False, is_writable=True),
]
)

create_accounts.extend(
[
AccountMeta(pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False),
Expand All @@ -348,6 +346,7 @@ def build_create_instructions(
+ _encode_borsh_string(uri)
+ bytes(user) # creator arg
+ struct.pack("<?", is_mayhem) # is_mayhem_mode: bool
+ struct.pack("<?", is_cashback) # is_cashback_enabled: OptionBool
)

create_ix = Instruction(
Expand Down
8 changes: 8 additions & 0 deletions tests/test_commands/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ def test_tokens_no_subcommand_shows_help():
assert result.exit_code == 0


def test_launch_help_shows_cashback():
"""Launch --help includes --cashback flag."""
result = runner.invoke(app, ["launch", "--help"])
assert result.exit_code == 0
out = _strip_ansi(result.output)
assert "--cashback" in out


@pytest.mark.parametrize("limit", ["0", "-1"])
@pytest.mark.parametrize(
("subcommand", "extra_args"),
Expand Down
91 changes: 91 additions & 0 deletions tests/test_core/test_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Tests for core/launch.py — cashback flag pass-through."""

from unittest.mock import AsyncMock, patch

import pytest


@pytest.mark.asyncio
async def test_launch_passes_cashback_false(tmp_keystore):
"""launch_token passes is_cashback=False through to build_create_instructions."""
with (
patch(
"pumpfun_cli.core.launch.upload_metadata",
new_callable=AsyncMock,
return_value="https://ipfs.example.com/metadata.json",
),
patch("pumpfun_cli.core.launch.build_create_instructions", wraps=None) as mock_build,
patch("pumpfun_cli.core.launch.build_extend_account_instruction"),
patch(
"pumpfun_cli.core.launch.RpcClient",
) as mock_rpc_cls,
):
mock_client = AsyncMock()
mock_client.send_tx = AsyncMock(return_value="fakesig123")
mock_rpc_cls.return_value = mock_client

# build_create_instructions needs to return a list of instructions
from unittest.mock import MagicMock

mock_ix = MagicMock()
mock_build.return_value = [mock_ix]

from pumpfun_cli.core.launch import launch_token

result = await launch_token(
rpc_url="https://fake.rpc",
keystore_path=tmp_keystore,
password="testpass",
name="TestToken",
ticker="TST",
description="A test token",
is_cashback=False,
)

mock_build.assert_called_once()
call_kwargs = mock_build.call_args
assert call_kwargs.kwargs.get("is_cashback") is False or (
not call_kwargs.kwargs.get("is_cashback", True)
)
assert result["is_cashback"] is False


@pytest.mark.asyncio
async def test_launch_passes_cashback_true(tmp_keystore):
"""launch_token passes is_cashback=True through to build_create_instructions."""
with (
patch(
"pumpfun_cli.core.launch.upload_metadata",
new_callable=AsyncMock,
return_value="https://ipfs.example.com/metadata.json",
),
patch("pumpfun_cli.core.launch.build_create_instructions", wraps=None) as mock_build,
patch("pumpfun_cli.core.launch.build_extend_account_instruction"),
patch(
"pumpfun_cli.core.launch.RpcClient",
) as mock_rpc_cls,
):
mock_client = AsyncMock()
mock_client.send_tx = AsyncMock(return_value="fakesig456")
mock_rpc_cls.return_value = mock_client

from unittest.mock import MagicMock

mock_ix = MagicMock()
mock_build.return_value = [mock_ix]

from pumpfun_cli.core.launch import launch_token

result = await launch_token(
rpc_url="https://fake.rpc",
keystore_path=tmp_keystore,
password="testpass",
name="TestToken",
ticker="TST",
description="A test token",
is_cashback=True,
)

mock_build.assert_called_once()
assert mock_build.call_args.kwargs["is_cashback"] is True
assert result["is_cashback"] is True
86 changes: 85 additions & 1 deletion tests/test_protocol/test_instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@
from solders.pubkey import Pubkey

from pumpfun_cli.protocol.address import derive_associated_bonding_curve, derive_bonding_curve
from pumpfun_cli.protocol.contracts import BUY_EXACT_SOL_IN_DISCRIMINATOR
from pumpfun_cli.protocol.contracts import (
BUY_EXACT_SOL_IN_DISCRIMINATOR,
MAYHEM_GLOBAL_PARAMS,
MAYHEM_PROGRAM_ID,
MAYHEM_SOL_VAULT,
)
from pumpfun_cli.protocol.idl_parser import IDLParser
from pumpfun_cli.protocol.instructions import (
build_buy_exact_sol_in_instructions,
build_buy_instructions,
build_create_instructions,
build_sell_instructions,
)

# create_v2 must always have exactly 16 accounts (including mayhem accounts).
_EXPECTED_CREATE_V2_ACCOUNTS = 16

IDL_PATH = Path(__file__).parent.parent.parent / "idl" / "pump_fun_idl.json"
_MINT = Pubkey.from_string("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
_USER = Pubkey.from_string("11111111111111111111111111111112")
Expand Down Expand Up @@ -81,3 +90,78 @@ def test_buy_exact_sol_in_discriminator():
)
buy_ix = ixs[-1]
assert buy_ix.data[:8] == BUY_EXACT_SOL_IN_DISCRIMINATOR


def test_create_instructions_cashback_false():
"""create_v2 with is_cashback=False encodes OptionBool as 0x00."""
idl = IDLParser(str(IDL_PATH))
ixs = build_create_instructions(
idl=idl,
mint=_MINT,
user=_USER,
name="Test",
symbol="TST",
uri="https://example.com",
is_mayhem=False,
is_cashback=False,
)
assert len(ixs) == 1
create_ix = ixs[0]
# Lock account layout to prevent AccountNotEnoughKeys regressions
assert len(create_ix.accounts) == _EXPECTED_CREATE_V2_ACCOUNTS
assert create_ix.accounts[9].pubkey == MAYHEM_PROGRAM_ID
assert create_ix.accounts[10].pubkey == MAYHEM_GLOBAL_PARAMS
assert create_ix.accounts[11].pubkey == MAYHEM_SOL_VAULT
# Last byte should be 0x00 (is_cashback_enabled = false)
assert create_ix.data[-1:] == b"\x00"
# Second-to-last byte is is_mayhem_mode = false
assert create_ix.data[-2:-1] == b"\x00"


def test_create_instructions_cashback_true():
"""create_v2 with is_cashback=True encodes OptionBool as 0x01."""
idl = IDLParser(str(IDL_PATH))
ixs = build_create_instructions(
idl=idl,
mint=_MINT,
user=_USER,
name="Test",
symbol="TST",
uri="https://example.com",
is_mayhem=False,
is_cashback=True,
)
assert len(ixs) == 1
create_ix = ixs[0]
assert len(create_ix.accounts) == _EXPECTED_CREATE_V2_ACCOUNTS
assert create_ix.accounts[9].pubkey == MAYHEM_PROGRAM_ID
assert create_ix.accounts[10].pubkey == MAYHEM_GLOBAL_PARAMS
assert create_ix.accounts[11].pubkey == MAYHEM_SOL_VAULT
# Last byte should be 0x01 (is_cashback_enabled = true)
assert create_ix.data[-1:] == b"\x01"
# Second-to-last byte is is_mayhem_mode = false
assert create_ix.data[-2:-1] == b"\x00"


def test_create_instructions_mayhem_and_cashback():
"""create_v2 with both is_mayhem=True and is_cashback=True."""
idl = IDLParser(str(IDL_PATH))
ixs = build_create_instructions(
idl=idl,
mint=_MINT,
user=_USER,
name="Test",
symbol="TST",
uri="https://example.com",
is_mayhem=True,
is_cashback=True,
)
assert len(ixs) == 1
create_ix = ixs[0]
assert len(create_ix.accounts) == _EXPECTED_CREATE_V2_ACCOUNTS
assert create_ix.accounts[9].pubkey == MAYHEM_PROGRAM_ID
assert create_ix.accounts[10].pubkey == MAYHEM_GLOBAL_PARAMS
assert create_ix.accounts[11].pubkey == MAYHEM_SOL_VAULT
# Last byte = cashback true, second-to-last = mayhem true
assert create_ix.data[-1:] == b"\x01"
assert create_ix.data[-2:-1] == b"\x01"
81 changes: 81 additions & 0 deletions tests/test_surfpool/test_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Surfpool integration: token launch with mayhem/cashback flag combinations.

Tests launch_token() against a surfpool fork, covering the full matrix of
is_mayhem x is_cashback (with and without initial buy), verifying that
tokens are created on-chain successfully.

The IPFS metadata upload is mocked — the on-chain program does not
validate URI content, so a fake URI is sufficient for testing the
create_v2 instruction serialisation.
"""

from unittest.mock import AsyncMock, patch

import pytest

from pumpfun_cli.core.info import get_token_info
from pumpfun_cli.core.launch import launch_token

FAKE_URI = "https://example.com/test-metadata.json"

_UPLOAD_PATCH = patch(
"pumpfun_cli.core.launch.upload_metadata",
new_callable=AsyncMock,
return_value=FAKE_URI,
)

_LAUNCH_MATRIX = [
pytest.param(False, False, None, id="default"),
pytest.param(False, True, None, id="cashback"),
pytest.param(True, False, None, id="mayhem"),
pytest.param(True, True, None, id="mayhem+cashback"),
pytest.param(False, False, 0.001, id="default+buy"),
pytest.param(False, True, 0.001, id="cashback+buy"),
pytest.param(True, False, 0.001, id="mayhem+buy"),
pytest.param(True, True, 0.001, id="mayhem+cashback+buy"),
]


@pytest.mark.asyncio
@pytest.mark.parametrize("is_mayhem,is_cashback,initial_buy_sol", _LAUNCH_MATRIX)
@_UPLOAD_PATCH
async def test_launch(
_mock_upload,
surfpool_rpc,
funded_keypair,
test_keystore,
test_password,
is_mayhem,
is_cashback,
initial_buy_sol,
):
"""Launch token with given mayhem/cashback/buy combination."""
kwargs = {}
if is_mayhem:
kwargs["is_mayhem"] = True
if is_cashback:
kwargs["is_cashback"] = True
if initial_buy_sol is not None:
kwargs["initial_buy_sol"] = initial_buy_sol

result = await launch_token(
rpc_url=surfpool_rpc,
keystore_path=str(test_keystore),
password=test_password,
name="Test",
ticker="TST",
description="parametrized launch",
**kwargs,
)

assert "error" not in result, f"Launch failed: {result}"
assert result["action"] == "launch"
assert result["is_cashback"] is is_cashback
assert result["signature"]

if initial_buy_sol is not None:
assert result["initial_buy_sol"] == initial_buy_sol

info = await get_token_info(surfpool_rpc, result["mint"])
assert "error" not in info, f"Token not found: {info}"
assert info["graduated"] is False
Loading