Skip to content

Commit b74b635

Browse files
smypmsaclaude
andauthored
feat: add --cashback flag to token launch + fix create_v2 accounts (#14)
* feat: add --cashback flag to token launch + fix create_v2 account layout Wire is_cashback_enabled (OptionBool) through all three layers for the create_v2 instruction, and fix a pre-existing bug where mayhem accounts were conditionally included — the IDL requires them always. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review — writable flag, account layout tests, parametrize surfpool - Set MAYHEM_PROGRAM_ID to is_writable=False in create_v2 (read-only per IDL) - Add account count + position assertions to create_v2 instruction tests - Parametrize 8 surfpool launch tests into single matrix test 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 ed5e22f commit b74b635

File tree

7 files changed

+284
-14
lines changed

7 files changed

+284
-14
lines changed

src/pumpfun_cli/commands/launch.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def launch(
1616
image: str | None = typer.Option(None, "--image", help="Path to token image"),
1717
buy: float | None = typer.Option(None, "--buy", help="Initial buy amount in SOL"),
1818
mayhem: bool = typer.Option(False, "--mayhem", help="Enable mayhem mode"),
19+
cashback: bool = typer.Option(False, "--cashback", help="Enable cashback for the token"),
1920
):
2021
"""Launch a new token on pump.fun (create_v2 + extend_account)."""
2122
state = ctx.obj
@@ -49,6 +50,7 @@ def launch(
4950
image,
5051
buy,
5152
mayhem,
53+
cashback,
5254
**overrides,
5355
)
5456
)
@@ -63,5 +65,7 @@ def launch(
6365
typer.echo(f" Mint: {result['mint']}")
6466
typer.echo(f" TX: {result['explorer']}")
6567
typer.echo(f" Pump.fun: {result['pump_url']}")
68+
if result.get("is_cashback"):
69+
typer.echo(" Cashback: enabled")
6670
if result.get("initial_buy_sol"):
6771
typer.echo(f" Buy: {result['initial_buy_sol']} SOL")

src/pumpfun_cli/core/launch.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ async def launch_token(
5858
image_path: str | None = None,
5959
initial_buy_sol: float | None = None,
6060
is_mayhem: bool = False,
61+
is_cashback: bool = False,
6162
priority_fee: int | None = None,
6263
compute_units: int | None = None,
6364
) -> dict:
@@ -84,6 +85,7 @@ async def launch_token(
8485
symbol=ticker,
8586
uri=uri,
8687
is_mayhem=is_mayhem,
88+
is_cashback=is_cashback,
8789
)
8890

8991
# 4. Add extend_account instruction (required for frontend visibility)
@@ -121,6 +123,7 @@ async def launch_token(
121123
"ticker": ticker,
122124
"mint": str(mint),
123125
"metadata_uri": uri,
126+
"is_cashback": is_cashback,
124127
"initial_buy_sol": initial_buy_sol,
125128
"signature": sig,
126129
"explorer": f"https://solscan.io/tx/{sig}",

src/pumpfun_cli/protocol/instructions.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ def build_create_instructions(
286286
symbol: str,
287287
uri: str,
288288
is_mayhem: bool = False,
289+
is_cashback: bool = False,
289290
token_program: Pubkey = TOKEN_2022_PROGRAM,
290291
) -> list[Instruction]:
291292
"""Build create_v2 token instruction for pump.fun (Token2022).
@@ -307,6 +308,9 @@ def build_create_instructions(
307308
assoc_bc = derive_associated_bonding_curve(mint, bonding_curve, token_program)
308309
mint_auth = _derive_mint_authority()
309310

311+
mayhem_state = derive_mayhem_state(mint)
312+
mayhem_token_vault = derive_mayhem_token_vault(mint)
313+
310314
create_accounts = [
311315
AccountMeta(pubkey=mint, is_signer=True, is_writable=True),
312316
AccountMeta(pubkey=mint_auth, is_signer=False, is_writable=False),
@@ -317,21 +321,15 @@ def build_create_instructions(
317321
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
318322
AccountMeta(pubkey=TOKEN_2022_PROGRAM, is_signer=False, is_writable=False),
319323
AccountMeta(pubkey=ASSOCIATED_TOKEN_PROGRAM, is_signer=False, is_writable=False),
324+
# Mayhem accounts are always required by create_v2 per IDL,
325+
# regardless of is_mayhem_mode flag value.
326+
AccountMeta(pubkey=MAYHEM_PROGRAM_ID, is_signer=False, is_writable=False),
327+
AccountMeta(pubkey=MAYHEM_GLOBAL_PARAMS, is_signer=False, is_writable=False),
328+
AccountMeta(pubkey=MAYHEM_SOL_VAULT, is_signer=False, is_writable=True),
329+
AccountMeta(pubkey=mayhem_state, is_signer=False, is_writable=True),
330+
AccountMeta(pubkey=mayhem_token_vault, is_signer=False, is_writable=True),
320331
]
321332

322-
if is_mayhem:
323-
mayhem_state = derive_mayhem_state(mint)
324-
mayhem_token_vault = derive_mayhem_token_vault(mint)
325-
create_accounts.extend(
326-
[
327-
AccountMeta(pubkey=MAYHEM_PROGRAM_ID, is_signer=False, is_writable=True),
328-
AccountMeta(pubkey=MAYHEM_GLOBAL_PARAMS, is_signer=False, is_writable=False),
329-
AccountMeta(pubkey=MAYHEM_SOL_VAULT, is_signer=False, is_writable=True),
330-
AccountMeta(pubkey=mayhem_state, is_signer=False, is_writable=True),
331-
AccountMeta(pubkey=mayhem_token_vault, is_signer=False, is_writable=True),
332-
]
333-
)
334-
335333
create_accounts.extend(
336334
[
337335
AccountMeta(pubkey=PUMP_EVENT_AUTHORITY, is_signer=False, is_writable=False),
@@ -348,6 +346,7 @@ def build_create_instructions(
348346
+ _encode_borsh_string(uri)
349347
+ bytes(user) # creator arg
350348
+ struct.pack("<?", is_mayhem) # is_mayhem_mode: bool
349+
+ struct.pack("<?", is_cashback) # is_cashback_enabled: OptionBool
351350
)
352351

353352
create_ix = Instruction(

tests/test_commands/test_smoke.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ def test_tokens_no_subcommand_shows_help():
173173
assert result.exit_code == 0
174174

175175

176+
def test_launch_help_shows_cashback():
177+
"""Launch --help includes --cashback flag."""
178+
result = runner.invoke(app, ["launch", "--help"])
179+
assert result.exit_code == 0
180+
out = _strip_ansi(result.output)
181+
assert "--cashback" in out
182+
183+
176184
@pytest.mark.parametrize("limit", ["0", "-1"])
177185
@pytest.mark.parametrize(
178186
("subcommand", "extra_args"),

tests/test_core/test_launch.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Tests for core/launch.py — cashback flag pass-through."""
2+
3+
from unittest.mock import AsyncMock, patch
4+
5+
import pytest
6+
7+
8+
@pytest.mark.asyncio
9+
async def test_launch_passes_cashback_false(tmp_keystore):
10+
"""launch_token passes is_cashback=False through to build_create_instructions."""
11+
with (
12+
patch(
13+
"pumpfun_cli.core.launch.upload_metadata",
14+
new_callable=AsyncMock,
15+
return_value="https://ipfs.example.com/metadata.json",
16+
),
17+
patch("pumpfun_cli.core.launch.build_create_instructions", wraps=None) as mock_build,
18+
patch("pumpfun_cli.core.launch.build_extend_account_instruction"),
19+
patch(
20+
"pumpfun_cli.core.launch.RpcClient",
21+
) as mock_rpc_cls,
22+
):
23+
mock_client = AsyncMock()
24+
mock_client.send_tx = AsyncMock(return_value="fakesig123")
25+
mock_rpc_cls.return_value = mock_client
26+
27+
# build_create_instructions needs to return a list of instructions
28+
from unittest.mock import MagicMock
29+
30+
mock_ix = MagicMock()
31+
mock_build.return_value = [mock_ix]
32+
33+
from pumpfun_cli.core.launch import launch_token
34+
35+
result = await launch_token(
36+
rpc_url="https://fake.rpc",
37+
keystore_path=tmp_keystore,
38+
password="testpass",
39+
name="TestToken",
40+
ticker="TST",
41+
description="A test token",
42+
is_cashback=False,
43+
)
44+
45+
mock_build.assert_called_once()
46+
call_kwargs = mock_build.call_args
47+
assert call_kwargs.kwargs.get("is_cashback") is False or (
48+
not call_kwargs.kwargs.get("is_cashback", True)
49+
)
50+
assert result["is_cashback"] is False
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_launch_passes_cashback_true(tmp_keystore):
55+
"""launch_token passes is_cashback=True through to build_create_instructions."""
56+
with (
57+
patch(
58+
"pumpfun_cli.core.launch.upload_metadata",
59+
new_callable=AsyncMock,
60+
return_value="https://ipfs.example.com/metadata.json",
61+
),
62+
patch("pumpfun_cli.core.launch.build_create_instructions", wraps=None) as mock_build,
63+
patch("pumpfun_cli.core.launch.build_extend_account_instruction"),
64+
patch(
65+
"pumpfun_cli.core.launch.RpcClient",
66+
) as mock_rpc_cls,
67+
):
68+
mock_client = AsyncMock()
69+
mock_client.send_tx = AsyncMock(return_value="fakesig456")
70+
mock_rpc_cls.return_value = mock_client
71+
72+
from unittest.mock import MagicMock
73+
74+
mock_ix = MagicMock()
75+
mock_build.return_value = [mock_ix]
76+
77+
from pumpfun_cli.core.launch import launch_token
78+
79+
result = await launch_token(
80+
rpc_url="https://fake.rpc",
81+
keystore_path=tmp_keystore,
82+
password="testpass",
83+
name="TestToken",
84+
ticker="TST",
85+
description="A test token",
86+
is_cashback=True,
87+
)
88+
89+
mock_build.assert_called_once()
90+
assert mock_build.call_args.kwargs["is_cashback"] is True
91+
assert result["is_cashback"] is True

tests/test_protocol/test_instructions.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,23 @@
33
from solders.pubkey import Pubkey
44

55
from pumpfun_cli.protocol.address import derive_associated_bonding_curve, derive_bonding_curve
6-
from pumpfun_cli.protocol.contracts import BUY_EXACT_SOL_IN_DISCRIMINATOR
6+
from pumpfun_cli.protocol.contracts import (
7+
BUY_EXACT_SOL_IN_DISCRIMINATOR,
8+
MAYHEM_GLOBAL_PARAMS,
9+
MAYHEM_PROGRAM_ID,
10+
MAYHEM_SOL_VAULT,
11+
)
712
from pumpfun_cli.protocol.idl_parser import IDLParser
813
from pumpfun_cli.protocol.instructions import (
914
build_buy_exact_sol_in_instructions,
1015
build_buy_instructions,
16+
build_create_instructions,
1117
build_sell_instructions,
1218
)
1319

20+
# create_v2 must always have exactly 16 accounts (including mayhem accounts).
21+
_EXPECTED_CREATE_V2_ACCOUNTS = 16
22+
1423
IDL_PATH = Path(__file__).parent.parent.parent / "idl" / "pump_fun_idl.json"
1524
_MINT = Pubkey.from_string("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1625
_USER = Pubkey.from_string("11111111111111111111111111111112")
@@ -81,3 +90,78 @@ def test_buy_exact_sol_in_discriminator():
8190
)
8291
buy_ix = ixs[-1]
8392
assert buy_ix.data[:8] == BUY_EXACT_SOL_IN_DISCRIMINATOR
93+
94+
95+
def test_create_instructions_cashback_false():
96+
"""create_v2 with is_cashback=False encodes OptionBool as 0x00."""
97+
idl = IDLParser(str(IDL_PATH))
98+
ixs = build_create_instructions(
99+
idl=idl,
100+
mint=_MINT,
101+
user=_USER,
102+
name="Test",
103+
symbol="TST",
104+
uri="https://example.com",
105+
is_mayhem=False,
106+
is_cashback=False,
107+
)
108+
assert len(ixs) == 1
109+
create_ix = ixs[0]
110+
# Lock account layout to prevent AccountNotEnoughKeys regressions
111+
assert len(create_ix.accounts) == _EXPECTED_CREATE_V2_ACCOUNTS
112+
assert create_ix.accounts[9].pubkey == MAYHEM_PROGRAM_ID
113+
assert create_ix.accounts[10].pubkey == MAYHEM_GLOBAL_PARAMS
114+
assert create_ix.accounts[11].pubkey == MAYHEM_SOL_VAULT
115+
# Last byte should be 0x00 (is_cashback_enabled = false)
116+
assert create_ix.data[-1:] == b"\x00"
117+
# Second-to-last byte is is_mayhem_mode = false
118+
assert create_ix.data[-2:-1] == b"\x00"
119+
120+
121+
def test_create_instructions_cashback_true():
122+
"""create_v2 with is_cashback=True encodes OptionBool as 0x01."""
123+
idl = IDLParser(str(IDL_PATH))
124+
ixs = build_create_instructions(
125+
idl=idl,
126+
mint=_MINT,
127+
user=_USER,
128+
name="Test",
129+
symbol="TST",
130+
uri="https://example.com",
131+
is_mayhem=False,
132+
is_cashback=True,
133+
)
134+
assert len(ixs) == 1
135+
create_ix = ixs[0]
136+
assert len(create_ix.accounts) == _EXPECTED_CREATE_V2_ACCOUNTS
137+
assert create_ix.accounts[9].pubkey == MAYHEM_PROGRAM_ID
138+
assert create_ix.accounts[10].pubkey == MAYHEM_GLOBAL_PARAMS
139+
assert create_ix.accounts[11].pubkey == MAYHEM_SOL_VAULT
140+
# Last byte should be 0x01 (is_cashback_enabled = true)
141+
assert create_ix.data[-1:] == b"\x01"
142+
# Second-to-last byte is is_mayhem_mode = false
143+
assert create_ix.data[-2:-1] == b"\x00"
144+
145+
146+
def test_create_instructions_mayhem_and_cashback():
147+
"""create_v2 with both is_mayhem=True and is_cashback=True."""
148+
idl = IDLParser(str(IDL_PATH))
149+
ixs = build_create_instructions(
150+
idl=idl,
151+
mint=_MINT,
152+
user=_USER,
153+
name="Test",
154+
symbol="TST",
155+
uri="https://example.com",
156+
is_mayhem=True,
157+
is_cashback=True,
158+
)
159+
assert len(ixs) == 1
160+
create_ix = ixs[0]
161+
assert len(create_ix.accounts) == _EXPECTED_CREATE_V2_ACCOUNTS
162+
assert create_ix.accounts[9].pubkey == MAYHEM_PROGRAM_ID
163+
assert create_ix.accounts[10].pubkey == MAYHEM_GLOBAL_PARAMS
164+
assert create_ix.accounts[11].pubkey == MAYHEM_SOL_VAULT
165+
# Last byte = cashback true, second-to-last = mayhem true
166+
assert create_ix.data[-1:] == b"\x01"
167+
assert create_ix.data[-2:-1] == b"\x01"

tests/test_surfpool/test_launch.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Surfpool integration: token launch with mayhem/cashback flag combinations.
2+
3+
Tests launch_token() against a surfpool fork, covering the full matrix of
4+
is_mayhem x is_cashback (with and without initial buy), verifying that
5+
tokens are created on-chain successfully.
6+
7+
The IPFS metadata upload is mocked — the on-chain program does not
8+
validate URI content, so a fake URI is sufficient for testing the
9+
create_v2 instruction serialisation.
10+
"""
11+
12+
from unittest.mock import AsyncMock, patch
13+
14+
import pytest
15+
16+
from pumpfun_cli.core.info import get_token_info
17+
from pumpfun_cli.core.launch import launch_token
18+
19+
FAKE_URI = "https://example.com/test-metadata.json"
20+
21+
_UPLOAD_PATCH = patch(
22+
"pumpfun_cli.core.launch.upload_metadata",
23+
new_callable=AsyncMock,
24+
return_value=FAKE_URI,
25+
)
26+
27+
_LAUNCH_MATRIX = [
28+
pytest.param(False, False, None, id="default"),
29+
pytest.param(False, True, None, id="cashback"),
30+
pytest.param(True, False, None, id="mayhem"),
31+
pytest.param(True, True, None, id="mayhem+cashback"),
32+
pytest.param(False, False, 0.001, id="default+buy"),
33+
pytest.param(False, True, 0.001, id="cashback+buy"),
34+
pytest.param(True, False, 0.001, id="mayhem+buy"),
35+
pytest.param(True, True, 0.001, id="mayhem+cashback+buy"),
36+
]
37+
38+
39+
@pytest.mark.asyncio
40+
@pytest.mark.parametrize("is_mayhem,is_cashback,initial_buy_sol", _LAUNCH_MATRIX)
41+
@_UPLOAD_PATCH
42+
async def test_launch(
43+
_mock_upload,
44+
surfpool_rpc,
45+
funded_keypair,
46+
test_keystore,
47+
test_password,
48+
is_mayhem,
49+
is_cashback,
50+
initial_buy_sol,
51+
):
52+
"""Launch token with given mayhem/cashback/buy combination."""
53+
kwargs = {}
54+
if is_mayhem:
55+
kwargs["is_mayhem"] = True
56+
if is_cashback:
57+
kwargs["is_cashback"] = True
58+
if initial_buy_sol is not None:
59+
kwargs["initial_buy_sol"] = initial_buy_sol
60+
61+
result = await launch_token(
62+
rpc_url=surfpool_rpc,
63+
keystore_path=str(test_keystore),
64+
password=test_password,
65+
name="Test",
66+
ticker="TST",
67+
description="parametrized launch",
68+
**kwargs,
69+
)
70+
71+
assert "error" not in result, f"Launch failed: {result}"
72+
assert result["action"] == "launch"
73+
assert result["is_cashback"] is is_cashback
74+
assert result["signature"]
75+
76+
if initial_buy_sol is not None:
77+
assert result["initial_buy_sol"] == initial_buy_sol
78+
79+
info = await get_token_info(surfpool_rpc, result["mint"])
80+
assert "error" not in info, f"Token not found: {info}"
81+
assert info["graduated"] is False

0 commit comments

Comments
 (0)