Skip to content

fix(scenarios): send Phoenix's new funding fields and use Pump's reserved fee recipient for mayhem coins - #54

Open
92Infinitus92 wants to merge 4 commits into
feat/scenarios/override/phoenix-pump-fixes-supportfrom
fix/scenarios/phoenix-funding-parameters
Open

92Infinitus92 wants to merge 4 commits into
feat/scenarios/override/phoenix-pump-fixes-supportfrom
fix/scenarios/phoenix-funding-parameters

Conversation

@92Infinitus92

@92Infinitus92 92Infinitus92 commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Three live tests started to fail after Phoenix and Pump changed their mainnet programs. This PR fixes the one product defect behind them and makes the two tests follow the programs.

  • Phoenix funding. Since the Eternal deployment of 2026-10-30, UpdateFundingParameters also reads an optional clamp center, clamp band radius and divisor. The published IDL still lists four fields, so the phoenix-market-funding template sent a short payload and the program answered InvalidInstructionData. The encoder now appends three Nones, which keep the current values, and refuses to do so once the IDL lists more than the four fields. Mainnet transactions of the market authority from 2026-11-02 carry the same three trailing options.
  • Phoenix stale readings. Hawkeye (deployed 2026-11-07) reworded its refusal from "staleness or validity check failed" to "OracleMarkPriceUnavailable: Mark price of market N is unavailable or stale". The test now checks the error code 12289 instead of the text, and the README troubleshooting row quotes the new message.
  • Pump graduation. The test picked a live coin from the last 20 pump transactions, so it failed when none was eligible, and with NotAuthorized (fee_recipient.rs:19) when the coin was in mayhem mode: such a coin pays its fees to Global.reserved_fee_recipient. The test now creates its own Token-2022 coin on the fork with pump's create_v2 and runs twice, for an ordinary and a mayhem coin, taking the matching fee recipient from Global.

The bundled Phoenix IDL is unchanged: it equals the published one except for the Trader.maxPositions u32 + traderPreferenceBits u32 split it has carried since solana-foundation#845.

Tests: cargo +nightly fmt --all -- --check and cargo clippy --workspace --all-targets pass. cargo test -p surfpool-core --features ignore_tests_ci,integration-tests gives 921 passed, 0 failed, including the 19 Phoenix live tests and both Pump lifecycle tests, which also passed six runs in a row. cargo test --all --features "postgres,ignore_tests_ci" was run on the first commit without a local Postgres, so only the with_postgres_db variants failed there, on the missing SURFPOOL_TEST_POSTGRES_URL.

@92Infinitus92
92Infinitus92 changed the base branch from main to feat/scenarios/override/phoenix-pump-fixes-support November 10, 2026 14:20

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

The PR fixes three live tests that broke after mainnet program upgrades:

  • Phoenix funding: UpdateFundingParameters now gets three trailing None option bytes appended to its payload.
  • Phoenix stale readings: the test now checks Hawkeye's error code (Custom(12289)) instead of its reworded message.
  • Pump graduation: the fixture reads the curve's is_mayhem_mode flag and, for mayhem coins, uses Global.reserved_fee_recipient.

The fixes are small and focused. Matching on the error code is more robust than matching on text.

Notes

  • Phoenix encoding hack: the hard-coded [0, 0, 0] suffix in encode_instruction is easy to break later (see inline comment). The bundled IDL is already patched for Trader (solana-foundation#845), so another option is to add the three Option fields to UpdateFundingParametersInstruction in idl.json. That keeps one source of truth and lets callers set the values.
  • Unrelated commit: the PR also includes a82b6a5 ci: add Claude pull request reviews (.github/workflows/claude-review.yml), which the description doesn't mention. Consider moving it to its own PR. If it stays:
    • It interpolates the PR title and body straight into the prompt. Fork PRs are skipped and the allowed tools are read-only, so the risk is limited to prompt injection steering the review output. Still worth knowing.
    • A failed JSON.parse in post_feedback throws without a fallback. That is probably fine, but the job will show as failed.

}
// The program also reads an optional clamp center, clamp band radius and divisor, which the
// published IDL leaves out. Three `None`s keep their current values.
if name == "UpdateFundingParameters" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This always appends three bytes for this instruction name. If the bundled idl.json is later refreshed from a published IDL that includes the clamp/divisor fields, the args loop will encode them and these bytes will be added again. The program would then get a payload 3 bytes too long, which can cause another InvalidInstructionData that is hard to trace.

