diff --git a/Cargo.lock b/Cargo.lock index 141bce739..4b0df2e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1212,6 +1212,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + [[package]] name = "base16ct" version = "0.2.0" @@ -3212,6 +3218,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "059c31d7d36c43fe39d89e55711858b4da8be7eb6dabac23c7289b1a19489406" +[[package]] +name = "fixed" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707070ccf8c4173548210893a0186e29c266901b71ed20cd9e2ca0193dfe95c3" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -5990,6 +6008,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pastey" version = "0.2.3" @@ -6110,6 +6134,35 @@ dependencies = [ "serde", ] +[[package]] +name = "phoenix-rise-accounts" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549b130422dfd976cef8d5f82394cbb5491d18355a77eaead7fa092b90844bdd" +dependencies = [ + "borsh 1.7.0", + "bytemuck", + "phoenix-rise-math", + "sha2-const-stable", + "solana-pubkey 3.0.0", + "thiserror 2.0.20", +] + +[[package]] +name = "phoenix-rise-math" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f6c10d1a8fd19901084fe96a17404e2946a0de9645ad788dc096fa986fed7a3" +dependencies = [ + "borsh 1.7.0", + "bytemuck", + "fixed", + "pastey 0.1.1", + "rust_decimal", + "sha2-const-stable", + "thiserror 2.0.20", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -7036,6 +7089,23 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh 1.7.0", + "bytes 1.12.1", + "num-traits", + "rand 0.8.6", + "rand 0.9.4", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -12124,6 +12194,8 @@ dependencies = [ "opentelemetry-prometheus", "opentelemetry_sdk 0.28.0", "p256", + "phoenix-rise-accounts", + "phoenix-rise-math", "prometheus", "reqwest 0.12.28", "scheduled-thread-pool", @@ -13448,6 +13520,7 @@ dependencies = [ "cfg-if 1.0.4", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -13620,7 +13693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66d967db7705dc29120bb6e8ce5b5a2e27734ed5976d1c904e95bd238d1c3c5a" dependencies = [ "bv", - "pastey", + "pastey 0.2.3", "proc-macro2", "quote", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index 8963c5889..1272a6a92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,8 @@ mustache = "0.9.0" notify = { version = "8.0.0", default-features = false } npm_rs = "1.0.0" once_cell = { version = "1.19.0", default-features = false } +phoenix-rise-accounts = "=0.3.4" +phoenix-rise-math = "=0.3.4" ratatui = { version = "0.29.0", features = [ "crossterm", ], default-features = false } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 48a18662e..4d8706dfd 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -112,6 +112,8 @@ opentelemetry_sdk = { version = "0.28", default-features = false, features = ["r opentelemetry-prometheus = { version = "0.28", default-features = false, optional = true } prometheus = { version = "0.13", default-features = false, optional = true } axum = { version = "0.8", default-features = false, features = ["tokio", "http1"], optional = true } +phoenix-rise-accounts = { workspace = true } +phoenix-rise-math = { workspace = true } [dev-dependencies] ed25519-dalek = "1.0.1" diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 10950e578..032de3c81 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -19,6 +19,7 @@ Protocols that are natively supported by Surfpool will have their IDLs included - **Kamino v1.x** – Lending protocol with Reserve liquidity, risk config, and Obligation health override templates - **Drift v2** - Perp and spot markets, user state, and global state - **Pump v1** - Bonding curve launchpad with curve reserve and global config override templates +- **Phoenix Eternal** - Perpetuals venue with trader collateral, mark-price, and spot/perp reference override templates, built through its MCP tools - **PumpSwap v1** - Constant-product AMM with pool state and global config override templates, including canonical pool derivation for migrated pump.fun coins For custom protocols, an IDL can be registered at runtime using the [`surfnet_registerIdl`](https://docs.surfpool.run/rpc/cheatcodes#surfnet-registeridl) RPC cheatcode. diff --git a/crates/core/src/scenarios/examples/phoenix-eternal-liquidation-cascade.json b/crates/core/src/scenarios/examples/phoenix-eternal-liquidation-cascade.json new file mode 100644 index 000000000..a41a9085e --- /dev/null +++ b/crates/core/src/scenarios/examples/phoenix-eternal-liquidation-cascade.json @@ -0,0 +1,41 @@ +{ + "id": "phoenix-eternal-liquidation-cascade-example", + "name": "Phoenix Eternal Liquidation Cascade - BTC", + "description": "Stresses one Phoenix Eternal Trader's collateral, then shocks the BTC mark price a slot later, so the trader crosses into liquidation territory while the run is live. Replace the trader pubkey with an account that exists on your fork; collateral can only be lowered, since an override cannot mint the vault backing it. Both values are decimal strings because they span the full i64/u64 range.", + "tags": [ + "phoenix-eternal", + "liquidation", + "multi-slot" + ], + "overrides": [ + { + "id": "trader-collateral-stress", + "templateId": "phoenix-trader-collateral-stress", + "label": "Phoenix Trader collateral stress", + "scenarioRelativeSlot": 0, + "enabled": true, + "fetchBeforeUse": true, + "account": { + "pubkey": "" + }, + "values": { + "quote_lot_collateral": "900000000" + } + }, + { + "id": "btc-direct-mark-shock", + "templateId": "phoenix-direct-mark-risk-shock", + "label": "Phoenix BTC direct mark risk shock", + "scenarioRelativeSlot": 1, + "enabled": true, + "fetchBeforeUse": true, + "account": { + "pubkey": "2nHGAaEw3D5dd4hVueaUNoygkQFmoeKqRQWnSPqSMFUC" + }, + "values": { + "symbol": "BTC", + "target_ticks": "1" + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/mod.rs b/crates/core/src/scenarios/protocols/mod.rs index 99f0b0967..e378abba7 100644 --- a/crates/core/src/scenarios/protocols/mod.rs +++ b/crates/core/src/scenarios/protocols/mod.rs @@ -1 +1,3 @@ +#[path = "phoenix-eternal/mod.rs"] +pub mod phoenix_eternal; pub mod pump; diff --git a/crates/core/src/scenarios/protocols/phoenix-eternal/README.md b/crates/core/src/scenarios/protocols/phoenix-eternal/README.md new file mode 100644 index 000000000..058616f00 --- /dev/null +++ b/crates/core/src/scenarios/protocols/phoenix-eternal/README.md @@ -0,0 +1,154 @@ +# Phoenix Eternal state preparation + +This integration prepares deterministic Phoenix Eternal account state. Bots remain responsible +for submitting trades, arbitrage, and liquidation transactions. + +## Supported state preparations + +| Goal | Template | State changed | +| --- | --- | --- | +| Collateral stress | `phoenix-trader-collateral-stress` | Exact signed quote-lot collateral on one validated Trader account | +| Direct mark shock | `phoenix-direct-mark-risk-shock` | Mark-price ticks for one market in the current PerpAssetMap | +| Spot/perp divergence | `phoenix-reference-price-divergence` | Cached spot and external-perp reference ticks while preserving the mark price | +| Liquidation cascade | Two validated overrides in one scenario | Trader collateral at slot 0, then a direct market mark shock at slot 1 | + +Tick inputs are Phoenix protocol ticks, not human-readable USD prices. Pass tick and collateral +values as decimal strings so values outside JavaScript's safe integer range remain exact. + +## Use from Studio + +1. Start an online Surfpool fork and open Studio. +2. Open **Scenario presets**, choose **Phoenix state**, then select the state goal. +3. For price scenarios, type the market symbol, such as `BTC`. For collateral stress, enter a + Phoenix Eternal Trader account. +4. Enter the target values and create the scenario. +5. Inspect the generated override, then press **Play** to activate it. +6. Send the bot, trade, arbitrage, or liquidation transaction you want to evaluate to the local + Surfnet RPC, normally `http://127.0.0.1:8899`. + +Play prepares account state; it does not submit a Phoenix transaction. The Transaction Inspector +remains empty until a client sends a transaction against the prepared state. + +## Where scenarios are built + +Phoenix has no HTTP routes of its own. The perp asset map address is a GlobalConfig field rather +than a PDA, but both market templates carry that address directly, so those two scenarios need no +tool. Only the collateral scenario needs one, because it must read and validate the live Trader +account first — the one tool listed below. Studio's preset calls that tool for collateral and posts +the market templates directly. + +Accounts are read through the Surfnet's own RPC, so local state wins and only missing accounts fall +back to the fork's datasource. A scenario therefore computes from the state you see, including +accounts you changed locally. + +## Use through MCP + +One tool, for the one scenario a template cannot express: + +| Tool | Required parameters | +| --- | --- | +| `create_phoenix_collateral_scenario` | `trader`, `targetQuoteLots` | + +It returns a Studio editor URL. The backend reads the live Trader account and refuses a target +the global vault does not back, which an LLM cannot bypass. + +The market scenarios need no tool: their templates carry the perp asset map address, so a client +fills in the values and posts the scenario to `/v1/scenarios`, which is what the Studio preset +does. The liquidation cascade is those two templates in one scenario at slots 0 and 1 — +[`phoenix-eternal-liquidation-cascade.json`](../../examples/phoenix-eternal-liquidation-cascade.json) +is a ready example to post or import. + +## What each preparation guarantees + +- Collateral stress changes only `quote_lot_collateral` in the selected Trader account. +- Direct mark shock changes only the selected market's mark-price ticks and the mark-price slot, + which is stamped with the slot the override materializes at so the program reads the new mark + as fresh. +- Reference divergence changes all five cached spot-reference ticks and all five cached + external-perp-reference ticks for the selected market, stamping their slots the same way. It + preserves the mark price, orderbook, spline liquidity, account length, and every unrelated byte. +- Liquidation cascade is the collateral and direct-mark overrides in one scenario: the collateral + override activates at slot 0 and the mark shock at slot 1. +- Collateral stress only lowers collateral when created through the MCP tool or the Studio + preset: the field is a claim on the global vault's real tokens, which an override cannot + create, so the builder refuses a raise; deposit first. A scenario posted straight to + `/v1/scenarios` bypasses that check by design — writers write what the scenario says — and + owns the consequences. +- Generated overrides leave `fetchBeforeUse` off: creation already read the accounts the plan + applies to, and a refresh at Play time would patch a different version of them. An override that + asks for a refresh anyway takes the shared refresh path, like every other protocol: the fork + serves the rest of the account graph lazily when the program reads it. + +These guarantees describe state preparation. Whether a particular transaction trades, arbitrages, +or liquidates depends on the transaction, the selected account, and the rest of the forked state. + +## Troubleshooting + +| Error or observation | Meaning | +| --- | --- | +| `Phoenix GlobalConfig ... was not found` or `Phoenix PerpAssetMap ... was not found` | Neither the local fork nor its datasource holds the Phoenix account graph. Start Surfpool against a datasource that carries the deployment. | +| `Phoenix market ... was not found` | The symbol is not in the live PerpAssetMap. Symbols are exact, such as `BTC`. | +| `Phoenix collateral stress can only lower collateral` | The target exceeds what the global vault backs. Send a real deposit to raise collateral. | +| `Expected a valid Phoenix Eternal Trader account` | The supplied account is not a decodable Phoenix Eternal Trader owned by the deployed program. | +| Scenario is green but the Transaction Inspector is empty | The state is active, but no client transaction has been sent yet. | +| A transaction does not produce the expected economic result | Confirm its accounts and instruction path consume the field changed by the selected preparation. | + +## Layout drift + +Phoenix is zero-copy, so decoding an account built by these tests can never disagree with the +decoder that built it. Drift is caught against live mainnet accounts instead: + +```sh +cargo test -p surfpool-core --features integration-tests tests::phoenix +``` + +Those tests resolve the live account graph, assert the typed invariants the program relies on, +and prove an override on a live account changes only its target bytes. Set +`SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. + +## Behavioral verification + +The behavioral test runs with the rest of the integration suite; there is nothing to install by +hand and no snapshots to keep current, since the deployed bytecode is fetched straight from the +chain: + +```sh +cargo test -p surfpool-core --features integration-tests tests::phoenix +``` + +It forks the live account graph, discovers a live Trader that carries collateral and a long +position, and loads the deployed Phoenix Eternal and Hawkeye bytecode straight from their +ProgramData accounts (`B5ayDaz9HegiNZqYeBtcFqfZBVSGwjB2CJgHshoSfMQg` and +`Gv1WgG864CQqF5vedJVbpnhpRpRbTW1A7SyARzSw9B4Y`), cached under the system temp directory as +`surfpool-phoenix-eternal.so` and `surfpool-phoenix-hawkeye.so`. Delete those files to pick up a +program upgrade; the cached bytecode is otherwise reused as-is. + +The raw material is selected, not arbitrary: it walks the program's Trader accounts for one that +carries collateral, and for the mark-shock and cascade tests, one that also holds a long position, +since a downward shock only threatens a long. The risk condition itself — a trader actually +falling below its maintenance requirement — is produced by the preparations, not found +pre-existing. The run fails loudly with `no eligible live candidate` rather than skipping if the +raw material is missing. It proves, through real Phoenix and Hawkeye execution: + +- collateral stress lands in the account the program reads, and lowers the collateral its risk + engine can count on; +- the two cascade stages arrive in order: the prepared collateral at the first slot, and the + mark the program reads shocked at the next; +- spot/perp divergence moves the cached reference away from the mark while the mark itself + stands; +- a second live market takes the same direct-mark preparation, so the templates are not + market-specific. + +Expected test summary: + +```text +test result: ok. 9 passed; 0 failed; 0 ignored +``` + +## Why there is no orderbook-consumption test + +An earlier version proved a bot could consume the prepared orderbook by signing a market sell as +the localnet fixture's taker. A live Trader's key is not ours to sign with, so that test went +with the fixture. What it covered is still covered: the market and spline accounts are never +written by any Phoenix preparation, which the byte-level assertions on live accounts enforce +directly, and Hawkeye reads the resulting book state in the behavioral test. diff --git a/crates/core/src/scenarios/protocols/phoenix-eternal/mod.rs b/crates/core/src/scenarios/protocols/phoenix-eternal/mod.rs new file mode 100644 index 000000000..a3a6d96c3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/phoenix-eternal/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/crates/core/src/scenarios/protocols/phoenix-eternal/v1/editor-schema.json b/crates/core/src/scenarios/protocols/phoenix-eternal/v1/editor-schema.json new file mode 100644 index 000000000..104546a01 --- /dev/null +++ b/crates/core/src/scenarios/protocols/phoenix-eternal/v1/editor-schema.json @@ -0,0 +1,74 @@ +{ + "address": "EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih", + "metadata": { + "name": "phoenix_eternal_write_mode_schema", + "version": "0.3.4", + "spec": "0.1.0", + "description": "Editor schema for specialized Phoenix scenario writers; not an account serialization contract" + }, + "instructions": [], + "accounts": [ + { + "name": "TraderCollateralOverride", + "discriminator": [41, 97, 73, 105, 110, 214, 112, 9] + }, + { + "name": "DirectMarkOverride", + "discriminator": [142, 54, 115, 223, 124, 239, 108, 38] + }, + { + "name": "ReferencePriceOverride", + "discriminator": [210, 123, 24, 82, 132, 17, 53, 94] + } + ], + "types": [ + { + "name": "TraderCollateralOverride", + "type": { + "kind": "struct", + "fields": [ + { + "name": "quote_lot_collateral", + "type": "string" + } + ] + } + }, + { + "name": "DirectMarkOverride", + "type": { + "kind": "struct", + "fields": [ + { + "name": "symbol", + "type": "string" + }, + { + "name": "target_ticks", + "type": "string" + } + ] + } + }, + { + "name": "ReferencePriceOverride", + "type": { + "kind": "struct", + "fields": [ + { + "name": "symbol", + "type": "string" + }, + { + "name": "spot_ticks", + "type": "string" + }, + { + "name": "perp_ticks", + "type": "string" + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/phoenix-eternal/v1/mod.rs b/crates/core/src/scenarios/protocols/phoenix-eternal/v1/mod.rs new file mode 100644 index 000000000..d7e8cec45 --- /dev/null +++ b/crates/core/src/scenarios/protocols/phoenix-eternal/v1/mod.rs @@ -0,0 +1 @@ +pub mod state_builder; diff --git a/crates/core/src/scenarios/protocols/phoenix-eternal/v1/overrides.yaml b/crates/core/src/scenarios/protocols/phoenix-eternal/v1/overrides.yaml new file mode 100644 index 000000000..022181bfa --- /dev/null +++ b/crates/core/src/scenarios/protocols/phoenix-eternal/v1/overrides.yaml @@ -0,0 +1,312 @@ +protocol: Phoenix Eternal +version: v1 +account_type: TraderCollateralOverride +idl_file_path: editor-schema.json + +tags: + - perpetuals + - collateral + - risk + +constants: + # The perp markets listed on the live PerpAssetMap (captured 2026-08-31). A newly + # listed market reaches this catalog on the next update; until then the raw + # scenario API accepts any symbol and the writer validates it against live state. + market_symbol: + label: Market + description: Select a Phoenix Eternal perp market + options: + - id: aapl + label: AAPL + value: AAPL + - id: aave + label: AAVE + value: AAVE + - id: ada + label: ADA + value: ADA + - id: amat + label: AMAT + value: AMAT + - id: amd + label: AMD + value: AMD + - id: amzn + label: AMZN + value: AMZN + - id: ansem + label: ANSEM + value: ANSEM + - id: arm + label: ARM + value: ARM + - id: asml + label: ASML + value: ASML + - id: avgo + label: AVGO + value: AVGO + - id: baba + label: BABA + value: BABA + - id: bnb + label: BNB + value: BNB + - id: btc + label: BTC + value: BTC + - id: cbrs + label: CBRS + value: CBRS + - id: chip + label: CHIP + value: CHIP + - id: coin + label: COIN + value: COIN + - id: copper + label: COPPER + value: COPPER + - id: crcl + label: CRCL + value: CRCL + - id: crv + label: CRV + value: CRV + - id: crwd + label: CRWD + value: CRWD + - id: crwv + label: CRWV + value: CRWV + - id: doge + label: DOGE + value: DOGE + - id: ena + label: ENA + value: ENA + - id: eth + label: ETH + value: ETH + - id: fartcoin + label: FARTCOIN + value: FARTCOIN + - id: fet + label: FET + value: FET + - id: gold + label: GOLD + value: GOLD + - id: googl + label: GOOGL + value: GOOGL + - id: hood + label: HOOD + value: HOOD + - id: hype + label: HYPE + value: HYPE + - id: intc + label: INTC + value: INTC + - id: iren + label: IREN + value: IREN + - id: jto + label: JTO + value: JTO + - id: jup + label: JUP + value: JUP + - id: link + label: LINK + value: LINK + - id: lit + label: LIT + value: LIT + - id: lly + label: LLY + value: LLY + - id: mega + label: MEGA + value: MEGA + - id: met + label: MET + value: MET + - id: meta + label: META + value: META + - id: mon + label: MON + value: MON + - id: morpho + label: MORPHO + value: MORPHO + - id: mrna + label: MRNA + value: MRNA + - id: mrvl + label: MRVL + value: MRVL + - id: msft + label: MSFT + value: MSFT + - id: mstr + label: MSTR + value: MSTR + - id: mu + label: MU + value: MU + - id: nbis + label: NBIS + value: NBIS + - id: near + label: NEAR + value: NEAR + - id: net + label: NET + value: NET + - id: nflx + label: NFLX + value: NFLX + - id: nvda + label: NVDA + value: NVDA + - id: ondo + label: ONDO + value: ONDO + - id: pltr + label: PLTR + value: PLTR + - id: pump + label: PUMP + value: PUMP + - id: qcom + label: QCOM + value: QCOM + - id: render + label: RENDER + value: RENDER + - id: silver + label: SILVER + value: SILVER + - id: skhy + label: SKHY + value: SKHY + - id: skr + label: SKR + value: SKR + - id: sndk + label: SNDK + value: SNDK + - id: sol + label: SOL + value: SOL + - id: spcx + label: SPCX + value: SPCX + - id: sui + label: SUI + value: SUI + - id: tao + label: TAO + value: TAO + - id: trx + label: TRX + value: TRX + - id: tsla + label: TSLA + value: TSLA + - id: tsm + label: TSM + value: TSM + - id: virtual + label: VIRTUAL + value: VIRTUAL + - id: vvv + label: VVV + value: VVV + - id: wld + label: WLD + value: WLD + - id: wtioil + label: WTIOIL + value: WTIOIL + - id: xlm + label: XLM + value: XLM + - id: xpl + label: XPL + value: XPL + - id: xrp + label: XRP + value: XRP + - id: zec + label: ZEC + value: ZEC + +templates: + - id: phoenix-trader-collateral-stress + name: Phoenix Trader Collateral Stress + description: Set exact signed quote-lot collateral on one validated Phoenix Eternal Trader account. + idl_account_name: TraderCollateralOverride + properties: + - path: quote_lot_collateral + label: Target Collateral in Quote Lots + description: Exact signed quote-lot collateral encoded as a decimal string + llm_context: | + This prepares collateral stress state. It does not guarantee liquidation and does not execute liquidation. + For a liquidation cascade, pair it with phoenix-direct-mark-risk-shock at the next slot. Keep override labels short. + Pass quote_lot_collateral as a decimal string. Builder-created scenarios leave fetchBeforeUse off; the value applies to the local fork state. + address: + type: pubkey + + - id: phoenix-direct-mark-risk-shock + name: Phoenix Direct Mark Risk Shock + description: Set exact mark-price ticks for one market in a validated Phoenix Eternal PerpAssetMap. + idl_account_name: DirectMarkOverride + properties: + - path: symbol + type: constant_ref + label: Market Symbol + constant: market_symbol + description: Exact Phoenix market symbol + - path: target_ticks + label: Target Mark Ticks + description: Exact unsigned mark-price ticks encoded as a decimal string + llm_context: | + This directly changes Phoenix risk state, setting the mark ticks and stamping the mark slot with the materialization slot. It does not simulate an oracle, orderbook, spline, or trade. + Pass target_ticks as a decimal string. The account is the singleton PerpAssetMap, so this template carries its address. + Set fetchBeforeUse: true so the live asset map is forked first; use false only for a later + override that builds on state an earlier one prepared in the same scenario. + # The singleton named by GlobalConfig's perp_asset_map field, which only moves if the + # protocol migrates the map. + address: + type: pubkey + value: 2nHGAaEw3D5dd4hVueaUNoygkQFmoeKqRQWnSPqSMFUC + + - id: phoenix-reference-price-divergence + name: Phoenix Spot/Perp Reference Divergence + description: Set independent spot and external-perp reference ticks while preserving the current mark price. + idl_account_name: ReferencePriceOverride + properties: + - path: symbol + type: constant_ref + label: Market Symbol + constant: market_symbol + description: Exact Phoenix market symbol + - path: spot_ticks + label: Spot Reference Ticks + description: Exact spot reference ticks encoded as a decimal string + - path: perp_ticks + label: Perp Reference Ticks + description: Exact external-perp reference ticks encoded as a decimal string + llm_context: | + This prepares spot/perp reference and mark divergence state for bots to act on, setting the cached reference ticks and stamping their slots with the materialization slot. + It preserves the current mark, orderbook, and spline liquidity and does not execute a trade. + Pass both tick values as decimal strings. The account is the singleton PerpAssetMap, so this template carries its address. + Set fetchBeforeUse: true so the live asset map is forked first; use false only for a later + override that builds on state an earlier one prepared in the same scenario. + address: + type: pubkey + value: 2nHGAaEw3D5dd4hVueaUNoygkQFmoeKqRQWnSPqSMFUC diff --git a/crates/core/src/scenarios/protocols/phoenix-eternal/v1/state_builder.rs b/crates/core/src/scenarios/protocols/phoenix-eternal/v1/state_builder.rs new file mode 100644 index 000000000..8a65dece4 --- /dev/null +++ b/crates/core/src/scenarios/protocols/phoenix-eternal/v1/state_builder.rs @@ -0,0 +1,1314 @@ +use core::mem::size_of; +use std::collections::HashMap; + +use phoenix_rise_accounts::{ + PhoenixAccountDecodeError, + global_config::GlobalConfig, + perp_asset_map::{ + MarkPrice, PerpAssetMap, PerpPriceComponent, PriceComponent, SpotPriceComponent, + TicksAtSlot, + }, + trader::{Trader, TraderHeader}, +}; +use phoenix_rise_math::quantities::{SignedQuoteLots, Ticks}; +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; +use thiserror::Error; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +pub const PHOENIX_ETERNAL_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih"); +pub const PHOENIX_GLOBAL_CONFIG: Pubkey = + Pubkey::from_str_const("2zskx2iyCvb6Stg7RBZkt1f6MrF4dpYtMG3yMvKwqtUZ"); + +const TRADER_HEADER_LEN: usize = size_of::(); +const COLLATERAL_BYTE_RANGE: core::ops::Range = 88..96; +const COLLATERAL_FIELD: &str = "quote_lot_collateral"; +const DIRECT_MARK_SYMBOL_FIELD: &str = "symbol"; +const DIRECT_MARK_TICKS_FIELD: &str = "target_ticks"; +const REFERENCE_SPOT_TICKS_FIELD: &str = "spot_ticks"; +const REFERENCE_PERP_TICKS_FIELD: &str = "perp_ticks"; +const PREPARATION_SLOT: u64 = 0; +const MARK_PRICE_RANGE: core::ops::Range = 16..32; +const MARK_PRICE_TICKS_RANGE: core::ops::Range = 24..32; + +#[derive(Clone, Debug, PartialEq)] +pub struct PhoenixCollateralPreparation { + pub scenario: Scenario, + pub trader: Pubkey, + pub target_quote_lots: i64, +} + +pub fn phoenix_market_symbols( + perp_asset_map: Pubkey, + account: &Account, +) -> SurfpoolResult> { + if account.owner != PHOENIX_ETERNAL_PROGRAM_ID { + return Err(SurfpoolError::invalid_account_owner( + perp_asset_map, + None::, + )); + } + + let map = PerpAssetMap::try_from_account_bytes(&account.data).map_err(|error| { + SurfpoolError::invalid_account_data( + perp_asset_map, + "Expected a valid Phoenix Eternal PerpAssetMap account", + Some(error), + ) + })?; + let mut symbols = map + .iter() + .map(|entry| entry.map(|entry| entry.symbol.as_str().to_string())) + .collect::, _>>() + .map_err(|error| { + SurfpoolError::invalid_account_data( + perp_asset_map, + "Expected a valid Phoenix Eternal PerpAssetMap account", + Some(error), + ) + })?; + symbols.sort_unstable(); + + Ok(symbols) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PhoenixTraderPatchError { + #[error("expected Phoenix Eternal owner, got {actual}")] + InvalidOwner { actual: Pubkey }, + #[error("invalid Phoenix Trader account: {0}")] + InvalidTrader(#[from] PhoenixAccountDecodeError), + #[error("collateral patch changed byte {offset} outside the collateral field")] + UnexpectedByteChange { offset: usize }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PhoenixDirectMarkPatchError { + #[error("expected Phoenix Eternal owner, got {actual}")] + InvalidOwner { actual: Pubkey }, + #[error("invalid Phoenix PerpAssetMap account: {0}")] + InvalidPerpAssetMap(#[from] PhoenixAccountDecodeError), + #[error("Phoenix market {symbol} was not found")] + MarketNotFound { symbol: String }, + #[error("target mark ticks {ticks} exceed the Phoenix u32 tick range")] + InvalidTicks { ticks: u64 }, + #[error("selected Phoenix market metadata does not occur exactly once")] + InvalidMetadataLocation, + #[error("direct mark patch changed byte {offset} outside the mark tick field")] + UnexpectedByteChange { offset: usize }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PhoenixReferencePricePatchError { + #[error("expected Phoenix Eternal owner, got {actual}")] + InvalidOwner { actual: Pubkey }, + #[error("invalid Phoenix PerpAssetMap account: {0}")] + InvalidPerpAssetMap(#[from] PhoenixAccountDecodeError), + #[error("Phoenix market {symbol} was not found")] + MarketNotFound { symbol: String }, + #[error("reference ticks {ticks} exceed the Phoenix u32 tick range")] + InvalidTicks { ticks: u64 }, + #[error("selected Phoenix market metadata does not occur exactly once")] + InvalidMetadataLocation, + #[error("reference-price patch changed byte {offset} outside the spot/perp fields")] + UnexpectedByteChange { offset: usize }, +} + +/// The offset of the first byte a patch changed outside the ranges it was +/// allowed to touch, if there is one. +fn changed_byte_outside( + original: &[u8], + patched: &[u8], + allowed: &[core::ops::Range], +) -> Option { + original + .iter() + .zip(patched) + .enumerate() + .find(|(offset, (before, after))| { + before != after && !allowed.iter().any(|range| range.contains(offset)) + }) + .map(|(offset, _)| offset) +} + +pub fn patch_trader_collateral( + owner: &Pubkey, + data: &[u8], + target_quote_lots: i64, +) -> Result, PhoenixTraderPatchError> { + if owner != &PHOENIX_ETERNAL_PROGRAM_ID { + return Err(PhoenixTraderPatchError::InvalidOwner { actual: *owner }); + } + + validate_trader(data)?; + + let mut header = TraderHeader::try_read_from_account_bytes(data)?; + header.trader_state.quote_lot_collateral = SignedQuoteLots::new(target_quote_lots); + + let mut patched_data = data.to_vec(); + patched_data[..TRADER_HEADER_LEN].copy_from_slice(bytemuck::bytes_of(&header)); + + validate_trader(&patched_data)?; + + if let Some(offset) = changed_byte_outside(data, &patched_data, &[COLLATERAL_BYTE_RANGE]) { + return Err(PhoenixTraderPatchError::UnexpectedByteChange { offset }); + } + + Ok(patched_data) +} + +pub fn patch_direct_mark( + owner: &Pubkey, + data: &[u8], + symbol: &str, + target_ticks: u64, +) -> Result, PhoenixDirectMarkPatchError> { + patch_direct_mark_inner(owner, data, symbol, target_ticks, None) +} + +fn patch_direct_mark_at_slot( + owner: &Pubkey, + data: &[u8], + symbol: &str, + target_ticks: u64, + mark_slot: u64, +) -> Result, PhoenixDirectMarkPatchError> { + patch_direct_mark_inner(owner, data, symbol, target_ticks, Some(mark_slot)) +} + +fn patch_direct_mark_inner( + owner: &Pubkey, + data: &[u8], + symbol: &str, + target_ticks: u64, + mark_slot: Option, +) -> Result, PhoenixDirectMarkPatchError> { + if owner != &PHOENIX_ETERNAL_PROGRAM_ID { + return Err(PhoenixDirectMarkPatchError::InvalidOwner { actual: *owner }); + } + let target_ticks = Ticks::new_checked(target_ticks).map_err(|_| { + PhoenixDirectMarkPatchError::InvalidTicks { + ticks: target_ticks, + } + })?; + let map = PerpAssetMap::try_from_account_bytes(data)?; + let entry = + map.find_by_symbol(symbol)? + .ok_or_else(|| PhoenixDirectMarkPatchError::MarketNotFound { + symbol: symbol.to_string(), + })?; + let metadata_bytes = entry.metadata.as_bytes(); + let metadata_offset = unique_subslice_offset(data, metadata_bytes) + .ok_or(PhoenixDirectMarkPatchError::InvalidMetadataLocation)?; + let price_len = size_of::(); + let mut price = bytemuck::pod_read_unaligned::(&metadata_bytes[..price_len]); + if let Some(mark_slot) = mark_slot { + price.mark_price.price.slot = mark_slot; + } + price.mark_price.price.ticks = target_ticks; + + let mut patched_data = data.to_vec(); + patched_data[metadata_offset..metadata_offset + price_len] + .copy_from_slice(bytemuck::bytes_of(&price)); + PerpAssetMap::try_from_account_bytes(&patched_data)?; + + let mark_range = if mark_slot.is_some() { + MARK_PRICE_RANGE + } else { + MARK_PRICE_TICKS_RANGE + }; + let allowed_range = metadata_offset + mark_range.start..metadata_offset + mark_range.end; + if let Some(offset) = changed_byte_outside(data, &patched_data, &[allowed_range]) { + return Err(PhoenixDirectMarkPatchError::UnexpectedByteChange { offset }); + } + + Ok(patched_data) +} + +pub fn patch_reference_prices( + owner: &Pubkey, + data: &[u8], + symbol: &str, + spot_ticks: u64, + perp_ticks: u64, +) -> Result, PhoenixReferencePricePatchError> { + patch_reference_prices_inner(owner, data, symbol, spot_ticks, perp_ticks, None) +} + +fn patch_reference_prices_at_slot( + owner: &Pubkey, + data: &[u8], + symbol: &str, + spot_ticks: u64, + perp_ticks: u64, + reference_slot: u64, +) -> Result, PhoenixReferencePricePatchError> { + patch_reference_prices_inner( + owner, + data, + symbol, + spot_ticks, + perp_ticks, + Some(reference_slot), + ) +} + +fn patch_reference_prices_inner( + owner: &Pubkey, + data: &[u8], + symbol: &str, + spot_ticks: u64, + perp_ticks: u64, + reference_slot: Option, +) -> Result, PhoenixReferencePricePatchError> { + if owner != &PHOENIX_ETERNAL_PROGRAM_ID { + return Err(PhoenixReferencePricePatchError::InvalidOwner { actual: *owner }); + } + let spot_ticks = Ticks::new_checked(spot_ticks) + .map_err(|_| PhoenixReferencePricePatchError::InvalidTicks { ticks: spot_ticks })?; + let perp_ticks = Ticks::new_checked(perp_ticks) + .map_err(|_| PhoenixReferencePricePatchError::InvalidTicks { ticks: perp_ticks })?; + let map = PerpAssetMap::try_from_account_bytes(data)?; + let entry = map.find_by_symbol(symbol)?.ok_or_else(|| { + PhoenixReferencePricePatchError::MarketNotFound { + symbol: symbol.to_string(), + } + })?; + let metadata_bytes = entry.metadata.as_bytes(); + let metadata_offset = unique_subslice_offset(data, metadata_bytes) + .ok_or(PhoenixReferencePricePatchError::InvalidMetadataLocation)?; + let price_len = size_of::(); + let mut price = bytemuck::pod_read_unaligned::(&metadata_bytes[..price_len]); + for value in &mut price + .mark_price + .spot_price_component + .last_exchange_spot_price + { + if let Some(reference_slot) = reference_slot { + value.slot = reference_slot; + } + value.ticks = spot_ticks; + } + for value in &mut price + .mark_price + .perp_price_component + .last_exchange_perp_price + { + if let Some(reference_slot) = reference_slot { + value.slot = reference_slot; + } + value.ticks = perp_ticks; + } + + let mut patched_data = data.to_vec(); + patched_data[metadata_offset..metadata_offset + price_len] + .copy_from_slice(bytemuck::bytes_of(&price)); + PerpAssetMap::try_from_account_bytes(&patched_data)?; + + let allowed_ranges = reference_value_ranges(metadata_offset, reference_slot.is_some()); + if let Some(offset) = changed_byte_outside(data, &patched_data, &allowed_ranges) { + return Err(PhoenixReferencePricePatchError::UnexpectedByteChange { offset }); + } + + Ok(patched_data) +} + +fn reference_value_ranges( + metadata_offset: usize, + include_slots: bool, +) -> Vec> { + let mark_offset = core::mem::offset_of!(PriceComponent, mark_price); + let ticks_offset = core::mem::offset_of!(TicksAtSlot, ticks); + let spot_offset = mark_offset + + core::mem::offset_of!(MarkPrice, spot_price_component) + + core::mem::offset_of!(SpotPriceComponent, last_exchange_spot_price); + let perp_offset = mark_offset + + core::mem::offset_of!(MarkPrice, perp_price_component) + + core::mem::offset_of!(PerpPriceComponent, last_exchange_perp_price); + [spot_offset, perp_offset] + .into_iter() + .flat_map(|component_offset| { + (0..5).map(move |index| { + let value_start = + metadata_offset + component_offset + index * size_of::(); + if include_slots { + value_start..value_start + size_of::() + } else { + let ticks_start = value_start + ticks_offset; + ticks_start..ticks_start + size_of::() + } + }) + }) + .collect() +} + +fn unique_subslice_offset(data: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > data.len() { + return None; + } + + let mut match_offset = None; + let mut search_start = 0; + while search_start + needle.len() <= data.len() { + let Some(relative_offset) = data[search_start..=data.len() - needle.len()] + .iter() + .position(|byte| *byte == needle[0]) + else { + break; + }; + let offset = search_start + relative_offset; + if data[offset..].starts_with(needle) { + if match_offset.is_some() { + return None; + } + match_offset = Some(offset); + } + search_start = offset + 1; + } + match_offset +} + +/// Routes a Phoenix-owned account's override to the right typed patcher by the +/// value group it carries; the patchers themselves enforce the account type. +pub fn forge_phoenix_override( + account_pubkey: &Pubkey, + account: &Account, + account_values: &HashMap, + materialization_slot: u64, +) -> SurfpoolResult> { + let wants_collateral = account_values.contains_key(COLLATERAL_FIELD); + let wants_direct_mark = account_values.contains_key(DIRECT_MARK_TICKS_FIELD); + let wants_reference = account_values.contains_key(REFERENCE_SPOT_TICKS_FIELD) + || account_values.contains_key(REFERENCE_PERP_TICKS_FIELD); + + match (wants_collateral, wants_direct_mark, wants_reference) { + (true, false, false) => { + forge_trader_collateral_override(account_pubkey, account, account_values) + } + (false, true, false) => forge_direct_mark_override( + account_pubkey, + account, + account_values, + materialization_slot, + ), + (false, false, true) => forge_reference_price_override( + account_pubkey, + account, + account_values, + materialization_slot, + ), + _ => Err(SurfpoolError::internal( + "Phoenix overrides accept exactly one value group: quote_lot_collateral, \ + symbol + target_ticks, or symbol + spot_ticks + perp_ticks", + )), + } +} + +pub fn forge_trader_collateral_override( + account_pubkey: &Pubkey, + account: &Account, + account_values: &HashMap, +) -> SurfpoolResult> { + if account_values.len() != 1 || !account_values.contains_key(COLLATERAL_FIELD) { + return Err(SurfpoolError::internal( + "Phoenix Trader collateral overrides accept only quote_lot_collateral", + )); + } + + let target_quote_lots = account_values[COLLATERAL_FIELD] + .as_str() + .ok_or_else(invalid_quote_lot_collateral) + .and_then(parse_quote_lot_collateral)?; + + patch_trader_collateral(&account.owner, &account.data, target_quote_lots).map_err(|error| { + match error { + PhoenixTraderPatchError::InvalidOwner { .. } => { + SurfpoolError::invalid_account_owner(account_pubkey, Some(error)) + } + _ => SurfpoolError::invalid_account_data( + account_pubkey, + "Expected a valid Phoenix Eternal Trader account", + Some(error), + ), + } + }) +} + +pub fn forge_direct_mark_override( + account_pubkey: &Pubkey, + account: &Account, + account_values: &HashMap, + materialization_slot: u64, +) -> SurfpoolResult> { + if account_values.len() != 2 + || !account_values.contains_key(DIRECT_MARK_SYMBOL_FIELD) + || !account_values.contains_key(DIRECT_MARK_TICKS_FIELD) + { + return Err(SurfpoolError::internal( + "Phoenix direct mark overrides accept only symbol and target_ticks", + )); + } + + let symbol = account_values[DIRECT_MARK_SYMBOL_FIELD] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| SurfpoolError::internal("symbol must be a non-empty string"))?; + let target_ticks = account_values[DIRECT_MARK_TICKS_FIELD] + .as_str() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + SurfpoolError::internal( + "target_ticks must be an unsigned 64-bit integer encoded as a string", + ) + })?; + + patch_direct_mark_at_slot( + &account.owner, + &account.data, + symbol, + target_ticks, + materialization_slot, + ) + .map_err(|error| match error { + PhoenixDirectMarkPatchError::InvalidOwner { .. } => { + SurfpoolError::invalid_account_owner(account_pubkey, Some(error)) + } + _ => SurfpoolError::invalid_account_data( + account_pubkey, + "Expected a valid Phoenix Eternal PerpAssetMap account", + Some(error), + ), + }) +} + +pub fn forge_reference_price_override( + account_pubkey: &Pubkey, + account: &Account, + account_values: &HashMap, + materialization_slot: u64, +) -> SurfpoolResult> { + if account_values.len() != 3 + || !account_values.contains_key(DIRECT_MARK_SYMBOL_FIELD) + || !account_values.contains_key(REFERENCE_SPOT_TICKS_FIELD) + || !account_values.contains_key(REFERENCE_PERP_TICKS_FIELD) + { + return Err(SurfpoolError::internal( + "Phoenix reference-price overrides accept only symbol, spot_ticks, and perp_ticks", + )); + } + + let symbol = account_values[DIRECT_MARK_SYMBOL_FIELD] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| SurfpoolError::internal("symbol must be a non-empty string"))?; + let spot_ticks = parse_unsigned_ticks( + &account_values[REFERENCE_SPOT_TICKS_FIELD], + REFERENCE_SPOT_TICKS_FIELD, + )?; + let perp_ticks = parse_unsigned_ticks( + &account_values[REFERENCE_PERP_TICKS_FIELD], + REFERENCE_PERP_TICKS_FIELD, + )?; + + patch_reference_prices_at_slot( + &account.owner, + &account.data, + symbol, + spot_ticks, + perp_ticks, + materialization_slot, + ) + .map_err(|error| match error { + PhoenixReferencePricePatchError::InvalidOwner { .. } => { + SurfpoolError::invalid_account_owner(account_pubkey, Some(error)) + } + _ => SurfpoolError::invalid_account_data( + account_pubkey, + "Expected a valid Phoenix Eternal PerpAssetMap account", + Some(error), + ), + }) +} + +pub fn build_phoenix_collateral_scenario( + trader: Pubkey, + trader_account: &Account, + target_quote_lots: &str, +) -> SurfpoolResult { + let target_quote_lots = parse_quote_lot_collateral(target_quote_lots)?; + patch_trader_collateral( + &trader_account.owner, + &trader_account.data, + target_quote_lots, + ) + .map_err(|error| match error { + PhoenixTraderPatchError::InvalidOwner { .. } => { + SurfpoolError::invalid_account_owner(trader, Some(error)) + } + _ => SurfpoolError::invalid_account_data( + trader, + "Expected a valid Phoenix Eternal Trader account", + Some(error), + ), + })?; + + // Collateral is a claim on the global vault's real tokens. An override cannot + // mint that backing, so raising it would let withdrawals draw on balances that + // are not there; a real DepositFunds transaction is the way up. + let current_quote_lots = TraderHeader::try_read_from_account_bytes(&trader_account.data) + .map(|header| header.trader_state.quote_lot_collateral.as_inner()) + .map_err(|error| { + SurfpoolError::invalid_account_data( + trader, + "Expected a valid Phoenix Eternal Trader account", + Some(error), + ) + })?; + if target_quote_lots > current_quote_lots { + return Err(SurfpoolError::internal(format!( + "Phoenix collateral stress can only lower collateral: {current_quote_lots} quote lots \ + are backed by the global vault, {target_quote_lots} would not be. Deposit first to \ + raise it." + ))); + } + + let template = TemplateRegistry::new() + .get("phoenix-trader-collateral-stress") + .cloned() + .ok_or_else(|| SurfpoolError::internal("Phoenix collateral template is unavailable"))?; + let values = HashMap::from([( + COLLATERAL_FIELD.to_string(), + serde_json::json!(target_quote_lots.to_string()), + )]); + let collateral_override = OverrideInstance::new( + template.id, + PREPARATION_SLOT, + AccountAddress::Pubkey(trader.to_string()), + ) + .with_values(values) + .with_label("Phoenix Trader collateral stress".to_string()); + + let mut scenario = Scenario::new( + "Phoenix Trader Collateral Stress".to_string(), + "Set exact signed quote-lot collateral on a validated Phoenix Eternal Trader account." + .to_string(), + ); + scenario.tags = vec![ + "phoenix-eternal".to_string(), + "collateral".to_string(), + "risk".to_string(), + ]; + scenario.add_override(collateral_override); + + Ok(PhoenixCollateralPreparation { + scenario, + trader, + target_quote_lots, + }) +} + +pub fn phoenix_perp_asset_map_address(global_account: &Account) -> SurfpoolResult { + if global_account.owner != PHOENIX_ETERNAL_PROGRAM_ID { + return Err(SurfpoolError::invalid_account_owner( + PHOENIX_GLOBAL_CONFIG, + Some("expected Phoenix Eternal owner"), + )); + } + let global = GlobalConfig::try_from_account_bytes(&global_account.data).map_err(|error| { + SurfpoolError::invalid_account_data( + PHOENIX_GLOBAL_CONFIG, + "Expected a valid Phoenix Eternal GlobalConfig account", + Some(error), + ) + })?; + if Pubkey::new_from_array(global.account_key()) != PHOENIX_GLOBAL_CONFIG { + return Err(SurfpoolError::invalid_account_data( + PHOENIX_GLOBAL_CONFIG, + "GlobalConfig account_key does not match its address", + None::, + )); + } + Ok(Pubkey::new_from_array(global.perp_asset_map_key())) +} + +fn parse_unsigned_ticks(value: &serde_json::Value, field: &str) -> SurfpoolResult { + value + .as_str() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + SurfpoolError::internal(format!( + "{field} must be an unsigned 64-bit integer encoded as a string" + )) + }) +} + +fn parse_quote_lot_collateral(value: &str) -> SurfpoolResult { + value + .parse::() + .map_err(|_| invalid_quote_lot_collateral()) +} + +fn invalid_quote_lot_collateral() -> SurfpoolError { + SurfpoolError::internal( + "quote_lot_collateral must be a signed 64-bit integer encoded as a string", + ) +} + +fn validate_trader(data: &[u8]) -> Result<(), PhoenixAccountDecodeError> { + let mut aligned_words = vec![0_u64; data.len().div_ceil(size_of::())]; + let aligned_bytes = bytemuck::cast_slice_mut::(&mut aligned_words); + aligned_bytes[..data.len()].copy_from_slice(data); + Trader::try_from_account_bytes(&aligned_bytes[..data.len()])?; + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use base64::{Engine, prelude::BASE64_STANDARD}; + use phoenix_rise_accounts::{PhoenixAccount, trader::TraderHeader}; + use solana_account::Account; + + use super::*; + + const POSITION_MAP_PREFIX_LEN: usize = 16; + const POSITION_ENTRY_LEN: usize = 40; + const PERP_ASSET_MAP_LEN: usize = 1_622_064; + const SOL_PERP_ASSET_MAP_PREFIX_B64: &str = "jjZz33zvbCYBAAAAAAAAAF+FshYAAAAALAAAAAAAAAAtAAAAAQAAAAAEAAAAAAAAU09MAAAAAAAAAAAAAAAAAJIjVQMAAAAAK+yGGQAAAAAr7IYZAAAAABccAAAAAAAAK+yGGQAAAAAXHAAAAAAAACXshhkAAAAAFxwAAAAAAAAk7IYZAAAAABccAAAAAAAAIuyGGQAAAAAWHAAAAAAAACnshhkAAAAAGBwAAAAAAABkAAAAAAAAABkAAAAAAAAAK+yGGQAAAAAAAAAAAAAAAHUAAAAAAAAAdwEAAAAAAAByAQAAAAAAACvshhkAAAAAERwAAAAAAAAl7IYZAAAAABEcAAAAAAAAJOyGGQAAAAASHAAAAAAAACLshhkAAAAAERwAAAAAAAAp7IYZAAAAABEcAAAAAAAAZAAAAAAAAAAZAAAAAAAAACvshhkAAAAAGRwAAAAAAABkAAAAAAAAAGQAAAAAAAAAcgEAAAAAAAAk7IYZAAAAABkcAAAAAAAAJOyGGQAAAAAaHAAAAAAAAPjrhhkAAAAAFBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAQAAAAAAAgIBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgIBAAAAAAACAgEAAAAAAAAAAAAAAAAAAAAAAAAAAAACAgEAAAAAAAICAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAQAAAAAAAgIBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgIBAAAAAAACAgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2d/uHkzTMEI+nE0Ymaus9KEPf4oJXVEtWcWP29rQOywAAAAAAAAAAPBv/oFQVyUzn/BwmYuYTfvalmqearF4UMH8Xu10jPi1AAAAAAAAAAC9eIxYdxEuqtIqoFaGCUmDIS3Ki2887zwxOIzii37LZgAAAAAAAAAAp5Qc5gqxc5w9o5gk0/YHpMTClPTT8zaXjCVPspfSfxwAAAAAAAAAAIj2IrJxxvwcSeH0Zi3/xWcn5icVCYuh/OncuwHqSRBjAAAAAAAAAAD0AQEAAAAAACvshhkAAAAAnI6GGQAAAAAAAAAAAAAAAFRyhhkAAAAA5wAF8p4BAAAh9gTyngEAAFj1BPKeAQAAxu8E8p4BAAC6/ATyngEAAAAAAAAAAAAAAAAAAAAAAABZQzBUxbJLqOqoIX/f+QNvuZxLwZEZqXGqSDPsYEwjH2QAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATMvqAQAAAAAPAAAAAAAAABAnAAAAAAAATcvqAQAAAAABAAAAAAAAABAnAAAAAAAATsvqAQAAAAABAAAAAAAAABAnAAAAAAAAT8vqAQAAAAABAAAAAAAAABAnAAAAAAAAECcAAAAAAABQwwAAAAAAAKCGAQAAAAAAZAAAAAAAAAAgoQcAAAAAAMgAAAAAAAAAQEIPAAAAAAAsAQAAAAAAAICWmAAAAAAAkAEAAAAAAACIE9AH6ANMHWQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVFYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKCQAAAAAAAJDaOWoAAAAAatw5agAAAAAQDgAAAAAAAIBRAQAAAAAAogYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnuYhAAAAAABMy+oBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQlRDAAAAAAAAAAAAAAAAAA=="; + + fn trader_fixture(collateral: i64, len: u64, capacity: u64) -> Vec { + let capacity = usize::try_from(capacity).expect("fixture capacity"); + let mut data = + vec![0_u8; TRADER_HEADER_LEN + POSITION_MAP_PREFIX_LEN + capacity * POSITION_ENTRY_LEN]; + data[..8].copy_from_slice(&PhoenixAccount::Trader.discriminant()); + data[COLLATERAL_BYTE_RANGE].copy_from_slice(&collateral.to_le_bytes()); + data[112..116].copy_from_slice(&(capacity as u32).to_le_bytes()); + data[TRADER_HEADER_LEN..TRADER_HEADER_LEN + 8].copy_from_slice(&len.to_le_bytes()); + data[TRADER_HEADER_LEN + 8..TRADER_HEADER_LEN + 16] + .copy_from_slice(&(capacity as u64).to_le_bytes()); + if len > 0 && capacity > 0 { + data[TRADER_HEADER_LEN + POSITION_MAP_PREFIX_LEN + ..TRADER_HEADER_LEN + POSITION_MAP_PREFIX_LEN + 8] + .copy_from_slice(&42_u64.to_le_bytes()); + } + data + } + + fn trader_account() -> Account { + Account { + lamports: 1, + data: trader_fixture(0, 1, 2), + owner: PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + } + } + + pub(crate) fn perp_asset_map_fixture() -> Vec { + let prefix = BASE64_STANDARD + .decode(SOL_PERP_ASSET_MAP_PREFIX_B64) + .unwrap(); + let mut data = vec![0_u8; PERP_ASSET_MAP_LEN]; + data[..prefix.len()].copy_from_slice(&prefix); + data[24..26].copy_from_slice(&1_u16.to_le_bytes()); + data[32..36].copy_from_slice(&1_u32.to_le_bytes()); + data[36..40].copy_from_slice(&0_u32.to_le_bytes()); + data + } + + #[test] + fn patches_only_collateral_and_preserves_the_dynamic_tail() { + let data = trader_fixture(6_996_825_500, 1, 2); + let dynamic_tail = data[TRADER_HEADER_LEN..].to_vec(); + + let patched = patch_trader_collateral(&PHOENIX_ETERNAL_PROGRAM_ID, &data, 371_499_999) + .expect("valid collateral patch"); + + let header = TraderHeader::try_read_from_account_bytes(&patched).expect("valid header"); + assert_eq!( + header.trader_state.quote_lot_collateral.as_inner(), + 371_499_999 + ); + assert_eq!(patched.len(), data.len()); + assert_eq!(&patched[TRADER_HEADER_LEN..], dynamic_tail); + assert!( + data.iter() + .zip(&patched) + .enumerate() + .filter(|(_, (before, after))| before != after) + .all(|(offset, _)| COLLATERAL_BYTE_RANGE.contains(&offset)) + ); + } + + #[test] + fn accepts_i64_collateral_boundaries() { + let data = trader_fixture(0, 0, 0); + + for target in [i64::MIN, i64::MAX] { + let patched = + patch_trader_collateral(&PHOENIX_ETERNAL_PROGRAM_ID, &data, target).unwrap(); + let header = TraderHeader::try_read_from_account_bytes(&patched).unwrap(); + assert_eq!(header.trader_state.quote_lot_collateral.as_inner(), target); + } + } + + #[test] + fn rejects_the_wrong_owner() { + let error = patch_trader_collateral(&Pubkey::new_unique(), &trader_fixture(0, 0, 0), 1) + .unwrap_err(); + + assert!(matches!( + error, + PhoenixTraderPatchError::InvalidOwner { .. } + )); + } + + #[test] + fn rejects_the_wrong_discriminant() { + let mut data = trader_fixture(0, 0, 0); + data[..8].fill(0); + + let error = patch_trader_collateral(&PHOENIX_ETERNAL_PROGRAM_ID, &data, 1).unwrap_err(); + + assert!(matches!( + error, + PhoenixTraderPatchError::InvalidTrader( + PhoenixAccountDecodeError::InvalidDiscriminant { .. } + ) + )); + } + + #[test] + fn rejects_a_truncated_header() { + let data = trader_fixture(0, 0, 0); + + let error = patch_trader_collateral( + &PHOENIX_ETERNAL_PROGRAM_ID, + &data[..TRADER_HEADER_LEN - 1], + 1, + ) + .unwrap_err(); + + assert!(matches!( + error, + PhoenixTraderPatchError::InvalidTrader( + PhoenixAccountDecodeError::AccountTooSmall { .. } + ) + )); + } + + #[test] + fn rejects_missing_allocated_position_capacity() { + let data = trader_fixture(0, 1, 2); + let truncated_len = data.len() - POSITION_ENTRY_LEN; + + let error = patch_trader_collateral(&PHOENIX_ETERNAL_PROGRAM_ID, &data[..truncated_len], 1) + .unwrap_err(); + + assert!(matches!( + error, + PhoenixTraderPatchError::InvalidTrader( + PhoenixAccountDecodeError::AccountTooSmall { .. } + ) + )); + } + + #[test] + fn rejects_position_length_above_capacity() { + let data = trader_fixture(2, 2, 1); + + let error = patch_trader_collateral(&PHOENIX_ETERNAL_PROGRAM_ID, &data, 1).unwrap_err(); + + assert!(matches!( + error, + PhoenixTraderPatchError::InvalidTrader(PhoenixAccountDecodeError::InvalidData { + reason: "position map length exceeds capacity", + .. + }) + )); + } + + #[test] + fn override_accepts_only_the_collateral_field() { + let values = HashMap::from([ + (COLLATERAL_FIELD.to_string(), serde_json::json!("371499999")), + ("flags".to_string(), serde_json::json!("1")), + ]); + + let error = + forge_trader_collateral_override(&Pubkey::new_unique(), &trader_account(), &values) + .unwrap_err(); + + assert!(error.to_string().contains("accept only")); + } + + #[test] + fn override_rejects_json_numbers() { + let values = + HashMap::from([(COLLATERAL_FIELD.to_string(), serde_json::json!(371_499_999))]); + + let error = + forge_trader_collateral_override(&Pubkey::new_unique(), &trader_account(), &values) + .unwrap_err(); + + assert!(error.to_string().contains("encoded as a string")); + } + + #[test] + fn override_rejects_out_of_range_strings() { + let values = HashMap::from([( + COLLATERAL_FIELD.to_string(), + serde_json::json!("9223372036854775808"), + )]); + + let error = + forge_trader_collateral_override(&Pubkey::new_unique(), &trader_account(), &values) + .unwrap_err(); + + assert!(error.to_string().contains("signed 64-bit integer")); + } + + #[test] + fn override_parses_exact_string_values() { + let values = HashMap::from([( + COLLATERAL_FIELD.to_string(), + serde_json::json!("-9007199254740993"), + )]); + + let patched = + forge_trader_collateral_override(&Pubkey::new_unique(), &trader_account(), &values) + .unwrap(); + let header = TraderHeader::try_read_from_account_bytes(&patched).unwrap(); + + assert_eq!( + header.trader_state.quote_lot_collateral.as_inner(), + -9_007_199_254_740_993 + ); + } + + #[test] + fn builds_one_editable_collateral_override_for_the_requested_trader() { + let trader = Pubkey::new_unique(); + let preparation = + build_phoenix_collateral_scenario(trader, &trader_account(), "-9007199254740993") + .unwrap(); + + assert_eq!(preparation.trader, trader); + assert_eq!(preparation.target_quote_lots, -9_007_199_254_740_993); + assert_eq!(preparation.scenario.overrides.len(), 1); + + let collateral_override = &preparation.scenario.overrides[0]; + assert_eq!( + collateral_override.template_id, + "phoenix-trader-collateral-stress" + ); + assert_eq!( + collateral_override.account, + AccountAddress::Pubkey(trader.to_string()) + ); + assert_eq!( + collateral_override.values[COLLATERAL_FIELD], + serde_json::json!("-9007199254740993") + ); + assert_eq!(collateral_override.scenario_relative_slot, PREPARATION_SLOT); + assert!(!collateral_override.fetch_before_use); + } + + #[test] + fn builder_rejects_an_invalid_target_before_creating_a_scenario() { + let error = build_phoenix_collateral_scenario( + Pubkey::new_unique(), + &trader_account(), + "9223372036854775808", + ) + .unwrap_err(); + + assert!(error.to_string().contains("signed 64-bit integer")); + } + + #[test] + fn builder_refuses_to_raise_collateral_above_its_vault_backing() { + let trader = Pubkey::new_unique(); + let funded = Account { + data: trader_fixture(500, 1, 2), + ..trader_account() + }; + + let raised = build_phoenix_collateral_scenario(trader, &funded, "501").unwrap_err(); + assert!(raised.to_string().contains("can only lower collateral")); + + let lowered = build_phoenix_collateral_scenario(trader, &funded, "499").unwrap(); + assert_eq!(lowered.target_quote_lots, 499); + let held = build_phoenix_collateral_scenario(trader, &funded, "500").unwrap(); + assert_eq!(held.target_quote_lots, 500); + } + + #[test] + fn builder_rejects_a_non_phoenix_account() { + let trader = Pubkey::new_unique(); + let mut account = trader_account(); + account.owner = Pubkey::new_unique(); + + let error = build_phoenix_collateral_scenario(trader, &account, "1").unwrap_err(); + + assert!(error.to_string().contains("invalid account owner")); + } + + #[test] + fn lists_active_market_symbols_from_the_perp_asset_map() { + let perp_asset_map = Pubkey::new_unique(); + let account = Account { + lamports: 1, + data: perp_asset_map_fixture(), + owner: PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }; + + assert_eq!( + phoenix_market_symbols(perp_asset_map, &account).unwrap(), + vec!["SOL"] + ); + } + + #[test] + fn patches_only_the_selected_mark_ticks_in_an_official_market_entry() { + let data = perp_asset_map_fixture(); + let map = PerpAssetMap::try_from_account_bytes(&data).unwrap(); + let before = map.find_by_symbol("SOL").unwrap().unwrap(); + let metadata_offset = unique_subslice_offset(&data, before.metadata.as_bytes()).unwrap(); + + let patched = patch_direct_mark(&PHOENIX_ETERNAL_PROGRAM_ID, &data, "SOL", 1).unwrap(); + let map = PerpAssetMap::try_from_account_bytes(&patched).unwrap(); + let after = map.find_by_symbol("SOL").unwrap().unwrap(); + + assert_eq!( + after + .metadata + .oracle_price() + .mark_price + .price + .ticks + .as_inner(), + 1 + ); + assert_eq!(patched.len(), data.len()); + assert!( + data.iter() + .zip(&patched) + .enumerate() + .filter(|(_, (before, after))| before != after) + .all(|(offset, _)| { + (metadata_offset + MARK_PRICE_TICKS_RANGE.start + ..metadata_offset + MARK_PRICE_TICKS_RANGE.end) + .contains(&offset) + }) + ); + } + + #[test] + fn reference_price_patch_supports_both_divergence_directions_and_preserves_mark() { + let data = perp_asset_map_fixture(); + let before_map = PerpAssetMap::try_from_account_bytes(&data).unwrap(); + let before = before_map.find_by_symbol("SOL").unwrap().unwrap(); + let before_mark = before + .metadata + .oracle_price() + .mark_price + .price + .ticks + .as_inner(); + + for (spot_ticks, perp_ticks) in [(8_000, 7_000), (7_000, 8_000)] { + let patched = patch_reference_prices( + &PHOENIX_ETERNAL_PROGRAM_ID, + &data, + "SOL", + spot_ticks, + perp_ticks, + ) + .unwrap(); + let map = PerpAssetMap::try_from_account_bytes(&patched).unwrap(); + let entry = map.find_by_symbol("SOL").unwrap().unwrap(); + let price = entry.metadata.oracle_price(); + + assert_eq!(price.mark_price.price.ticks.as_inner(), before_mark); + assert!( + price + .mark_price + .spot_price_component + .last_exchange_spot_price + .iter() + .all(|value| value.ticks.as_inner() == spot_ticks) + ); + assert!( + price + .mark_price + .perp_price_component + .last_exchange_perp_price + .iter() + .all(|value| value.ticks.as_inner() == perp_ticks) + ); + assert_eq!(patched.len(), data.len()); + } + } + + #[test] + fn reference_price_patch_refreshes_each_reference_slot() { + let data = perp_asset_map_fixture(); + let patched = patch_reference_prices_at_slot( + &PHOENIX_ETERNAL_PROGRAM_ID, + &data, + "SOL", + 8_000, + 7_000, + 123, + ) + .unwrap(); + let map = PerpAssetMap::try_from_account_bytes(&patched).unwrap(); + let entry = map.find_by_symbol("SOL").unwrap().unwrap(); + let price = entry.metadata.oracle_price(); + + assert!( + price + .mark_price + .spot_price_component + .last_exchange_spot_price + .iter() + .all(|value| value.slot == 123 && value.ticks.as_inner() == 8_000) + ); + assert!( + price + .mark_price + .perp_price_component + .last_exchange_perp_price + .iter() + .all(|value| value.slot == 123 && value.ticks.as_inner() == 7_000) + ); + } + + #[test] + fn reference_price_override_requires_exact_string_fields() { + let account = Account { + lamports: 1, + data: perp_asset_map_fixture(), + owner: PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }; + let values = HashMap::from([ + ( + DIRECT_MARK_SYMBOL_FIELD.to_string(), + serde_json::json!("SOL"), + ), + ( + REFERENCE_SPOT_TICKS_FIELD.to_string(), + serde_json::json!("8000"), + ), + ( + REFERENCE_PERP_TICKS_FIELD.to_string(), + serde_json::json!("7000"), + ), + ]); + forge_reference_price_override(&Pubkey::new_unique(), &account, &values, 100).unwrap(); + + let mut numeric = values; + numeric.insert( + REFERENCE_SPOT_TICKS_FIELD.to_string(), + serde_json::json!(8000), + ); + assert!( + forge_reference_price_override(&Pubkey::new_unique(), &account, &numeric, 100) + .unwrap_err() + .to_string() + .contains("encoded as a string") + ); + } + + #[test] + fn direct_mark_rejects_unknown_markets_and_out_of_range_ticks() { + let data = perp_asset_map_fixture(); + + assert!(matches!( + patch_direct_mark(&PHOENIX_ETERNAL_PROGRAM_ID, &data, "BTC", 1), + Err(PhoenixDirectMarkPatchError::MarketNotFound { .. }) + )); + assert!(matches!( + patch_direct_mark( + &PHOENIX_ETERNAL_PROGRAM_ID, + &data, + "SOL", + u64::from(u32::MAX) + 1, + ), + Err(PhoenixDirectMarkPatchError::InvalidTicks { .. }) + )); + } + + #[test] + fn direct_mark_override_requires_exact_string_fields() { + let account = Account { + lamports: 1, + data: perp_asset_map_fixture(), + owner: PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }; + let values = HashMap::from([ + ( + DIRECT_MARK_SYMBOL_FIELD.to_string(), + serde_json::json!("SOL"), + ), + (DIRECT_MARK_TICKS_FIELD.to_string(), serde_json::json!("1")), + ]); + + let patched = + forge_direct_mark_override(&Pubkey::new_unique(), &account, &values, 123).unwrap(); + let map = PerpAssetMap::try_from_account_bytes(&patched).unwrap(); + let price = map + .find_by_symbol("SOL") + .unwrap() + .unwrap() + .metadata + .oracle_price() + .mark_price + .price; + assert_eq!(price.ticks.as_inner(), 1); + assert_eq!(price.slot, 123); + + let mut numeric_ticks = values; + numeric_ticks.insert(DIRECT_MARK_TICKS_FIELD.to_string(), serde_json::json!(1)); + assert!( + forge_direct_mark_override(&Pubkey::new_unique(), &account, &numeric_ticks, 123) + .unwrap_err() + .to_string() + .contains("encoded as a string") + ); + } + + #[test] + fn forge_dispatches_collateral_values_to_the_trader_patcher() { + let values = + HashMap::from([(COLLATERAL_FIELD.to_string(), serde_json::json!("371499999"))]); + + let patched = + forge_phoenix_override(&Pubkey::new_unique(), &trader_account(), &values, 100).unwrap(); + let header = TraderHeader::try_read_from_account_bytes(&patched).unwrap(); + + assert_eq!( + header.trader_state.quote_lot_collateral.as_inner(), + 371_499_999 + ); + } + + #[test] + fn forge_dispatches_symbol_and_target_ticks_to_the_direct_mark_patcher() { + let account = Account { + lamports: 1, + data: perp_asset_map_fixture(), + owner: PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }; + let values = HashMap::from([ + ( + DIRECT_MARK_SYMBOL_FIELD.to_string(), + serde_json::json!("SOL"), + ), + (DIRECT_MARK_TICKS_FIELD.to_string(), serde_json::json!("1")), + ]); + + let patched = + forge_phoenix_override(&Pubkey::new_unique(), &account, &values, 123).unwrap(); + let map = PerpAssetMap::try_from_account_bytes(&patched).unwrap(); + let price = map + .find_by_symbol("SOL") + .unwrap() + .unwrap() + .metadata + .oracle_price() + .mark_price + .price; + + assert_eq!(price.ticks.as_inner(), 1); + assert_eq!(price.slot, 123); + } + + #[test] + fn forge_dispatches_symbol_and_reference_ticks_to_the_reference_patcher() { + let account = Account { + lamports: 1, + data: perp_asset_map_fixture(), + owner: PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }; + let values = HashMap::from([ + ( + DIRECT_MARK_SYMBOL_FIELD.to_string(), + serde_json::json!("SOL"), + ), + ( + REFERENCE_SPOT_TICKS_FIELD.to_string(), + serde_json::json!("8000"), + ), + ( + REFERENCE_PERP_TICKS_FIELD.to_string(), + serde_json::json!("7000"), + ), + ]); + + let patched = + forge_phoenix_override(&Pubkey::new_unique(), &account, &values, 123).unwrap(); + let map = PerpAssetMap::try_from_account_bytes(&patched).unwrap(); + let entry = map.find_by_symbol("SOL").unwrap().unwrap(); + let price = entry.metadata.oracle_price(); + + assert!( + price + .mark_price + .spot_price_component + .last_exchange_spot_price + .iter() + .all(|value| value.slot == 123 && value.ticks.as_inner() == 8_000) + ); + assert!( + price + .mark_price + .perp_price_component + .last_exchange_perp_price + .iter() + .all(|value| value.slot == 123 && value.ticks.as_inner() == 7_000) + ); + } + + #[test] + fn forge_rejects_no_value_group() { + let error = forge_phoenix_override( + &Pubkey::new_unique(), + &trader_account(), + &HashMap::new(), + 100, + ) + .unwrap_err(); + + assert!(error.to_string().contains("exactly one value group")); + } + + #[test] + fn forge_rejects_mixed_value_groups() { + let values = HashMap::from([ + (COLLATERAL_FIELD.to_string(), serde_json::json!("371499999")), + (DIRECT_MARK_TICKS_FIELD.to_string(), serde_json::json!("1")), + ]); + + let error = forge_phoenix_override(&Pubkey::new_unique(), &trader_account(), &values, 100) + .unwrap_err(); + + assert!(error.to_string().contains("exactly one value group")); + } +} diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index c426d3777..f89c70778 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -39,6 +39,11 @@ pub const PUMP_AMM_V1_IDL_CONTENT: &str = include_str!("./protocols/pump-amm/v1/ pub const PUMP_AMM_V1_OVERRIDES_CONTENT: &str = include_str!("./protocols/pump-amm/v1/overrides.yaml"); +pub const PHOENIX_ETERNAL_IDL_CONTENT: &str = + include_str!("./protocols/phoenix-eternal/v1/editor-schema.json"); +pub const PHOENIX_ETERNAL_OVERRIDES_CONTENT: &str = + include_str!("./protocols/phoenix-eternal/v1/overrides.yaml"); + /// Registry for managing override templates loaded from YAML files #[derive(Clone, Debug, Default)] pub struct TemplateRegistry { @@ -59,6 +64,7 @@ impl TemplateRegistry { default.load_whirlpool_overrides(); default.load_spl_token_overrides(); default.load_pump_overrides(); + default.load_phoenix_overrides(); default } @@ -128,6 +134,14 @@ impl TemplateRegistry { ); } + pub fn load_phoenix_overrides(&mut self) { + self.load_protocol_overrides( + PHOENIX_ETERNAL_IDL_CONTENT, + PHOENIX_ETERNAL_OVERRIDES_CONTENT, + "phoenix-eternal", + ); + } + fn load_protocol_overrides( &mut self, idl_content: &str, @@ -426,11 +440,11 @@ mod tests { fn test_registry_loads_all_protocols() { let registry = TemplateRegistry::new(); - // Should have Pyth (1 template) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift(4) + Meteora (2) + Kamino(3) + Whirlpool(6) + SPL Token (2) + Pump (2) + PumpSwap (3) = 29 total + // Should have Pyth (1 template) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift(4) + Meteora (2) + Kamino(3) + Whirlpool(6) + SPL Token (2) + Pump (2) + PumpSwap (3) + Phoenix Eternal (3) = 32 total assert_eq!( registry.count(), - 29, - "Registry should load 29 templates total" + 32, + "Registry should load 32 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); @@ -472,6 +486,9 @@ mod tests { assert!(registry.contains("pump-amm-pool-state")); assert!(registry.contains("pump-amm-canonical-pool")); assert!(registry.contains("pump-amm-global-config")); + + assert!(registry.contains("phoenix-trader-collateral-stress")); + assert!(registry.contains("phoenix-direct-mark-risk-shock")); } #[test] @@ -532,6 +549,13 @@ mod tests { 3, "Should have 3 PumpSwap templates" ); + + let phoenix_templates = registry.by_protocol("Phoenix Eternal"); + assert_eq!( + phoenix_templates.len(), + 3, + "Should have 3 Phoenix Eternal templates" + ); } #[test] diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 0e65972cf..8c21ce16e 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -88,7 +88,12 @@ use super::{ use crate::{ error::{AirdropError, SurfpoolError, SurfpoolResult}, rpc::utils::convert_transaction_metadata_from_canonical, - scenarios::TemplateRegistry, + scenarios::{ + TemplateRegistry, + protocols::phoenix_eternal::v1::state_builder::{ + PHOENIX_ETERNAL_PROGRAM_ID, forge_phoenix_override, + }, + }, storage::{OverlayStorage, Storage, StorageBackend}, surfnet::{ LogsSubscriptionData, locker::is_supported_token_program, surfnet_lite_svm::SurfnetLiteSvm, @@ -2708,6 +2713,10 @@ impl SurfnetSvm { target_slot ); + // Accounts already patched in this batch. A later fetch-before-use override on the + // same account must not reinstall remote bytes over an earlier same-slot patch. + let mut patched_this_slot: HashSet = HashSet::new(); + for override_instance in overrides { if !override_instance.enabled { debug!("Skipping disabled override: {}", override_instance.id); @@ -2746,7 +2755,12 @@ impl SurfnetSvm { ); // Fetch fresh account data from remote if requested - if override_instance.fetch_before_use { + if override_instance.fetch_before_use && patched_this_slot.contains(&account_pubkey) { + debug!( + "Skipping refresh for {}: an earlier override in this slot already patched it", + account_pubkey + ); + } else if override_instance.fetch_before_use { if let Some((client, _)) = remote_ctx { debug!( "Fetching fresh account data for {} from remote", @@ -2883,10 +2897,32 @@ impl SurfnetSvm { rent_epoch: account.rent_epoch(), }; self.inner.set_account(account_pubkey, modified_account)?; + patched_this_slot.insert(account_pubkey); continue; } } + // Phoenix Eternal accounts are zero-copy: route them by owner through + // the typed codec, the same seam the token path uses. + if account.owner() == &PHOENIX_ETERNAL_PROGRAM_ID { + let new_account_data = forge_phoenix_override( + &account_pubkey, + &account, + &account_values, + target_slot, + )?; + let modified_account = Account { + lamports: account.lamports(), + data: new_account_data, + owner: *account.owner(), + executable: account.executable(), + rent_epoch: account.rent_epoch(), + }; + self.inner.set_account(account_pubkey, modified_account)?; + patched_this_slot.insert(account_pubkey); + continue; + } + // Get the account owner (program ID) let owner_program_id = account.owner(); @@ -2969,6 +3005,7 @@ impl SurfnetSvm { account_pubkey, e ); } else { + patched_this_slot.insert(account_pubkey); debug!( "Successfully applied {} override(s) to account {} (override {})", override_instance.values.len(), @@ -4516,6 +4553,83 @@ mod tests { ); } + /// Two overrides on the same account in the same slot: the later one asks for a + /// refresh, which must not reinstall remote bytes over the earlier patch. + #[tokio::test(flavor = "multi_thread")] + async fn test_fetch_before_use_preserves_an_earlier_same_slot_patch() { + let url = canned_rpc(CANNED_TOKEN_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let locker = crate::surfnet::locker::SurfnetSvmLocker::new(svm); + let target = Pubkey::new_unique(); + + // The local account's lamports are the marker: a remote reinstall would replace them. + let local = crate::types::TokenAccount::new( + &spl_token_interface::id(), + Pubkey::new_unique(), + Pubkey::default(), + None, + ); + locker.with_svm_writer(|svm_writer| { + svm_writer + .set_account( + &target, + Account { + lamports: 1_000_000, + data: local.pack_into_vec(), + owner: spl_token_interface::id(), + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + }); + + let mut scenario = surfpool_types::Scenario::new( + "same-slot patches".to_string(), + "a later refresh must not erase an earlier same-slot patch".to_string(), + ); + let first = surfpool_types::OverrideInstance::new( + "spl-token-account-balance".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(target.to_string()), + ) + .with_values(HashMap::from([( + "amount".to_string(), + serde_json::json!("42"), + )])); + scenario.add_override(first); + let mut second = surfpool_types::OverrideInstance::new( + "spl-token-account-balance".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(target.to_string()), + ) + .with_values(HashMap::from([( + "amount".to_string(), + serde_json::json!("77"), + )])); + second.fetch_before_use = true; + scenario.add_override(second); + + locker.register_scenario(scenario, Some(100)).unwrap(); + locker + .materialize_overrides_for_slot(&Some(remote), 100) + .await + .unwrap(); + + let after = locker + .with_svm_reader(|svm_reader| svm_reader.get_account(&target)) + .unwrap() + .unwrap(); + assert_eq!( + after.lamports, 1_000_000, + "the second override's refresh must be skipped: this account was already patched \ + in the same slot" + ); + let token = crate::types::TokenAccount::unpack(&after.data).unwrap(); + assert_eq!(token.amount(), 77); + } + fn build_transfer_transaction( payer: &Keypair, recipient: &Pubkey, diff --git a/crates/core/src/tests/live.rs b/crates/core/src/tests/live.rs new file mode 100644 index 000000000..853cb2b77 --- /dev/null +++ b/crates/core/src/tests/live.rs @@ -0,0 +1,60 @@ +//! Shared plumbing for tests that read mainnet. +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. + +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::surfnet::remote::SurfnetRemoteClient; + +pub const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +pub const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +pub fn client() -> SurfnetRemoteClient { + SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ) +} + +/// Fetches the accounts in one request, so every account returned is from the same slot. +pub async fn fetch(addresses: &[Pubkey]) -> Vec { + // The public endpoint throttles and intermittently 503s, which has nothing to do with what + // the callers assert. Retry a few times with backoff so a transient refusal is not read as a + // failure. + let mut attempt = 0; + let results = loop { + match client() + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + { + Ok(results) => break results, + Err(error) if attempt < 4 => { + attempt += 1; + tokio::time::sleep(std::time::Duration::from_millis(500 * attempt)).await; + let _ = error; + } + Err(error) => panic!("failed to fetch {addresses:?} from mainnet: {error}"), + } + }; + + results + .into_iter() + .zip(addresses) + .map(|(result, address)| { + result.map_account().unwrap_or_else(|_| { + panic!("{address} no longer exists on mainnet; the integration needs a new address") + }) + }) + .collect() +} + +/// The offsets at which two buffers differ. +pub fn diff_indices(left: &[u8], right: &[u8]) -> Vec { + left.iter() + .zip(right) + .enumerate() + .filter(|(_, (a, b))| a != b) + .map(|(index, _)| index) + .collect() +} diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index 54093ec1e..d18810e84 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,5 +1,9 @@ pub mod helpers; pub mod integration; +#[cfg(feature = "integration-tests")] +pub mod live; +#[cfg(feature = "integration-tests")] +pub mod phoenix; pub mod plugin; #[cfg(feature = "integration-tests")] pub mod pump; diff --git a/crates/core/src/tests/phoenix/mod.rs b/crates/core/src/tests/phoenix/mod.rs new file mode 100644 index 000000000..27e8e2acd --- /dev/null +++ b/crates/core/src/tests/phoenix/mod.rs @@ -0,0 +1,1093 @@ +//! Phoenix Eternal integration tests. +//! +//! These fetch the real accounts from mainnet rather than embedding captured copies, so they +//! need a network connection and are compiled only behind a feature: +//! +//! ```text +//! cargo test -p surfpool-core --features integration-tests phoenix +//! ``` +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. +//! +//! What these cover that unit tests cannot: Phoenix is zero-copy, so decoding a synthetic +//! account and decoding the bytes it was built from can never disagree. Only live accounts +//! carry the real header values, populated market entries and the dynamic tail, so a program +//! upgrade that moves a field shows up here as a failed invariant or a stray byte diff. +//! +//! Nothing here hunts for a market that happens to sit in an interesting state: the live +//! accounts are the raw material, and the production builders prepare the scene. + +use phoenix_rise_accounts::{ + PhoenixAccount, + global_config::GlobalConfig, + pda::derive_spline_collection_address, + perp_asset_map::PerpAssetMap, + trader::{Trader, TraderHeader}, +}; +use std::collections::HashMap; + +use bytemuck::{Pod, Zeroable}; +use solana_account::Account; +use solana_account_decoder::UiAccountEncoding; +use solana_clock::Clock; +use solana_commitment_config::CommitmentConfig; +use solana_compute_budget_interface::ComputeBudgetInstruction; +use solana_instruction::{AccountMeta, Instruction}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_rpc_client_api::{ + config::RpcAccountInfoConfig, + filter::{Memcmp, RpcFilterType}, +}; +use solana_signer::Signer; +use solana_transaction::Transaction; + +use surfpool_types::AccountAddress; + +use crate::{ + scenarios::{ + TemplateRegistry, + protocols::phoenix_eternal::v1::state_builder::{ + PHOENIX_ETERNAL_PROGRAM_ID, PHOENIX_GLOBAL_CONFIG, build_phoenix_collateral_scenario, + patch_direct_mark, patch_reference_prices, patch_trader_collateral, + phoenix_market_symbols, phoenix_perp_asset_map_address, + }, + }, + surfnet::{locker::SurfnetSvmLocker, svm::SurfnetSvm}, + tests::live::{RPC_URL_ENV, client, diff_indices, fetch}, + types::RemoteRpcResult, +}; + +type LiveMarketGraph = (Account, Pubkey, Account, String); + +/// One fetch of the fork state per process. The perp asset map alone is 1.6MB, and running the +/// tests in parallel against a public endpoint is what exhausts it. +fn market_graph_cache() -> &'static tokio::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| tokio::sync::Mutex::new(None)) +} + +fn trader_cache() -> &'static tokio::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) +} + +/// The live Phoenix account graph: GlobalConfig, the perp asset map it points at, and the +/// symbol of a market that is actually listed right now. +async fn live_market_graph() -> LiveMarketGraph { + let mut cache = market_graph_cache().lock().await; + if let Some(cached) = cache.as_ref() { + return cached.clone(); + } + let global_account = fetch(&[PHOENIX_GLOBAL_CONFIG]).await.remove(0); + assert_eq!( + global_account.owner, PHOENIX_ETERNAL_PROGRAM_ID, + "GlobalConfig must be owned by the deployed Eternal program" + ); + + let perp_asset_map = phoenix_perp_asset_map_address(&global_account) + .expect("live GlobalConfig should resolve its perp asset map"); + let map_account = fetch(&[perp_asset_map]).await.remove(0); + let symbols = phoenix_market_symbols(perp_asset_map, &map_account) + .expect("live PerpAssetMap should decode"); + let symbol = symbols + .first() + .cloned() + .expect("no eligible live candidate: the Phoenix PerpAssetMap lists no markets"); + + let graph = (global_account, perp_asset_map, map_account, symbol); + *cache = Some(graph.clone()); + + graph +} + +/// A live Trader account with collateral on it. Traders are per-user accounts that come and go, +/// so the test discovers one through the program's own account list rather than pinning an +/// address that may be closed tomorrow. +pub(crate) async fn live_trader_with_position() -> (Pubkey, Account) { + live_candidate(true).await +} + +async fn live_trader() -> (Pubkey, Account) { + live_candidate(false).await +} + +/// Walks the program's own account list for a Trader that carries collateral, and an open +/// position when the caller needs something for a mark shock to act on. +async fn live_candidate(needs_position: bool) -> (Pubkey, Account) { + let mut cache = trader_cache().lock().await; + if let Some(hit) = cache.get(&needs_position) { + return hit.clone(); + } + let listed = client() + .get_program_accounts( + &PHOENIX_ETERNAL_PROGRAM_ID, + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + commitment: Some(CommitmentConfig::confirmed()), + ..RpcAccountInfoConfig::default() + }, + Some(vec![RpcFilterType::Memcmp(Memcmp::new_base58_encoded( + 0, + &PhoenixAccount::Trader.discriminant(), + ))]), + ) + .await; + let candidates = match listed { + Ok(RemoteRpcResult::Ok(accounts)) => accounts, + // The protocol keeps its own trader index, but 0.3.4 exposes only the arena + // metadata, so the program's account list is the reader we have. + Ok(RemoteRpcResult::MethodNotSupported) => panic!( + "environment: this endpoint does not support getProgramAccounts, which these tests \ + need to find a live trader. Set {RPC_URL_ENV} to an endpoint that supports it. \ + Nothing is proven or disproven about the integration." + ), + Err(error) => panic!("failed to list live Phoenix traders: {error}"), + }; + + // Addresses come back in pubkey order, so a handful from the front is not a + // representative sample; scan in batches until one qualifies. + for batch in candidates.chunks(100).take(4) { + let pubkeys = batch.iter().map(|(pubkey, _)| *pubkey).collect::>(); + for (pubkey, account) in pubkeys.iter().zip(fetch(&pubkeys).await) { + let Ok(trader) = Trader::try_from_account_bytes(&account.data) else { + continue; + }; + let has_collateral = trader.header.trader_state.quote_lot_collateral.as_inner() > 0; + // A downward mark shock only threatens a long, so the risk scenarios need one: + // the shock direction is fixed, the trader is what we go looking for. + let holds_a_long = trader + .positions() + .any(|(_, position)| position.base_lot_position().as_inner() > 0); + if has_collateral && (!needs_position || holds_a_long) { + cache.insert(needs_position, (*pubkey, account.clone())); + return (*pubkey, account); + } + } + } + + panic!( + "no eligible live candidate: no live Phoenix Trader read carries collateral{}", + if needs_position { + " and a long position" + } else { + "" + } + ) +} + +/// A zero-copy layout cannot be round-tripped against itself, so drift shows up as an +/// invariant that stops holding on live bytes. +#[tokio::test(flavor = "multi_thread")] +async fn live_accounts_satisfy_the_typed_layout_invariants() { + let (global_account, perp_asset_map, map_account, symbol) = live_market_graph().await; + + let global = GlobalConfig::try_from_account_bytes(&global_account.data) + .expect("live GlobalConfig should decode through phoenix-rise-accounts"); + assert_eq!( + Pubkey::new_from_array(global.account_key()), + PHOENIX_GLOBAL_CONFIG, + "GlobalConfig stores its own address, so a moved field shows up here first" + ); + assert_eq!( + Pubkey::new_from_array(global.perp_asset_map_key()), + perp_asset_map + ); + assert_ne!( + Pubkey::new_from_array(global.global_trader_index_header_key()), + Pubkey::default() + ); + assert_ne!( + Pubkey::new_from_array(global.active_trader_buffer_header_key()), + Pubkey::default() + ); + + assert_eq!(map_account.owner, PHOENIX_ETERNAL_PROGRAM_ID); + let map = PerpAssetMap::try_from_account_bytes(&map_account.data) + .expect("live PerpAssetMap should decode through phoenix-rise-accounts"); + let entry = map + .find_by_symbol(&symbol) + .expect("symbol lookup should decode") + .expect("the symbol came from this map"); + let market = Pubkey::new_from_array(entry.metadata.static_market_params().market_account); + assert_ne!( + market, + Pubkey::default(), + "a listed market needs an address" + ); + + let price = entry.metadata.oracle_price(); + assert!( + price.mark_price.price.ticks.as_inner() > 0, + "{symbol} is listed with a zero mark price, which the risk engine cannot use" + ); + + // The spline address is derived, so a change in the seeds surfaces as an account the + // program would no longer find. + let spline = derive_spline_collection_address(&PHOENIX_ETERNAL_PROGRAM_ID, &market); + let spline_account = fetch(&[spline]).await.remove(0); + assert_eq!( + spline_account.owner, PHOENIX_ETERNAL_PROGRAM_ID, + "the derived spline collection must belong to the Eternal program" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn overrides_on_live_accounts_touch_only_their_target_bytes() { + let (_global_account, perp_asset_map, map_account, symbol) = live_market_graph().await; + + let map = PerpAssetMap::try_from_account_bytes(&map_account.data).expect("live map decodes"); + let entry = map + .find_by_symbol(&symbol) + .expect("symbol lookup") + .expect("listed symbol"); + let live_mark = entry + .metadata + .oracle_price() + .mark_price + .price + .ticks + .as_inner(); + + let shocked = patch_direct_mark( + &map_account.owner, + &map_account.data, + &symbol, + live_mark / 2 + 1, + ) + .expect("direct mark patch on the live map"); + assert_eq!( + shocked.len(), + map_account.data.len(), + "the map's dynamic tail must survive" + ); + let mark_diffs = diff_indices(&shocked, &map_account.data); + assert!( + !mark_diffs.is_empty() && mark_diffs.len() <= 16, + "a mark shock writes one tick field, got {} changed bytes", + mark_diffs.len() + ); + + let diverged = patch_reference_prices( + &map_account.owner, + &map_account.data, + &symbol, + live_mark * 2, + live_mark * 3, + ) + .expect("reference price patch on the live map"); + assert_eq!(diverged.len(), map_account.data.len()); + let reference_diffs = diff_indices(&diverged, &map_account.data); + assert!( + reference_diffs + .iter() + .all(|index| !mark_diffs.contains(index)), + "reference divergence must preserve the mark price it diverges from" + ); + + let _ = perp_asset_map; +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_market_templates_address_the_live_perp_asset_map() { + let (_global_account, perp_asset_map, _map_account, _symbol) = live_market_graph().await; + let registry = TemplateRegistry::new(); + + for template_id in [ + "phoenix-direct-mark-risk-shock", + "phoenix-reference-price-divergence", + ] { + let template = registry + .get(template_id) + .unwrap_or_else(|| panic!("{template_id} should be registered")); + let addressed = match &template.address { + AccountAddress::Pubkey(value) => Pubkey::from_str_const(value), + other => panic!("{template_id} should address a fixed pubkey, got {other:?}"), + }; + assert_eq!( + addressed, perp_asset_map, + "{template_id} writes to a hardcoded map; a Phoenix migration moves the one \ + GlobalConfig points at, and nothing else here would notice" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn collateral_stress_refuses_to_outrun_the_live_vault() { + let (trader, account) = live_trader().await; + let live_collateral = TraderHeader::try_read_from_account_bytes(&account.data) + .expect("a discovered trader decodes") + .trader_state + .quote_lot_collateral + .as_inner(); + + let lowered = build_phoenix_collateral_scenario(trader, &account, "1") + .expect("lowering collateral is state preparation"); + assert_eq!(lowered.target_quote_lots, 1); + + let raised = + build_phoenix_collateral_scenario(trader, &account, &(live_collateral + 1).to_string()) + .unwrap_err(); + assert!( + raised.to_string().contains("can only lower collateral"), + "raising collateral past its vault backing must be refused, got: {raised}" + ); + + let patched = patch_trader_collateral(&account.owner, &account.data, 1) + .expect("collateral patch on a valid trader"); + let diffs = diff_indices(&patched, &account.data); + assert!( + diffs.iter().all(|index| (88..96).contains(index)), + "only the collateral field may change, got {diffs:?}" + ); +} + +const HAWKEYE_VIEW_MARGIN_DISCRIMINANT: [u8; 8] = [0xb2, 0x0a, 0x7c, 0xad, 0xec, 0xd2, 0x75, 0x06]; +const HAWKEYE_VIEW_BBO_DISCRIMINANT: [u8; 8] = [0x37, 0x5f, 0x23, 0x2d, 0x53, 0xaf, 0x12, 0x52]; +const ETERNAL_PROGRAMDATA: Pubkey = + Pubkey::from_str_const("B5ayDaz9HegiNZqYeBtcFqfZBVSGwjB2CJgHshoSfMQg"); +const HAWKEYE_PROGRAMDATA: Pubkey = + Pubkey::from_str_const("Gv1WgG864CQqF5vedJVbpnhpRpRbTW1A7SyARzSw9B4Y"); + +/// The deployed bytecode, read from the upgradeable loader's ProgramData account. The ELF +/// starts 45 bytes in, past the loader's own header. +async fn deployed_program(programdata: Pubkey, name: &str) -> Vec { + let cache = std::env::temp_dir().join(format!("surfpool-phoenix-{name}.so")); + match std::fs::read(&cache) { + Ok(bytes) if bytes.len() > 200_000 => bytes, + _ => { + let bytes = fetch(&[programdata]).await.remove(0).data[45..].to_vec(); + let _ = std::fs::write(&cache, &bytes); + bytes + } + } +} + +/// Refetching the graph per test is what exhausts a public endpoint: the perp asset map alone +/// is 1.6MB, so every test reads the same fork state from one cached fetch. +fn live_graph_cache() -> &'static tokio::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| tokio::sync::Mutex::new(None)) +} + +#[tokio::test(flavor = "multi_thread")] +async fn phoenix_state_preparation_changes_hawkeye_risk_outcomes() { + // Collateral stress produces the risk condition: a trader with an open position and + // almost no collateral is liquidatable whichever way the position points. + let (collateral_locker, graph) = phoenix_behavior_locker().await; + let before = hawkeye_margin(&collateral_locker, &graph); + assert!( + before.position_count > 0, + "the discovered trader must hold a position for margin to mean anything" + ); + // The program itself says how much collateral this trader's positions require, so the + // stress target is derived from live state rather than picked. + assert!( + before.maintenance_margin_quote_lots > 0, + "no eligible live candidate: the discovered trader's positions require no margin" + ); + assert_eq!(before.is_liquidatable, 0, "the fork starts healthy"); + + collateral_locker + .register_scenario( + phoenix_collateral_scenario(graph.trader, serde_json::json!("1"), false), + Some(100), + ) + .unwrap(); + collateral_locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + let after_collateral = hawkeye_margin(&collateral_locker, &graph); + assert_eq!( + after_collateral.collateral_quote_lots, 1, + "the program reads the collateral the preparation wrote" + ); + assert!( + after_collateral.effective_collateral_quote_lots < before.effective_collateral_quote_lots, + "stressing collateral must lower what the risk engine can count on" + ); + + // The cascade prepares the same collateral at slot 0 and a mark shock at slot 1. What + // the deployed program reads is asserted; whether this particular position liquidates + // depends on its side, which the discovery does not choose. + let (mark_locker, graph) = phoenix_behavior_locker().await; + let (symbol, orderbook, spline) = graph.markets[0].clone(); + let (trader_account, global_account, perp_asset_map_account) = + mark_locker.with_svm_reader(|svm_reader| { + ( + svm_reader.get_account(&graph.trader).unwrap().unwrap(), + svm_reader + .get_account(&crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_GLOBAL_CONFIG) + .unwrap() + .unwrap(), + svm_reader + .get_account(&graph.perp_asset_map) + .unwrap() + .unwrap(), + ) + }); + let prepared_collateral = hawkeye_margin(&mark_locker, &graph).collateral_quote_lots / 2; + // The cascade is the two templates across slots: the collateral tool's scenario at slot 0 + // and the mark shock at slot 1, which is what a user composes in the editor. + let mut cascade = crate::scenarios::protocols::phoenix_eternal::v1::state_builder::build_phoenix_collateral_scenario( + graph.trader, + &trader_account, + &prepared_collateral.to_string(), + ) + .unwrap() + .scenario; + let mut shock = phoenix_direct_mark_scenario(graph.perp_asset_map, &symbol, "1", false) + .overrides + .remove(0); + shock.scenario_relative_slot = 1; + cascade.add_override(shock); + let _ = (&global_account, &perp_asset_map_account); + mark_locker.register_scenario(cascade, Some(100)).unwrap(); + mark_locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + let before_mark = hawkeye_bbo_for_market(&graph, &mark_locker, orderbook, spline); + assert_eq!( + hawkeye_margin(&mark_locker, &graph).collateral_quote_lots, + prepared_collateral, + "stage 0 prepares the collateral the cascade was built with" + ); + assert_ne!(before_mark.mark_price_ticks, 1); + mark_locker + .materialize_overrides_for_slot(&None, 101) + .await + .unwrap(); + let after_mark = hawkeye_bbo_for_market(&graph, &mark_locker, orderbook, spline); + assert_eq!( + after_mark.mark_price_ticks, 1, + "stage 1 shocks the mark the program itself reads" + ); + + // Reference divergence moves the cached index away from the mark and leaves the mark. + let (reference_locker, graph) = phoenix_behavior_locker().await; + let (symbol, orderbook, spline) = graph.markets[0].clone(); + let before_reference = hawkeye_bbo_for_market(&graph, &reference_locker, orderbook, spline); + reference_locker + .register_scenario( + phoenix_reference_price_scenario( + graph.perp_asset_map, + &symbol, + "80000", + "120000", + false, + ), + Some(100), + ) + .unwrap(); + reference_locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + let after_reference = hawkeye_bbo_for_market(&graph, &reference_locker, orderbook, spline); + assert_eq!( + after_reference.mark_price_ticks, before_reference.mark_price_ticks, + "the divergence preserves the mark it diverges from" + ); + assert_ne!( + after_reference.index_price_ticks, before_reference.index_price_ticks, + "the cached reference the program reads must move" + ); + assert_ne!( + after_reference.index_price_ticks, after_reference.mark_price_ticks, + "spot and perp references diverging from the mark is the whole scenario" + ); + + // The second live market proves the preparations are not market-specific. + let (second_locker, graph) = phoenix_behavior_locker().await; + let (symbol, orderbook, spline) = graph.markets[1].clone(); + let before_second = hawkeye_bbo_for_market(&graph, &second_locker, orderbook, spline); + second_locker + .register_scenario( + phoenix_direct_mark_scenario(graph.perp_asset_map, &symbol, "1", false), + Some(100), + ) + .unwrap(); + second_locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + let after_second = hawkeye_bbo_for_market(&graph, &second_locker, orderbook, spline); + assert_ne!(before_second.mark_price_ticks, 1); + assert_eq!(after_second.mark_price_ticks, 1); +} + +/// A surfnet holding the live Phoenix account graph and a discovered live trader, with the +/// two SBF programs loaded. Nothing here looks for a market or trader in an interesting +/// state: the collateral stress below produces the risk condition the assertions check. +async fn phoenix_behavior_locker() -> (SurfnetSvmLocker, PhoenixLiveGraph) { + let eternal_program = deployed_program(ETERNAL_PROGRAMDATA, "eternal").await; + let hawkeye_program = deployed_program(HAWKEYE_PROGRAMDATA, "hawkeye").await; + let graph = phoenix_live_graph().await; + + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + locker.with_svm_writer(|svm_writer| { + let mut clock = svm_writer.inner.get_sysvar::(); + clock.slot = 0; + svm_writer.inner.set_sysvar(&clock); + svm_writer + .inner + .svm + .add_program(crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_ETERNAL_PROGRAM_ID, &eternal_program) + .unwrap(); + svm_writer + .inner + .svm + .add_program(HAWKEYE_PROGRAM_ID, &hawkeye_program) + .unwrap(); + for (address, account) in &graph.accounts { + svm_writer.set_account(address, account.clone()).unwrap(); + } + }); + + (locker, graph) +} + +async fn phoenix_live_graph() -> PhoenixLiveGraph { + let mut cache = live_graph_cache().lock().await; + if let Some(cached) = cache.as_ref() { + return cached.clone(); + } + + let global_account = fetch(&[ + crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_GLOBAL_CONFIG, + ]) + .await + .remove(0); + let global = phoenix_rise_accounts::global_config::GlobalConfig::try_from_account_bytes( + &global_account.data, + ) + .expect("live GlobalConfig decodes"); + let perp_asset_map = Pubkey::new_from_array(global.perp_asset_map_key()); + let global_trader_index = Pubkey::new_from_array(global.global_trader_index_header_key()); + let active_trader_buffer = Pubkey::new_from_array(global.active_trader_buffer_header_key()); + + let supporting = fetch(&[perp_asset_map, global_trader_index, active_trader_buffer]).await; + let map = PerpAssetMap::try_from_account_bytes(&supporting[0].data) + .expect("live PerpAssetMap decodes"); + let symbols = + crate::scenarios::protocols::phoenix_eternal::v1::state_builder::phoenix_market_symbols( + perp_asset_map, + &supporting[0], + ) + .expect("live PerpAssetMap lists its markets"); + assert!( + symbols.len() >= 2, + "no eligible live candidate: the preparations claim to work for any market, which \ + needs two live ones to show, found {symbols:?}" + ); + + let mut markets = Vec::new(); + let mut accounts = vec![ + ( + crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_GLOBAL_CONFIG, + global_account, + ), + (perp_asset_map, supporting[0].clone()), + (global_trader_index, supporting[1].clone()), + (active_trader_buffer, supporting[2].clone()), + ]; + for symbol in symbols.into_iter().take(2) { + let entry = map + .find_by_symbol(&symbol) + .expect("symbol lookup") + .expect("listed symbol"); + let orderbook = + Pubkey::new_from_array(entry.metadata.static_market_params().market_account); + let spline = phoenix_rise_accounts::pda::derive_spline_collection_address( + &crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_ETERNAL_PROGRAM_ID, + &orderbook, + ); + let market_accounts = fetch(&[orderbook, spline]).await; + accounts.push((orderbook, market_accounts[0].clone())); + accounts.push((spline, market_accounts[1].clone())); + markets.push((symbol, orderbook, spline)); + } + + let (trader, trader_account) = crate::tests::phoenix::live_trader_with_position().await; + accounts.push((trader, trader_account)); + + let graph = PhoenixLiveGraph { + accounts, + global_trader_index, + active_trader_buffer, + perp_asset_map, + trader, + markets, + }; + *cache = Some(graph.clone()); + + graph +} + +/// The live accounts a Phoenix behavioral run needs, with the addresses the Hawkeye +/// margin view expects to be passed alongside them. +#[derive(Clone)] +struct PhoenixLiveGraph { + accounts: Vec<(Pubkey, Account)>, + global_trader_index: Pubkey, + active_trader_buffer: Pubkey, + perp_asset_map: Pubkey, + trader: Pubkey, + /// Two live markets, as symbol plus the orderbook and spline accounts the + /// Hawkeye reader wants for it. + markets: Vec<(String, Pubkey, Pubkey)>, +} + +fn hawkeye_margin(locker: &SurfnetSvmLocker, graph: &PhoenixLiveGraph) -> HawkeyeMarginView { + let payer = Keypair::new(); + locker.with_svm_writer(|svm_writer| { + svm_writer + .inner + .airdrop(&payer.pubkey(), 1_000_000_000) + .unwrap(); + let instruction = Instruction { + program_id: HAWKEYE_PROGRAM_ID, + accounts: vec![ + AccountMeta::new_readonly( + crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_ETERNAL_PROGRAM_ID, + false, + ), + AccountMeta::new_readonly( + crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_GLOBAL_CONFIG, + false, + ), + AccountMeta::new_readonly(graph.global_trader_index, false), + AccountMeta::new_readonly(graph.active_trader_buffer, false), + AccountMeta::new_readonly(graph.perp_asset_map, false), + AccountMeta::new_readonly(graph.trader, false), + ], + data: HAWKEYE_VIEW_MARGIN_DISCRIMINANT.to_vec(), + }; + let transaction = Transaction::new_signed_with_payer( + // A live trader carries more positions than the fixture did, and the margin view + // walks all of them. + &[ + ComputeBudgetInstruction::set_compute_unit_limit(1_400_000), + instruction, + ], + Some(&payer.pubkey()), + &[&payer], + svm_writer.inner.svm.latest_blockhash(), + ); + let metadata = svm_writer.inner.send_transaction(transaction).unwrap(); + let margin = + bytemuck::pod_read_unaligned::(&metadata.return_data.data); + assert_eq!(margin.magic, HAWKEYE_MARGIN_RETURN_MAGIC); + margin + }) +} + +fn hawkeye_bbo_for_market( + graph: &PhoenixLiveGraph, + locker: &SurfnetSvmLocker, + orderbook: Pubkey, + spline: Pubkey, +) -> HawkeyeBboView { + let payer = Keypair::new(); + locker.with_svm_writer(|svm_writer| { + svm_writer + .inner + .airdrop(&payer.pubkey(), 1_000_000_000) + .unwrap(); + let instruction = Instruction { + program_id: HAWKEYE_PROGRAM_ID, + accounts: vec![ + AccountMeta::new_readonly( + crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_ETERNAL_PROGRAM_ID, + false, + ), + AccountMeta::new_readonly( + crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_GLOBAL_CONFIG, + false, + ), + AccountMeta::new_readonly(graph.global_trader_index, false), + AccountMeta::new_readonly(graph.active_trader_buffer, false), + AccountMeta::new_readonly(graph.perp_asset_map, false), + AccountMeta::new_readonly(orderbook, false), + AccountMeta::new_readonly(spline, false), + ], + data: HAWKEYE_VIEW_BBO_DISCRIMINANT.to_vec(), + }; + let transaction = Transaction::new_signed_with_payer( + // A live trader carries more positions than the fixture did, and the margin view + // walks all of them. + &[ + ComputeBudgetInstruction::set_compute_unit_limit(1_400_000), + instruction, + ], + Some(&payer.pubkey()), + &[&payer], + svm_writer.inner.svm.latest_blockhash(), + ); + let metadata = svm_writer.inner.send_transaction(transaction).unwrap(); + let bbo = bytemuck::pod_read_unaligned::(&metadata.return_data.data); + assert_eq!(bbo.magic, HAWKEYE_BBO_RETURN_MAGIC); + bbo + }) +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +struct HawkeyeMarginView { + magic: u64, + version: u16, + position_count: u16, + risk_state: u8, + risk_tier: u8, + is_liquidatable: u8, + padding: u8, + collateral_quote_lots: i64, + effective_collateral_quote_lots: i64, + free_collateral_quote_lots: i64, + withdrawable_collateral_quote_lots: u64, + initial_margin_quote_lots: u64, + maintenance_margin_quote_lots: u64, + cancel_margin_quote_lots: u64, + backstop_margin_quote_lots: u64, + high_risk_margin_quote_lots: u64, + unrealized_pnl_quote_lots: i64, + discounted_unrealized_pnl_quote_lots: i64, + unsettled_funding_quote_lots: i64, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +struct HawkeyeBboView { + magic: u64, + version: u16, + flags: u8, + padding: [u8; 5], + best_bid_ticks: u64, + best_ask_ticks: u64, + mark_price_ticks: u64, + index_price_ticks: u64, + mark_price_last_updated_slot: u64, + index_price_last_updated_slot: u64, +} + +const HAWKEYE_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("RiSeVw3ZjNfsaXPRb4mgaqYaEEt41pNNJoDvVh7pgQj"); + +const HAWKEYE_MARGIN_RETURN_MAGIC: u64 = 0x955f5b9d3dff253f; + +const HAWKEYE_BBO_RETURN_MAGIC: u64 = 0xefca1fa31fa74171; + +fn phoenix_collateral_scenario( + trader: Pubkey, + collateral: serde_json::Value, + fetch_before_use: bool, +) -> surfpool_types::Scenario { + let mut scenario = surfpool_types::Scenario::new( + "Phoenix collateral stress".to_string(), + "Phoenix Trader collateral override".to_string(), + ); + let mut instance = surfpool_types::OverrideInstance::new( + "phoenix-trader-collateral-stress".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(trader.to_string()), + ) + .with_values(HashMap::from([( + "quote_lot_collateral".to_string(), + collateral, + )])); + instance.fetch_before_use = fetch_before_use; + scenario.add_override(instance); + scenario +} + +fn phoenix_direct_mark_scenario( + perp_asset_map: Pubkey, + symbol: &str, + target_ticks: &str, + fetch_before_use: bool, +) -> surfpool_types::Scenario { + let mut scenario = surfpool_types::Scenario::new( + "Phoenix direct mark risk shock".to_string(), + "Phoenix direct mark override".to_string(), + ); + let mut instance = surfpool_types::OverrideInstance::new( + "phoenix-direct-mark-risk-shock".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(perp_asset_map.to_string()), + ) + .with_values(HashMap::from([ + ("symbol".to_string(), serde_json::json!(symbol)), + ("target_ticks".to_string(), serde_json::json!(target_ticks)), + ])); + instance.fetch_before_use = fetch_before_use; + scenario.add_override(instance); + scenario +} + +fn phoenix_reference_price_scenario( + perp_asset_map: Pubkey, + symbol: &str, + spot_ticks: &str, + perp_ticks: &str, + fetch_before_use: bool, +) -> surfpool_types::Scenario { + let mut scenario = surfpool_types::Scenario::new( + "Phoenix spot/perp reference divergence".to_string(), + "Phoenix reference-price override".to_string(), + ); + let mut instance = surfpool_types::OverrideInstance::new( + "phoenix-reference-price-divergence".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(perp_asset_map.to_string()), + ) + .with_values(HashMap::from([ + ("symbol".to_string(), serde_json::json!(symbol)), + ("spot_ticks".to_string(), serde_json::json!(spot_ticks)), + ("perp_ticks".to_string(), serde_json::json!(perp_ticks)), + ])); + instance.fetch_before_use = fetch_before_use; + scenario.add_override(instance); + scenario +} + +#[tokio::test(flavor = "multi_thread")] +async fn materialize_patches_only_phoenix_trader_collateral() { + let trader = Pubkey::new_unique(); + let base = phoenix_trader_fixture(6_996_825_500); + let scenario = phoenix_collateral_scenario(trader, serde_json::json!("371499999"), false); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + locker.with_svm_writer(|svm_writer| { + svm_writer + .set_account( + &trader, + Account { + lamports: 1, + data: base.clone(), + owner: crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + }); + + locker.register_scenario(scenario, Some(100)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + + let after = locker + .with_svm_reader(|svm_reader| svm_reader.get_account(&trader)) + .unwrap() + .unwrap(); + let header = TraderHeader::try_read_from_account_bytes(&after.data).unwrap(); + assert_eq!( + header.trader_state.quote_lot_collateral.as_inner(), + 371_499_999 + ); + assert_eq!(after.data.len(), base.len()); + assert!( + base.iter() + .zip(&after.data) + .enumerate() + .filter(|(_, (before, after))| before != after) + .all(|(offset, _)| (88..96).contains(&offset)) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn materialize_applies_phoenix_collateral_without_fetch_before_use() { + let trader = Pubkey::new_unique(); + let scenario = phoenix_collateral_scenario(trader, serde_json::json!("371499999"), false); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + locker.with_svm_writer(|svm_writer| { + svm_writer + .set_account( + &trader, + Account { + lamports: 1, + data: phoenix_trader_fixture(6_996_825_500), + owner: crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + }); + + locker.register_scenario(scenario, Some(100)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + + let after = locker + .with_svm_reader(|svm_reader| svm_reader.get_account(&trader)) + .unwrap() + .unwrap(); + let header = TraderHeader::try_read_from_account_bytes(&after.data).unwrap(); + assert_eq!( + header.trader_state.quote_lot_collateral.as_inner(), + 371_499_999 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn materialize_applies_a_phoenix_direct_mark_override() { + let perp_asset_map = Pubkey::new_unique(); + let base = crate::scenarios::protocols::phoenix_eternal::v1::state_builder::tests::perp_asset_map_fixture(); + let mut scenario = surfpool_types::Scenario::new( + "Phoenix direct mark risk shock".to_string(), + "Phoenix direct mark override".to_string(), + ); + let mut instance = surfpool_types::OverrideInstance::new( + "phoenix-direct-mark-risk-shock".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(perp_asset_map.to_string()), + ) + .with_values(HashMap::from([ + ("symbol".to_string(), serde_json::json!("SOL")), + ("target_ticks".to_string(), serde_json::json!("1")), + ])); + instance.fetch_before_use = false; + scenario.add_override(instance); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + locker.with_svm_writer(|svm_writer| { + svm_writer + .set_account( + &perp_asset_map, + Account { + lamports: 1, + data: base.clone(), + owner: crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + }); + + locker.register_scenario(scenario, Some(100)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + + let after = locker + .with_svm_reader(|svm_reader| svm_reader.get_account(&perp_asset_map)) + .unwrap() + .unwrap(); + let map = PerpAssetMap::try_from_account_bytes(&after.data).unwrap(); + let mark_price = map + .find_by_symbol("SOL") + .unwrap() + .unwrap() + .metadata + .oracle_price() + .mark_price + .price; + assert_eq!(mark_price.ticks.as_inner(), 1); + assert_eq!(mark_price.slot, 100); + assert_eq!(after.data.len(), base.len()); + assert!( + base.iter() + .zip(&after.data) + .filter(|(before, after)| before != after) + .count() + <= 16 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn materialize_refreshes_phoenix_reference_price_slots() { + let perp_asset_map = Pubkey::new_unique(); + let base = crate::scenarios::protocols::phoenix_eternal::v1::state_builder::tests::perp_asset_map_fixture(); + let before = PerpAssetMap::try_from_account_bytes(&base).unwrap(); + let before_mark = before + .find_by_symbol("SOL") + .unwrap() + .unwrap() + .metadata + .oracle_price() + .mark_price + .price + .ticks + .as_inner(); + let scenario = phoenix_reference_price_scenario(perp_asset_map, "SOL", "8000", "7000", false); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let locker = SurfnetSvmLocker::new(svm); + locker.with_svm_writer(|svm_writer| { + svm_writer + .set_account( + &perp_asset_map, + Account { + lamports: 1, + data: base, + owner: crate::scenarios::protocols::phoenix_eternal::v1::state_builder::PHOENIX_ETERNAL_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + }); + + let mut scenario = scenario; + scenario.overrides[0].account = + surfpool_types::AccountAddress::Pubkey(perp_asset_map.to_string()); + scenario.overrides[0] + .values + .insert("symbol".to_string(), serde_json::json!("SOL")); + locker.register_scenario(scenario, Some(100)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 100) + .await + .unwrap(); + + let after = locker + .with_svm_reader(|svm_reader| svm_reader.get_account(&perp_asset_map)) + .unwrap() + .unwrap(); + let map = PerpAssetMap::try_from_account_bytes(&after.data).unwrap(); + let entry = map.find_by_symbol("SOL").unwrap().unwrap(); + let price = entry.metadata.oracle_price(); + + assert_eq!(price.mark_price.price.ticks.as_inner(), before_mark); + assert!( + price + .mark_price + .spot_price_component + .last_exchange_spot_price + .iter() + .all(|value| value.slot == 100 && value.ticks.as_inner() == 8_000) + ); + assert!( + price + .mark_price + .perp_price_component + .last_exchange_perp_price + .iter() + .all(|value| value.slot == 100 && value.ticks.as_inner() == 7_000) + ); +} + +fn phoenix_trader_fixture(collateral: i64) -> Vec { + let header_len = core::mem::size_of::(); + let mut data = vec![0_u8; header_len + 16 + 80]; + data[..8].copy_from_slice(&PhoenixAccount::Trader.discriminant()); + data[88..96].copy_from_slice(&collateral.to_le_bytes()); + data[112..116].copy_from_slice(&2_u32.to_le_bytes()); + data[header_len..header_len + 8].copy_from_slice(&1_u64.to_le_bytes()); + data[header_len + 8..header_len + 16].copy_from_slice(&2_u64.to_le_bytes()); + data[header_len + 16..header_len + 24].copy_from_slice(&42_u64.to_le_bytes()); + data +} diff --git a/crates/core/src/tests/pump/mod.rs b/crates/core/src/tests/pump/mod.rs index 960ce8e2f..ba8917c73 100644 --- a/crates/core/src/tests/pump/mod.rs +++ b/crates/core/src/tests/pump/mod.rs @@ -45,18 +45,14 @@ use crate::{ }, }, storage::tests::TestType, - surfnet::{ - GetAccountResult, locker::SurfnetSvmLocker, remote::SurfnetRemoteClient, svm::SurfnetSvm, - }, + surfnet::{locker::SurfnetSvmLocker, svm::SurfnetSvm}, tests::{ helpers::get_free_port, integration::{RunloopGuard, spawn_runloop, wait_for_ready_and_connected}, + live::{DEFAULT_RPC_URL, RPC_URL_ENV, diff_indices, fetch}, }, }; -const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; -const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; - const PUMP: Pubkey = Pubkey::from_str_const("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"); const PAMM: Pubkey = Pubkey::from_str_const("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA"); const FEE_PROGRAM: Pubkey = Pubkey::from_str_const("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ"); @@ -103,38 +99,6 @@ const POOL_LP_SUPPLY_OFFSET: usize = 203; const POOL_VIRTUAL_QUOTE_RESERVES_OFFSET: usize = 245; const POOL_COIN_CREATOR_OFFSET: usize = 211; -/// Fetches the accounts in one request, so every account returned is from the same slot. -async fn fetch(addresses: &[Pubkey]) -> Vec { - let client = SurfnetRemoteClient::new( - std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), - ); - - client - .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) - .await - .unwrap_or_else(|e| panic!("failed to fetch {addresses:?} from mainnet: {e}")) - .into_iter() - .zip(addresses) - .map(|(result, address)| match result { - GetAccountResult::FoundAccount(_, account, _) - | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account, - GetAccountResult::None(_) => { - panic!("{address} no longer exists on mainnet; the test needs a new address") - } - }) - .collect() -} - -/// Byte indices at which two buffers differ. -fn diff_indices(a: &[u8], b: &[u8]) -> Vec { - a.iter() - .zip(b.iter()) - .enumerate() - .filter(|(_, (x, y))| x != y) - .map(|(i, _)| i) - .collect() -} - /// A failure here means a bundled IDL disagrees with the live on-chain layout. #[tokio::test] async fn real_mainnet_accounts_round_trip_unchanged() { diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 7ce4c3e08..7e2b265b4 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -19,6 +19,7 @@ use start_surfnet::StartSurfnetResponse; use surfpool_core::{ scenarios::{ TemplateRegistry, + protocols::phoenix_eternal::v1::state_builder::build_phoenix_collateral_scenario, protocols::pump::v1::graduation_builder::{ build_pump_graduation_scenario, pump_graduation_addresses, }, @@ -130,6 +131,21 @@ pub struct CreatePumpGraduationScenarioParams { pub surfnet_port: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreatePhoenixCollateralScenarioParams { + #[schemars(description = "Phoenix Eternal Trader account pubkey.")] + pub trader: String, + #[schemars( + description = "Exact signed collateral target in quote lots, encoded as a decimal string." + )] + pub target_quote_lots: String, + #[schemars( + description = "The port of the target running local surfnet instance (e.g., 8899, 18899, 28899, etc.). Omit to use the default port, 8899." + )] + pub surfnet_port: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct StartSurfnetWithTokenAccountsParams { #[schemars( @@ -1001,6 +1017,45 @@ impl Surfpool { self.stage_scenario(preparation.scenario).await } + #[tool( + description = "Creates an editable Phoenix Eternal Trader collateral-stress scenario. Requires a Trader pubkey and exact signed quote lots as a decimal string. The backend fetches and validates the live Trader account. This prepares risk state; it does not guarantee or execute liquidation." + )] + async fn create_phoenix_collateral_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let trader = match Pubkey::from_str(params.trader.trim()) { + Ok(trader) => trader, + Err(error) => { + return Ok(scenario_tool_error(format!( + "Invalid Trader pubkey: {error}" + ))); + } + }; + let accounts = match self + .fetch_surfnet_accounts(params.surfnet_port, &[trader]) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let Some(trader_account) = accounts[0].as_ref() else { + return Ok(scenario_tool_error(format!( + "Phoenix Trader account {trader} was not found" + ))); + }; + let preparation = match build_phoenix_collateral_scenario( + trader, + trader_account, + ¶ms.target_quote_lots, + ) { + Ok(preparation) => preparation, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(preparation.scenario).await + } + #[tool( description = "Fetches ALL available override templates. MUST be called before create_scenario to get valid templateId values and property names. Constants are summarized as {label, description, optionsCount} - resolve an actual option value with search_constant_options." )] @@ -1298,6 +1353,43 @@ mod tests { ); } + #[tokio::test] + async fn phoenix_collateral_rejects_invalid_inputs_before_rpc() { + let surfpool = Surfpool::new(); + + let collateral = surfpool + .create_phoenix_collateral_scenario(Parameters(CreatePhoenixCollateralScenarioParams { + surfnet_port: None, + trader: "not-a-pubkey".to_string(), + target_quote_lots: "1".to_string(), + })) + .await + .expect("tool result"); + assert!( + json_of(&collateral)["error"] + .as_str() + .expect("error") + .contains("Invalid Trader pubkey") + ); + } + + #[test] + fn phoenix_collateral_requires_exact_string_inputs() { + assert!( + serde_json::from_value::(serde_json::json!({ + "trader": "GHkq1eHeZGi96RmGPZ23e3BDEcdfpPzsPwpYdm17SWgc", + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "trader": "GHkq1eHeZGi96RmGPZ23e3BDEcdfpPzsPwpYdm17SWgc", + "targetQuoteLots": 1, + })) + .is_err() + ); + } + fn json_of(result: &CallToolResult) -> serde_json::Value { let text = &result.content[0].as_text().expect("text content").text; serde_json::from_str(text).expect("valid JSON payload")