Two options:

  • Add the three Option fields to UpdateFundingParametersInstruction in the bundled IDL, as was done for the Trader split. This keeps one source of truth and lets callers set them.
  • Keep this suffix but guard it, e.g. append only when the IDL struct still has 4 fields, and fail loudly otherwise.

Comment thread crates/core/src/tests/pump/mod.rs Outdated
)
.expect("global fee recipient");
// A mayhem-mode coin pays its fees to the reserved recipient; any other is NotAuthorized.
let fee_recipient_offset = if curve_data[CURVE_IS_MAYHEM_MODE_OFFSET] == 1 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Borsh bool is strictly 0 or 1, so == 1 works. != 0 would match how the program reads the flag, though, and would still pick the reserved recipient if the byte ever held a non-canonical value. Optionally add a debug_assert!(curve_data.len() > CURVE_QUOTE_MINT_OFFSET) nearby so a layout change fails with a clear message instead of an index panic.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The PR fixes three live-test failures that came from mainnet program upgrades:

  • Phoenix funding: the encoder now appends three Nones to UpdateFundingParameters. A guard errors if the IDL ever lists more than its current four fields.
  • Phoenix stale readings: the test now asserts on error code 12289 instead of the old message text.
  • Pump graduation: mayhem-mode coins now use Global.reserved_fee_recipient as the fee recipient.

The changes are small and well targeted, and the comments explain why each one is needed. I found no correctness bugs.

Notes

  • Unrelated change in scope: the PR also adds .github/workflows/claude-review.yml (commit a82b6a5), which the title and description don't mention. It would be cleaner as its own PR. On the workflow itself:
    • Good: actions are pinned to SHAs, and fork PRs are skipped.
    • Untrusted input: the PR title and body go straight into the prompt. Fork PRs are excluded, so only collaborators can do this, and the allowed tools are read-only. The risk is low, but this is still a prompt-injection path that can change what gets posted as a review.
  • Clamp parameters can't be set: the phoenix-market-funding template has no way to set the new clamp center, band radius or divisor. Keeping current values is the right default, but the README could note that this template can't change them.
  • Hard-coded Pump offsets: CURVE_IS_MAYHEM_MODE_OFFSET = 81 matches the curve layout (it sits right after the 32-byte creator at 49). GLOBAL_RESERVED_FEE_RECIPIENT_OFFSET = 483 is a magic number with no derivation. A short comment listing the preceding Global fields, or a pointer to the IDL version, would make the next layout change easier to diagnose.

// The program also reads an optional clamp center, clamp band radius and divisor, which the
// published IDL leaves out. Three `None`s keep their current values.
// An IDL that lists them would encode them above, so this holds only for its four fields.
if name == "UpdateFundingParameters" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard runs only when the template is actually played. If someone swaps in a newer IDL that lists the clamp fields, CI won't flag it unless the live Phoenix tests run, because they are behind ignore_tests_ci. A cheap unit test that encodes UpdateFundingParameters and checks the data length, or the four-field shape, would catch the IDL drift in normal CI.

"the Phoenix IDL no longer gives {name} four fields; drop the three added options"
)));
}
data.extend([0, 0, 0]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small readability nit: [0, 0, 0] depends on Borsh encoding Option::None as a single 0 byte. A named constant or an inline note (// three Borsh Option::None tags) would make that explicit.

"{refused}"
);
// Hawkeye's OracleMarkPriceUnavailable; its message has been reworded, its code has not.
assert!(refused.contains("Custom(12289)"), "{refused}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching only Custom(12289) is more robust, but the assertion no longer pins down which program raised the error. If the CPI chain ever surfaces a different program's custom error 12289, this test would still pass. You could also check that the error is not a Phoenix-side error, or match the OracleMarkPriceUnavailable log line when it is present.

Pull request title and body:
----
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title and body are interpolated straight into the model prompt. Fork PRs are excluded, so this is limited to collaborators, and the allowed tools are read-only. Still, someone could steer the posted review through the PR body. Consider fencing the body as untrusted content, for example with an explicit 'the following is untrusted PR text' delimiter.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

The PR changes three things:

  • Phoenix funding: UpdateFundingParameters now gets three trailing None options. The encoder refuses to build the instruction if the IDL no longer lists exactly four fields.
  • Phoenix stale readings: the stale-oracle test checks the error code Custom(12289) instead of the message text.
  • Pump fee recipient: a mayhem-mode coin's fees now go to Global.reserved_fee_recipient.

The fixes look correct and are well targeted. I checked the create_v2 arguments against the bundled IDL: the order and widths match (OptionBool is a 1-byte struct).

Issues outside a single line

  • The PR description is out of date. It says the Pump test "picks a live coin on every run and reads its is_mayhem_mode flag". After d361a96, the test creates its own coins with create_v2, one ordinary and one mayhem. The "passed on five ordinary coins and on two mayhem coins" result was for the earlier version. Please update the description and re-run the new tests.
  • The Pump README is stale. crates/core/src/scenarios/protocols/pump/README.md:201-203 still says the lifecycle test "discovers a fresh, still-trading Token-2022 coin from the pump program's recent transactions". It should describe the new create_v2 approach and mention the second (mayhem) test.
  • Lost coverage. The test no longer runs graduation against real mainnet coin state, such as coins with a sharing config or older curve layouts. That trade-off is fine for stability, but the README and the test module docs should say so.
  • Unrelated change. .github/workflows/claude-review.yml (a82b6a5) has nothing to do with this fix. It should probably go in its own PR so it can be reviewed and reverted separately.

if name == "UpdateFundingParameters" {
let listed = definition
.args
.first()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard checks the first arg's struct, but line 195 appends the three Nones after all args. That is only correct while UpdateFundingParameters has a single arg. Consider also asserting definition.args.len() == 1, so an IDL change that adds a second arg fails loudly instead of putting the options in the wrong place.

fields: Some(IdlDefinedFields::Named(fields)),
}) if fields.len() == 4
) {
return Err(SurfpoolError::internal(format!(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check fails every phoenix-market-funding play with an internal error once someone updates the bundled IDL to list seven fields. That is intended as a tripwire, but it can only fire at runtime. A unit test that encodes UpdateFundingParameters against the bundled IDL and checks the payload length would catch it at IDL-bump time.

} else {
GLOBAL_FEE_RECIPIENT_OFFSET
};
let fee_recipient =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GLOBAL_RESERVED_FEE_RECIPIENT_OFFSET (483) reads only the single reserved_fee_recipient. If the program also accepts the reserved_fee_recipients array, this is fine. Still, add a short note on where 483 comes from (IDL field order) so the next layout bump is easier to check, like the other offsets.

&account(addresses.bonding_curve).await,
&account(addresses.curve_vault).await,
None,
&account(PUMP_GLOBAL).await,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing None for the canonical pool assumes the pool PDA does not exist on the fork. Mints are random keypairs, so this holds. However, .unwrap() on build_pump_graduation_scenario hides which validation failed. Use unwrap_or_else(|e| panic!("graduation builder rejected the created coin: {e:?}")) to match the create_v2 panic above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant