From b14a3ac2dd18256bcdb3f749eab664561d08884c Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:08:01 +0300 Subject: [PATCH 1/7] feat(scenarios): add GoonFi state preparation GoonFi is a two-account dark AMM with no IDL: a 32-byte external price oracle plus a 2048-byte market carrying the reference band that guards it. - Four raw-layout templates over the two accounts (price, reference band, freshness, stale quote), plus a live market catalog read from program accounts. - Two builders. Price moves the oracle bid and ask together with the market's reference band and a persistent freshness stamp, because the deployed program rejects a decoupled move with 0x24. Liquidity resolves both token vaults from the market's own pointers and scales their balances. - Three MCP tools on the generic scenario path: list_goonfi_markets, create_goonfi_price_scenario and create_goonfi_liquidity_scenario. No protocol HTTP endpoints. - The Slot raw encoding now takes a width, so the 4-byte freshness field no longer clobbers the decay multiplier stored beside it. - Live gated tests against the deployed program. The shared live-test helper duplicates the Tessera branch's on purpose; they fold together when the branches converge. --- Cargo.lock | 3 + crates/core/Cargo.toml | 2 + .../src/scenarios/protocols/goonfi/README.md | 139 +++ .../src/scenarios/protocols/goonfi/mod.rs | 1 + .../protocols/goonfi/v1/liquidity.rs | 417 +++++++ .../protocols/goonfi/v1/market_overrides.yaml | 55 + .../scenarios/protocols/goonfi/v1/markets.rs | 284 +++++ .../src/scenarios/protocols/goonfi/v1/mod.rs | 15 + .../protocols/goonfi/v1/oracle_overrides.yaml | 130 ++ .../scenarios/protocols/goonfi/v1/price.rs | 447 +++++++ crates/core/src/scenarios/protocols/mod.rs | 1 + crates/core/src/scenarios/registry.rs | 25 +- crates/core/src/surfnet/svm.rs | 11 +- crates/core/src/tests/goonfi/mod.rs | 1110 +++++++++++++++++ crates/core/src/tests/live.rs | 68 + crates/core/src/tests/mod.rs | 4 + crates/mcp/Cargo.toml | 2 + crates/mcp/src/surfpool/mod.rs | 256 +++- crates/types/src/scenarios.rs | 134 +- 19 files changed, 3084 insertions(+), 20 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/goonfi/README.md create mode 100644 crates/core/src/scenarios/protocols/goonfi/mod.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/markets.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/mod.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/price.rs create mode 100644 crates/core/src/tests/goonfi/mod.rs create mode 100644 crates/core/src/tests/live.rs diff --git a/Cargo.lock b/Cargo.lock index 141bce739..eda85fc42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12154,6 +12154,7 @@ dependencies = [ "solana-packet", "solana-program-option 3.1.0", "solana-program-pack 3.1.0", + "solana-program-runtime", "solana-pubkey 3.0.0", "solana-pubsub-client", "solana-rpc-client", @@ -12218,6 +12219,8 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "solana-account 4.3.1", + "solana-commitment-config", "solana-keypair", "solana-pubkey 3.0.0", "solana-signer", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 53f510da0..36631ffb7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -115,6 +115,8 @@ axum = { version = "0.8", default-features = false, features = ["tokio", "http1" [dev-dependencies] ed25519-dalek = "1.0.1" +# Only the GoonFi live suite uses it, to host the CPI wrapper that drives the deployed program. +solana-program-runtime = "4.1.2" libsecp256k1 = "0.7.2" p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } test-case = { workspace = true } diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md new file mode 100644 index 000000000..74a7a134b --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -0,0 +1,139 @@ +# GoonFi + +GoonFi V2 uses raw account layouts rather than an IDL. Each market points to a 32-byte +oracle owned by a companion publisher program. The oracle stores bid/ask prices; the +market stores the reference prices that guard them. Surfpool prepares these accounts +before a user runs a strategy. Product scenarios do not construct or submit swaps. + +## Pinned deployment + +The live tests in `crates/core/src/tests/goonfi/mod.rs` check these ProgramData sizes, +deployment slots and ELF hashes before replaying the program: + +| | Trading program | Oracle publisher | +|---|---|---| +| Program | `goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE` | `dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu` | +| ProgramData | `124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s` | `7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z` | +| ProgramData bytes | 252,429 | 557 | +| Deployment slot | 438563879 | 404369628 | +| ELF SHA-256 | `73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e` | `0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695` | + +A deployment change requires revalidation. These are test pins, not an upgrade-monitoring +service or a claim that every future deployment has the same layout. + +## Layouts and templates + +A market is 2048 bytes with magic `30 bc 2f 35 34 58 32 9a` at offset 0. Its base/quote +mints are at offsets 80/112, vaults at 144/176, and oracle pointer at 208. The oracle is +32 bytes with no discriminator. Both YAML layouts declare their expected program owner; +the shared materializer checks ownership before writing, then validates size, optional +magic bytes and write bounds. A failed owner check skips the override with a warning. + +| Template | Account | Fields | +|---|---|---| +| `goonfi-price` | Oracle | Bid and ask, u64 at offsets 0 and 8 | +| `goonfi-stale-quote` | Oracle | Freshness slot, u32 at offset 16; default lead -2000 | +| `goonfi-freshness` | Oracle | Freshness slot, u32 at offset 16; default lead 0 | +| `goonfi-reference-band` | Market | Reference prices, u64 at offsets 1712 and 1720 | + +Prices use the human pair price multiplied by `10^6`, independent of mint decimals. +For example, 99.74 quote tokens per base token becomes the integer string `"99740000"`. +Use strings for u64 price values to preserve precision in JSON and Studio. + +Slot templates write exactly four bytes. The u32 multiplier at offset 20 and the +millisecond timestamp at offset 24 remain untouched. A slot value of `null` selects the +template's default lead; an integer specifies a lead relative to the materialization +slot. The resulting slot must fit u32. + +## Catalog and price scenario + +The backend exposes three GoonFi MCP tools: + +- `list_goonfi_markets` discovers program accounts and validates market, oracle and mint + relationships. It returns market/oracle addresses, labels, mint addresses and decimals. + The YAML files contain no market catalog, and discovery does not require a fixed count. +- `create_goonfi_price_scenario` accepts a market address and a positive human price with + up to six decimal places. It resolves the oracle from the market account, validates + both accounts, and composes three overrides: equal oracle bid/ask, equal market reference + prices, and persistent freshness. An omitted market selects the default SOL/USDC market. +- `create_goonfi_liquidity_scenario` accepts a market address and per-vault remaining basis + points. It resolves both token vaults from the market's own pointers (offsets 144 and + 176), reads each current balance, validates the vault and oracle owners, and scales each + vault through `spl-token-account-balance`: 0 drains a vault so a swap rejects with `0x1`, + 10000 leaves it unchanged. A persistent freshness override keeps the rejection about + liquidity rather than a stale quote. Both default to 0; an omitted market selects the + default SOL/USDC market. + +These tools accept optional `surfnet_port`, defaulting to 8899, and read through the local +Surfnet RPC. Missing accounts fall back to that Surfnet's datasource. The price tool +stages through the shared Studio scenario API; Play registers the scenario. + +Studio's PMM fair-value dialog selects a protocol, a live market and a human price. It +calls these tools through Studio MCP without forwarding `rpcUrl` or `surfnet_port`, +matching the Tessera dialog convention. Consequently, these Studio GoonFi calls use the +backend's default RPC port. Studio retains only each catalog entry's market address and +label; the backend resolves the oracle when creating a price scenario. + +The price builder does not set `fetchBeforeUse`: the accounts read at creation retain +local edits, and only the specified fields are changed. Freshness uses `persist: true` +to stamp each subsequent materialization slot. These settings do not establish +transactional atomicity across all overrides in a scenario. + +## Composing other prepared states + +The four templates remain available through the generic scenario editor and AI flow. +There are no dedicated GoonFi spread or delayed-event builders. + +For a stale quote, target the oracle returned by `list_goonfi_markets` with +`goonfi-stale-quote`. Do not run a persistent freshness override over the same interval: +it would erase the stale state. Recovery can use `goonfi-freshness` at a later relative +slot. The Studio AI chip requests a stale-quote scenario through this generic flow. + +For depletion, `create_goonfi_liquidity_scenario` resolves the vaults from the market and +scales each balance for you; the AI chip calls it directly. Composing the same by hand +means reading the selected vault address from market offset 144 or 176, checking its token +program, and using `spl-token-account-balance` with an absolute amount, applied once. The +override does not recalculate percentages at execution time. + +## Behavioral verification + +The live suite fetches deployed account data and runs the pinned trading ELF in LiteSVM, +using a builtin wrapper for the Jupiter-shaped CPI. It checks: + +- Unchanged encoding produces the same fill; coupled price/reference changes alter output. +- Raising only the bid or lowering only the ask rejects with `0x24` (reference-band guard). +- Quotes decay with slot age and eventually reject with `0x15`. Changing the multiplier + changes decay in the tested fixture; stamping the slot restores freshness. Changing + the wall-clock timestamp alone does not change the tested fill. +- An impossible minimum output rejects with `0xf`. +- A successful sell still fills with exactly enough quote inventory. One atomic unit less + or an empty quote vault rejects with `0x1`, with the trade input held constant. +- The price builder's three overrides register and materialize through the production + path on two markets, preserving unrelated bytes and refreshing the u32 slot afterwards. +- Live discovery returns valid market/oracle relationships without a fixed catalog count. + +Behavior fixtures fund local vaults to at least 10,000 whole tokens and retain wrapped SOL +backing. This isolates price, ageing and inventory changes from fluctuating live liquidity; +it does not prove that the same trade currently has sufficient mainnet liquidity. Layout +and discovery checks use unfunded fetched accounts. Owner-predicate unit tests live in +`crates/types/src/scenarios.rs`. This suite does not provide a `pmm-sim` differential run +or a Studio browser test. + +Run all GoonFi unit and live checks serially: + +```bash +SURFPOOL_TEST_RPC_URL= cargo test -p surfpool-core --features integration-tests \ + goonfi -- --test-threads=1 --nocapture +``` + +The RPC variable is optional and defaults to the public mainnet endpoint. A private endpoint +can avoid public RPC rate limits. Re-run after a program upgrade or account-layout change. + +## Known boundaries + +The staleness window's on-chain source and exact decay formula remain unidentified. +Observed windows vary by market and time; historical slot ages are not fixed protocol +limits. The global account and other market fields are forked without assigned override +semantics. No enable/disable field is exposed. Direct top-level swaps are not covered by +the CPI replay, and the exact tolerance of the reference-band guard is not established +by these tests. diff --git a/crates/core/src/scenarios/protocols/goonfi/mod.rs b/crates/core/src/scenarios/protocols/goonfi/mod.rs new file mode 100644 index 000000000..a3a6d96c3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs new file mode 100644 index 000000000..adf3f58bd --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -0,0 +1,417 @@ +//! GoonFi liquidity state preparation. +//! +//! A market draws liquidity from two SPL token vaults whose addresses live in the market account +//! at fixed offsets. Unlike price or depth, the balances are not in the protocol account itself but +//! in those separate token accounts, so this scales each vault through the generic +//! `spl-token-account-balance` template. Draining a vault to zero makes the deployed program reject +//! a swap with custom error 0x1; a fresh re-stamp keeps that rejection about liquidity and not a +//! stale quote. + +use std::collections::HashMap; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenario}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +use super::{ + GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, +}; + +/// Read, never written, so no template declares them. +const BASE_MINT_OFFSET: usize = 80; +const QUOTE_MINT_OFFSET: usize = 112; +const BASE_VAULT_OFFSET: usize = 144; +const QUOTE_VAULT_OFFSET: usize = 176; +/// The SPL token account amount field. +const AMOUNT_OFFSET: usize = 64; + +const LIQUIDITY_TEMPLATE: &str = "spl-token-account-balance"; +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Both overrides apply on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +/// 10000 basis points leaves a vault untouched; 0 drains it. +const FULL_BPS: u16 = 10_000; + +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiLiquidityPreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub base_vault: Pubkey, + pub quote_vault: Pubkey, + pub base_amount: u64, + pub quote_amount: u64, +} + +/// The two SPL token vaults a market draws liquidity from, read from the market's own pointers. +/// +/// Validates the market first: the shared raw-layout guard has no owner predicate, so the owner +/// check in `validate_goonfi_market_layout` is what keeps these offsets pointed at a real market. +pub fn vault_addresses(market_account: &Account) -> SurfpoolResult<[Pubkey; 2]> { + validate_goonfi_market_layout(market_account)?; + let base = read_pubkey(&market_account.data, BASE_VAULT_OFFSET)?; + let quote = read_pubkey(&market_account.data, QUOTE_VAULT_OFFSET)?; + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(invalid("market carries invalid vault pointers")); + } + Ok([base, quote]) +} + +/// Scales each vault balance to the requested basis points and keeps the quote fresh. +/// +/// `market_account` is the source of truth for the vault and oracle addresses; the three passed +/// accounts are the base vault, quote vault and oracle the caller fetched by those addresses, in +/// that order. A side left at 10000 bps is untouched and gets no override. +pub fn build_goonfi_liquidity_scenario( + market: Pubkey, + market_account: &Account, + base_vault_account: &Account, + quote_vault_account: &Account, + oracle_account: &Account, + base_remaining_bps: u16, + quote_remaining_bps: u16, +) -> SurfpoolResult { + if [base_remaining_bps, quote_remaining_bps] + .iter() + .any(|bps| *bps > FULL_BPS) + { + return Err(invalid( + "remaining liquidity must be 0..=10000 basis points; 0 drains a vault, 10000 leaves it unchanged", + )); + } + if base_remaining_bps == FULL_BPS && quote_remaining_bps == FULL_BPS { + return Err(invalid( + "both vaults left unchanged; set a lower basis point value to drain at least one side", + )); + } + + let [base_vault, quote_vault] = vault_addresses(market_account)?; + let oracle = GoonfiMarket::oracle_address(market_account)?; + validate_goonfi_oracle_layout(oracle_account)?; + + let base_amount = vault_amount(base_vault_account)?; + let quote_amount = vault_amount(quote_vault_account)?; + + let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; + let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + let label = market_label(&base_mint, "e_mint); + + let registry = TemplateRegistry::new(); + let liquidity = template(®istry, LIQUIDITY_TEMPLATE)?; + + let mut scenario = Scenario::new( + format!("GoonFi {label} liquidity drain"), + format!( + "Prepare GoonFi {label} market ({market}) vaults to {} of base and {} of quote liquidity; no swap is sent.", + remaining_label(base_remaining_bps), + remaining_label(quote_remaining_bps) + ), + ); + scenario.tags = vec![ + "goonfi".to_string(), + "pmm".to_string(), + "liquidity-drain".to_string(), + ]; + + for (side, vault, current, bps) in [ + ("base", base_vault, base_amount, base_remaining_bps), + ("quote", quote_vault, quote_amount, quote_remaining_bps), + ] { + if bps == FULL_BPS { + continue; + } + let scaled = (u128::from(current) * u128::from(bps) / u128::from(FULL_BPS)) as u64; + scenario.add_override( + OverrideInstance::new( + liquidity.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(vault.to_string()), + ) + .with_values(HashMap::from([( + "amount".to_string(), + serde_json::json!(scaled.to_string()), + )])) + .with_label(format!("Drain GoonFi {side} vault")), + ); + } + + // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null keeps the + // template's own lead of zero. Persisted so the quote stays inside the staleness window and the + // swap the drained state is proven against is rejected for liquidity (0x1), not a stale quote. + scenario.add_override( + OverrideInstance::new( + FRESHNESS_TEMPLATE.to_string(), + PREPARATION_SLOT, + AccountAddress::Pubkey(oracle.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true), + ); + + Ok(GoonfiLiquidityPreparation { + scenario, + market, + base_vault, + quote_vault, + base_amount, + quote_amount, + }) +} + +/// The SPL token vaults are 32 undiscriminated-looking bytes at the front; the owner check is the +/// real discriminator that keeps a balance write out of a foreign account. +fn vault_amount(account: &Account) -> SurfpoolResult { + if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { + return Err(invalid("vault is not owned by a supported token program")); + } + let bytes: [u8; 8] = account + .data + .get(AMOUNT_OFFSET..AMOUNT_OFFSET + 8) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid("vault is too small to be an SPL token account"))?; + Ok(u64::from_le_bytes(bytes)) +} + +fn remaining_label(bps: u16) -> String { + format!("{}.{:02}%", bps / 100, bps % 100) +} + +fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let bytes: [u8; 32] = data + .get(offset..offset + 32) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid("market vault bytes are truncated"))?; + Ok(Pubkey::new_from_array(bytes)) +} + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenarios::protocols::goonfi::v1::{GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID}; + + const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + const WSOL: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + const USDC: Pubkey = Pubkey::from_str_const("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + + fn market_account(base_vault: &Pubkey, quote_vault: &Pubkey) -> Account { + let mut data = vec![0u8; 2048]; + // Magic tag every live market shares. + data[0..8].copy_from_slice(&[48, 188, 47, 53, 52, 88, 50, 154]); + data[BASE_MINT_OFFSET..BASE_MINT_OFFSET + 32].copy_from_slice(WSOL.as_ref()); + data[QUOTE_MINT_OFFSET..QUOTE_MINT_OFFSET + 32].copy_from_slice(USDC.as_ref()); + data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].copy_from_slice(base_vault.as_ref()); + data[QUOTE_VAULT_OFFSET..QUOTE_VAULT_OFFSET + 32].copy_from_slice(quote_vault.as_ref()); + data[208..240].copy_from_slice(FIXTURE_ORACLE.as_ref()); + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + fn vault(amount: u64) -> Account { + let mut data = vec![0u8; 165]; + data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); + Account { + data, + owner: spl_token_interface::ID, + ..Account::default() + } + } + + fn oracle() -> Account { + Account { + data: vec![0u8; 32], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + #[test] + fn drains_both_vaults_and_keeps_the_quote_fresh() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let market = Pubkey::new_unique(); + let preparation = build_goonfi_liquidity_scenario( + market, + &market_account(&base_vault, "e_vault), + &vault(2_441_078_070_812), + &vault(216_136_231_615), + &oracle(), + 0, + 0, + ) + .unwrap(); + + assert_eq!(preparation.base_vault, base_vault); + assert_eq!(preparation.quote_vault, quote_vault); + // A friendly pair label, not the raw market pubkey. + assert_eq!( + preparation.scenario.name, + "GoonFi SOL/USDC liquidity drain" + ); + let [base, quote, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected base drain, quote drain and freshness overrides"); + }; + assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + assert_eq!(quote.account, AccountAddress::Pubkey(quote_vault.to_string())); + assert_eq!(base.values.get("amount"), Some(&serde_json::json!("0"))); + assert_eq!(quote.values.get("amount"), Some(&serde_json::json!("0"))); + assert!(!base.fetch_before_use); + assert!(!base.persist); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(FIXTURE_ORACLE.to_string()) + ); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn scales_partially_and_skips_an_unchanged_side() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let preparation = build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market_account(&base_vault, "e_vault), + &vault(1_000), + &vault(999), + &oracle(), + 2_500, + FULL_BPS, + ) + .unwrap(); + + let [base, freshness] = &preparation.scenario.overrides[..] else { + panic!("the unchanged quote side must not get an override"); + }; + assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + // 1000 * 2500 / 10000, exact integer arithmetic. + assert_eq!(base.values.get("amount"), Some(&serde_json::json!("250"))); + assert_eq!(freshness.values.len(), 1); + } + + #[test] + fn rejects_bad_basis_points_and_accounts() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let good_market = market_account(&base_vault, "e_vault); + + // Out of range and a no-op leave nothing to prepare. + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &oracle(), + 10_001, + 0 + ) + .is_err() + ); + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &oracle(), + FULL_BPS, + FULL_BPS + ) + .is_err() + ); + + // A foreign account of the same size passes the raw guard, so the owner check must reject. + let foreign_market = Account { + owner: Pubkey::new_unique(), + ..market_account(&base_vault, "e_vault) + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &foreign_market, + &vault(1), + &vault(1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A vault not owned by a token program is not a real vault. + let foreign_vault = Account { + owner: Pubkey::new_unique(), + ..vault(1) + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &foreign_vault, + &vault(1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A foreign oracle carries no magic, so its owner is the only discriminator. + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle() + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &foreign_oracle, + 0, + 0 + ) + .is_err() + ); + } + + #[test] + fn resolves_vault_addresses_from_the_market() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let [base, quote] = vault_addresses(&market_account(&base_vault, "e_vault)).unwrap(); + assert_eq!(base, base_vault); + assert_eq!(quote, quote_vault); + + let mut zero_pointer = market_account(&base_vault, "e_vault); + zero_pointer.data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].fill(0); + assert!(vault_addresses(&zero_pointer).is_err()); + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml new file mode 100644 index 000000000..6a218ccfa --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml @@ -0,0 +1,55 @@ +protocol: GoonFi +version: deployed-438563879 +account_type: MarketState + +# The write target here is the market account itself: 2048 bytes owned by the GoonFi program, +# tagged by the 8 magic bytes every live market shares. Mints, vaults and the oracle pointer live +# in cleartext at fixed offsets; the only fields a product flow writes are the two reference +# prices the deployed program uses as an anti-manipulation band around the oracle. +raw_layout: + account_size: 2048 + owner: goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE + magic: + offset: 0 + bytes: [48, 188, 47, 53, 52, 88, 50, 154] + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: goonfi-reference-band + name: Override GoonFi Reference Band + description: Move the market's reference prices that band-guard the oracle + idl_account_name: MarketState + address: + type: pubkey + value: GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC + properties: + - path: reference_price_a_x1e6 + offset: 1712 + encoding: u64 + label: Reference price A + description: "First reference anchor, human pair price times 10^6. Same scale as the oracle's bid and ask." + - path: reference_price_b_x1e6 + offset: 1720 + encoding: u64 + label: Reference price B + description: "Second reference anchor, human pair price times 10^6. The pair's order is not fixed; scale both by the same factor." + llm_context: | + SET BOTH FIELDS AS ONE INVARIANT, scaled by the same factor as the oracle price move they + accompany. The deployed program rejects a swap with custom error 0x24 when the oracle + price it is about to use falls outside the band these two anchors define, in the + direction unfavorable to the venue: a raised bid blocks sells, a lowered ask blocks buys. + + This template exists as the second half of goonfi-price: apply both to shift a market's + price beyond a fraction of a percent. Use the market address returned by + list_goonfi_markets; its oracle field identifies the paired price account. The + GoonFi price builder composes the pair (plus freshness) automatically; composing by hand + and skipping either account breaks the invariant with error 0x24. + + Set fetchBeforeUse: true so the live market is forked before your reference prices apply; on + a fresh fork the account is not local yet, and an override on a missing account is skipped. + Use false only for a later override that builds on state an earlier override prepared in the + same scenario. diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs new file mode 100644 index 000000000..e4f4a6ba3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -0,0 +1,284 @@ +use std::collections::HashMap; + +use solana_account::Account; +use solana_account_decoder::UiAccountEncoding; +use solana_client::{ + rpc_config::RpcAccountInfoConfig, + rpc_filter::{Memcmp, RpcFilterType}, +}; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use surfpool_types::VERIFIED_TOKENS_BY_SYMBOL; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, + surfnet::remote::SurfnetRemoteClient, + types::MintAccount, +}; + +use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket}; + +#[derive(Debug, PartialEq)] +pub struct GoonfiDiscoveredMarket { + pub address: Pubkey, + pub oracle: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub base_decimals: u8, + pub quote_decimals: u8, +} + +impl GoonfiDiscoveredMarket { + pub fn label(&self) -> String { + market_label(&self.base_mint, &self.quote_mint) + } +} + +/// A human pair label from the two mints, e.g. "SOL/USDC". Falls back to a mint's full address +/// when it is not in the verified token list, so an unknown pair is still uniquely named. +pub fn market_label(base_mint: &Pubkey, quote_mint: &Pubkey) -> String { + let symbol = |mint: &Pubkey| { + let address = mint.to_string(); + VERIFIED_TOKENS_BY_SYMBOL + .values() + .filter(|token| token.address == address) + .map(|token| token.symbol.as_str()) + .min() + .map(str::to_string) + .unwrap_or(address) + }; + format!("{}/{}", symbol(base_mint), symbol(quote_mint)) +} + +fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 3]> { + let oracle = GoonfiMarket::oracle_address(account)?; + let base = Pubkey::new_from_array(account.data[80..112].try_into().unwrap()); + let quote = Pubkey::new_from_array(account.data[112..144].try_into().unwrap()); + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(SurfpoolError::internal( + "GoonFi market has invalid mint identities", + )); + } + Ok([base, quote, oracle]) +} + +fn mint_decimals(account: &Account) -> SurfpoolResult { + if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { + return Err(SurfpoolError::internal( + "GoonFi mint is not owned by a supported token program", + )); + } + Ok(MintAccount::unpack(&account.data)?.decimals()) +} + +fn resolve_market( + address: Pubkey, + account: &Account, + references: &HashMap, +) -> SurfpoolResult { + let [base, quote, oracle] = market_references(account)?; + let required = |address: &Pubkey| { + references.get(address).ok_or_else(|| { + SurfpoolError::internal(format!("GoonFi referenced account {address} was not found")) + }) + }; + GoonfiMarket::validate(address, account, required(&oracle)?)?; + Ok(GoonfiDiscoveredMarket { + address, + oracle, + base_mint: base, + quote_mint: quote, + base_decimals: mint_decimals(required(&base)?)?, + quote_decimals: mint_decimals(required("e)?)?, + }) +} + +pub async fn discover_goonfi_markets( + client: &SurfnetRemoteClient, +) -> SurfpoolResult> { + let registry = TemplateRegistry::new(); + let layout = registry + .get("goonfi-reference-band") + .and_then(|template| template.raw_layout.as_ref()) + .ok_or_else(|| SurfpoolError::internal("GoonFi market layout is unavailable"))?; + let mut filters = vec![RpcFilterType::DataSize(layout.account_size as u64)]; + if let Some(magic) = &layout.magic { + filters.push(RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + magic.offset, + magic.bytes.clone(), + ))); + } + let accounts = client + .get_program_accounts( + &GOONFI_PROGRAM_ID, + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + commitment: Some(CommitmentConfig::confirmed()), + ..Default::default() + }, + Some(filters), + ) + .await? + .into_result()?; + let accounts = accounts + .into_iter() + .map(|(address, encoded)| { + let account: Account = encoded.to_account().ok_or_else(|| { + SurfpoolError::internal(format!("Could not decode GoonFi market {address}")) + })?; + market_references(&account)?; + Ok((address, account)) + }) + .collect::>>()?; + let mut addresses = Vec::new(); + for (_, account) in &accounts { + addresses.extend(market_references(account)?); + } + addresses.sort_unstable(); + addresses.dedup(); + let mut references = HashMap::new(); + for batch in addresses.chunks(100) { + let fetched = client + .get_multiple_accounts(batch, CommitmentConfig::confirmed()) + .await?; + for (address, account) in batch.iter().zip(fetched) { + references.insert(*address, account.map_account()?); + } + } + let mut markets = accounts + .iter() + .map(|(address, account)| resolve_market(*address, account, &references)) + .collect::>>()?; + markets.sort_by_cached_key(|market| { + ( + market.address != GOONFI_DEFAULT_MARKET, + market.label(), + market.address, + ) + }); + Ok(markets) +} + +#[cfg(test)] +mod tests { + use solana_program_pack::Pack; + + use super::*; + use crate::scenarios::protocols::goonfi::v1::GOONFI_ORACLE_PROGRAM_ID; + + fn fixture() -> (Pubkey, Account, HashMap) { + let address = Pubkey::new_unique(); + let base = Pubkey::new_unique(); + let quote = Pubkey::new_unique(); + let oracle = Pubkey::new_unique(); + let registry = TemplateRegistry::new(); + let layout = registry + .get("goonfi-reference-band") + .unwrap() + .raw_layout + .as_ref() + .unwrap(); + let mut market = Account { + owner: GOONFI_PROGRAM_ID, + data: vec![0; layout.account_size], + ..Account::default() + }; + let magic = layout.magic.as_ref().unwrap(); + market.data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + market.data[80..112].copy_from_slice(base.as_ref()); + market.data[112..144].copy_from_slice(quote.as_ref()); + market.data[208..240].copy_from_slice(oracle.as_ref()); + let mint = |decimals| { + let mut account = Account { + owner: spl_token_interface::ID, + data: vec![0; spl_token_interface::state::Mint::LEN], + ..Account::default() + }; + spl_token_interface::state::Mint { + decimals, + is_initialized: true, + ..Default::default() + } + .pack_into_slice(&mut account.data); + account + }; + ( + address, + market, + HashMap::from([ + (base, mint(9)), + (quote, mint(6)), + ( + oracle, + Account { + owner: GOONFI_ORACLE_PROGRAM_ID, + data: vec![0; 32], + ..Account::default() + }, + ), + ]), + ) + } + + #[test] + fn goonfi_discovery_accepts_uncataloged_markets_and_preserves_mint_identity() { + let (address, account, references) = fixture(); + let result = resolve_market(address, &account, &references).unwrap(); + assert_eq!(result.address, address); + assert_eq!((result.base_decimals, result.quote_decimals), (9, 6)); + assert_eq!( + result.label(), + format!("{}/{}", result.base_mint, result.quote_mint) + ); + assert_eq!(result.oracle, market_references(&account).unwrap()[2]); + } + + #[test] + fn goonfi_discovery_rejects_invalid_market_layouts_and_mint_identities() { + let (_, account, _) = fixture(); + for invalid in 0..5 { + let mut account = account.clone(); + match invalid { + 0 => account.owner = Pubkey::new_unique(), + 1 => { + account.data.pop(); + } + 2 => account.data[0] ^= 1, + 3 => account.data[80..112].fill(0), + _ => { + let base = account.data[80..112].to_vec(); + account.data[112..144].copy_from_slice(&base); + } + } + assert!( + market_references(&account).is_err(), + "invalid case {invalid}" + ); + } + } + + #[test] + fn goonfi_discovery_rejects_missing_or_invalid_referenced_accounts() { + let (address, account, references) = fixture(); + let [base, _, oracle] = market_references(&account).unwrap(); + for invalid in 0..5 { + let mut references = references.clone(); + match invalid { + 0 => { + references.remove(&oracle); + } + 1 => references.get_mut(&oracle).unwrap().owner = Pubkey::new_unique(), + 2 => { + references.get_mut(&oracle).unwrap().data.pop(); + } + 3 => references.get_mut(&base).unwrap().owner = Pubkey::new_unique(), + _ => references.get_mut(&base).unwrap().data.fill(0), + } + assert!( + resolve_market(address, &account, &references).is_err(), + "invalid case {invalid}" + ); + } + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs new file mode 100644 index 000000000..c788b126d --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -0,0 +1,15 @@ +mod liquidity; +mod markets; +mod price; + +pub use liquidity::{ + GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses, +}; + +pub use price::{ + GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, + GoonfiPricePreparation, build_goonfi_price_scenario, validate_goonfi_market_layout, + validate_goonfi_oracle_layout, +}; + +pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml new file mode 100644 index 000000000..70ca05004 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml @@ -0,0 +1,130 @@ +protocol: GoonFi +version: deployed-438563879 +account_type: PriceOracle + +# The write target of every template here is the market's price-oracle account: 32 bytes owned by +# the companion publisher program. It carries no discriminator, so the byte guard can only pin +# the size; the owner predicate below is what keeps a raw write out of a foreign 32-byte account, +# and the builder additionally resolves the oracle through the market account's own pointer. +raw_layout: + account_size: 32 + owner: dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: goonfi-price + name: Override GoonFi Price + description: Move a GoonFi market's oracle bid and ask atomically in both directions + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: bid_price_x1e6 + offset: 0 + encoding: u64 + label: Bid price + description: "The human pair price the venue buys base at, multiplied by 10^6. Independent of mint decimals." + - path: ask_price_x1e6 + offset: 8 + encoding: u64 + label: Ask price + description: "The human pair price the venue sells base at, multiplied by 10^6. Live oracles publish it at or above the bid; keep that shape." + llm_context: | + SET BOTH FIELDS AS ONE INVARIANT, with ask >= bid. Values are the human pair price times + 10^6 regardless of mint decimals: SOL at 99.74 USDC is bid_price_x1e6 "99740000". Use + decimal integer strings, not JSON numbers. + + THE PRICE IS BAND-GUARDED ACROSS TWO ACCOUNTS. The deployed program rejects a swap with + custom error 0x24 when the oracle price sits outside the reference band stored in the + market account - a decoupled move of even 5% is proven to reject, and the live oracle + tracks its band within a fraction of a percent. Always apply goonfi-reference-band to the + market address returned by list_goonfi_markets, scaled by the same factor. The GoonFi price + builder composes both overrides plus freshness automatically; composing the raw templates + by hand and skipping one of them breaks the invariant. + + Set fetchBeforeUse: true so the live oracle is forked before your bid and ask apply; on a + fresh fork the account is not local yet, and an override on a missing account is skipped. + Use false only for a later override that builds on state an earlier override prepared in the + same scenario. + + GoonFi rejects a quote whose oracle is past its staleness window with custom error 0x15. + Pair long-running scenarios with goonfi-freshness. + + - id: goonfi-stale-quote + name: Make GoonFi Quote Stale + description: Age a GoonFi oracle past its rejection window + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: last_update_slot + offset: 16 + encoding: + slot: + lead: -2000 + width: 4 + label: Slot lead + description: >- + How far behind the materialization slot to place the oracle's update slot, as a + negative integer. Pass null to use -2000, past every observed window including the + stablecoin tier's. + llm_context: | + The value you pass IS the lead: Surfpool writes the materialization slot plus it, clamped + at zero. Pass null to take the -2000 default. The slot field is 4 bytes; the dynamic + multiplier stored beside it stays untouched. + + The rejection window is per-market and publisher-adjustable: ages 16 and 21 were observed + on volatile pairs and windows of a few hundred slots on stablecoin pairs, all on one day. + These observations are not fixed limits. Inside the window the deployed program decays + the quote with age - + faster the higher the oracle's multiplier at offset 20 - before rejecting outright with + custom error 0x15, so a small negative lead prepares a degraded-but-fillable quote and + the -2000 default prepares a rejected one on every observed market. + + Do not persist this override: the quote should stay stale. For a standalone stale quote on a + fresh fork, set fetchBeforeUse: true so the live oracle is forked before the ageing applies; + an override on an account not yet local is skipped. In a lifecycle where an earlier + goonfi-freshness override already forked and edited the oracle locally, use false so this + override does not refetch remote bytes over that local edit: refresh once at slot zero, age + at the requested relative slot, then optionally refresh with persist at a later recovery + slot. The initial refresh must not persist or it will erase the stale event. Before a + delayed event, the initial quote naturally ages. Keep override labels short ("SOL/USDC stale + quote"). + + - id: goonfi-freshness + name: Refresh GoonFi Quote + description: Publish the materialization slot into the oracle's freshness field + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: last_update_slot + offset: 16 + encoding: + slot: + lead: 0 + width: 4 + label: Current materialization slot + description: Slot lead, as an integer. Pass null to take the lead of zero and write the materialization slot itself. + llm_context: | + GoonFi rejects a swap whose oracle has aged past its per-market window with custom error + 0x15 - a loud failure, unlike BisonFi's silent zero - and decays the quote with age before + that, at a rate proportional to the oracle's multiplier at offset 20. Re-stamping this + field alone restores the full quote; the market account's own slot fields do not gate + freshness. + + Pass null for last_update_slot to take this template's lead of zero, which writes the + exact materialization slot. A number would be read as the lead instead. Use persist: true + when the prepared state must remain executable beyond the window; each application then + writes its own slot. + + Set fetchBeforeUse: true when this is the first override to touch the oracle on a fresh fork, + so the live account is forked before the stamp; an override on an account not yet local is + skipped. Use false when an earlier override in the same scenario already forked it. diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs new file mode 100644 index 000000000..61cda2863 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -0,0 +1,447 @@ +//! GoonFi price state preparation. +//! +//! GoonFi publishes no IDL. Every write goes through the raw layouts in `oracle_overrides.yaml` +//! and `market_overrides.yaml`; this module exists for what those templates cannot express: the +//! price lives in a per-market oracle account that must be resolved from the market's own pointer +//! and validated by owner, and a price move is one invariant across two accounts - oracle bid and +//! ask, the market's reference band, and a freshness re-stamp. + +use std::{collections::HashMap, sync::LazyLock}; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, RawLayout, Scenario}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +pub const GOONFI_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"); +/// The companion publisher program that owns every market's price oracle. +pub const GOONFI_ORACLE_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"); +pub const GOONFI_DEFAULT_MARKET: Pubkey = + Pubkey::from_str_const("GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC"); + +/// Read, never written, so no template declares it. +const ORACLE_POINTER_OFFSET: usize = 208; + +/// The layouts a GoonFi market and its oracle must have, taken from the manifests the raw +/// templates are written against so there is one definition of them. Built once; both manifests +/// are compiled in. +static ORACLE_LAYOUT: LazyLock = LazyLock::new(|| layout_of(PRICE_TEMPLATE)); +static MARKET_LAYOUT: LazyLock = LazyLock::new(|| layout_of(REFERENCE_TEMPLATE)); + +fn layout_of(template_id: &str) -> RawLayout { + template(&TemplateRegistry::new(), template_id) + .and_then(|template| { + template + .raw_layout + .clone() + .ok_or_else(|| SurfpoolError::internal("the GoonFi manifests carry no raw layout")) + }) + .expect("the GoonFi manifests are compiled in and always parse") +} + +const PRICE_TEMPLATE: &str = "goonfi-price"; +const REFERENCE_TEMPLATE: &str = "goonfi-reference-band"; +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Prices are the human pair price times 10^6, independent of mint decimals. +const PRICE_SCALE_DECIMALS: u32 = 6; + +/// All three overrides apply on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +/// The parts of a GoonFi market a price move needs: the market account itself and the oracle it +/// points at. +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiMarket { + pub address: Pubkey, + pub oracle: Pubkey, +} + +impl GoonfiMarket { + /// The oracle the market prices from, read from the market's own pointer. Never trust a + /// caller-supplied oracle address: the oracle is 32 undiscriminated bytes, so the pointer + /// plus the owner check below are what keep a write out of a foreign account. + pub fn oracle_address(market_account: &Account) -> SurfpoolResult { + validate_goonfi_market_layout(market_account)?; + let oracle = read_pubkey(&market_account.data, ORACLE_POINTER_OFFSET)?; + if oracle == Pubkey::default() { + return Err(invalid("market carries no oracle pointer")); + } + Ok(oracle) + } + + pub fn validate( + address: Pubkey, + market_account: &Account, + oracle_account: &Account, + ) -> SurfpoolResult { + let oracle = Self::oracle_address(market_account)?; + validate_goonfi_oracle_layout(oracle_account)?; + Ok(Self { address, oracle }) + } +} + +/// Rejects an account that is not a GoonFi market. +/// +/// The shared raw-layout guard has no owner predicate, so a foreign account of the same size +/// carrying the same magic would pass it. Every builder-made scenario comes through here, which +/// adds the ownership check the schema cannot express. +pub fn validate_goonfi_market_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != GOONFI_PROGRAM_ID { + return Err(invalid("market is not owned by GoonFi")); + } + MARKET_LAYOUT.guard(&account.data).map_err(invalid) +} + +/// Rejects an account that is not a GoonFi price oracle. +/// +/// The oracle is 32 bytes with no magic at all, so its guard pins only the size; the owner check +/// here is the real discriminator. +pub fn validate_goonfi_oracle_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != GOONFI_ORACLE_PROGRAM_ID { + return Err(invalid("oracle is not owned by the GoonFi publisher")); + } + ORACLE_LAYOUT.guard(&account.data).map_err(invalid) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiPricePreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub oracle: Pubkey, + pub price_x1e6: u64, +} + +pub fn build_goonfi_price_scenario( + market: &GoonfiMarket, + price: &str, +) -> SurfpoolResult { + let price_x1e6 = human_price_to_x1e6(price)?; + let scaled = price_x1e6.to_string(); + + let registry = TemplateRegistry::new(); + let price_template = template(®istry, PRICE_TEMPLATE)?; + let reference = template(®istry, REFERENCE_TEMPLATE)?; + let freshness = template(®istry, FRESHNESS_TEMPLATE)?; + let market_name = market.address.to_string(); + let oracle_target = AccountAddress::Pubkey(market.oracle.to_string()); + + // No fetch_before_use anywhere: the oracle and reference values are absolute targets for the + // account graph creation read, and a Play-time refetch would reinstall remote bytes over any + // local edit. + let price_override = OverrideInstance::new( + price_template.id.clone(), + PREPARATION_SLOT, + oracle_target.clone(), + ) + .with_values(HashMap::from([ + ( + "bid_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "ask_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ])) + .with_label(format!("GoonFi {market_name} price")); + + // The deployed program rejects an oracle price outside the market's reference band with + // custom error 0x24, so the band moves to the same target as one invariant. + let reference_override = OverrideInstance::new( + reference.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(market.address.to_string()), + ) + .with_values(HashMap::from([ + ( + "reference_price_a_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "reference_price_b_x1e6".to_string(), + serde_json::json!(scaled), + ), + ])) + .with_label(format!("GoonFi {market_name} reference band")); + + // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null takes + // the template's own lead of zero. Persisted, so the prepared price stays inside the oracle's + // staleness window however long the scenario is left running. + let freshness_override = + OverrideInstance::new(freshness.id.clone(), PREPARATION_SLOT, oracle_target) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true); + + let normalized_price = price.trim(); + let mut scenario = Scenario::new( + format!("GoonFi {market_name} at {normalized_price}"), + format!( + "Prepare GoonFi market {} to quote one base token at {normalized_price} quote tokens; no swap is sent.", + market.address + ), + ); + scenario.tags = vec![ + "goonfi".to_string(), + "pmm".to_string(), + "price-dislocation".to_string(), + ]; + scenario.add_override(price_override); + scenario.add_override(reference_override); + scenario.add_override(freshness_override); + + Ok(GoonfiPricePreparation { + scenario, + market: market.address, + oracle: market.oracle, + price_x1e6, + }) +} + +fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let bytes: [u8; 32] = data[offset..offset + 32] + .try_into() + .map_err(|_| invalid("market oracle bytes are truncated"))?; + Ok(Pubkey::new_from_array(bytes)) +} + +pub(super) fn human_price_to_x1e6(price: &str) -> SurfpoolResult { + let value = price.trim(); + let mut parts = value.split('.'); + let whole = parts.next().unwrap_or_default(); + let fractional = parts.next().unwrap_or_default(); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fractional.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(invalid("price must be a positive decimal string")); + } + + // Reject rather than truncate: a seventh decimal place cannot be represented, and silently + // dropping it would prepare a different price than the caller asked for. + if fractional.len() > PRICE_SCALE_DECIMALS as usize { + return Err(invalid(format!( + "price carries more than {PRICE_SCALE_DECIMALS} decimal places, past GoonFi's 10^-6 resolution" + ))); + } + let digits = format!("{whole}{fractional}") + .parse::() + .map_err(|_| invalid("price is too large"))?; + let exponent = PRICE_SCALE_DECIMALS - fractional.len() as u32; + let scaled = 10u128 + .checked_pow(exponent) + .and_then(|power| digits.checked_mul(power)) + .ok_or_else(|| invalid("price is too large"))?; + if scaled == 0 { + return Err(invalid("price must be greater than zero")); + } + u64::try_from(scaled).map_err(|_| { + let max_price = u64::MAX / 10u64.pow(PRICE_SCALE_DECIMALS); + invalid(format!( + "price is too large for GoonFi's u64 field; a market accepts at most about {max_price} quote per base" + )) + }) +} + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn market_account(oracle: &Pubkey) -> Account { + let mut data = vec![0; MARKET_LAYOUT.account_size]; + let magic = MARKET_LAYOUT.magic.as_ref().expect("manifest layout tag"); + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[ORACLE_POINTER_OFFSET..ORACLE_POINTER_OFFSET + 32].copy_from_slice(oracle.as_ref()); + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + fn oracle_account() -> Account { + Account { + data: vec![0; ORACLE_LAYOUT.account_size], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + + fn market() -> GoonfiMarket { + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&FIXTURE_ORACLE), + &oracle_account(), + ) + .expect("valid GoonFi market") + } + + #[test] + fn builds_price_scenario_across_both_accounts() { + let market = market(); + let preparation = build_goonfi_price_scenario(&market, "99.74").unwrap(); + assert_eq!(preparation.price_x1e6, 99_740_000); + + let [price, reference, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected price, reference-band and freshness overrides"); + }; + assert_eq!( + price.account, + AccountAddress::Pubkey(market.oracle.to_string()) + ); + assert_eq!( + reference.account, + AccountAddress::Pubkey(market.address.to_string()) + ); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(market.oracle.to_string()) + ); + assert_eq!( + price.values.get("bid_price_x1e6"), + Some(&serde_json::json!("99740000")) + ); + assert_eq!( + reference.values.get("reference_price_b_x1e6"), + Some(&serde_json::json!("99740000")) + ); + } + + /// The values are absolute targets for the creation read, so nothing refetches at Play; the + /// freshness value must stay null because the slot encoder reads a supplied number as the + /// lead rather than ignoring it. + #[test] + fn price_stays_on_the_creation_read_and_freshness_keeps_the_template_lead() { + let preparation = build_goonfi_price_scenario(&market(), "1").unwrap(); + let [price, reference, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected exactly three overrides"); + }; + assert!(!price.fetch_before_use); + assert!(!price.persist); + assert!(!reference.fetch_before_use); + assert!(!reference.persist); + assert!(!freshness.fetch_before_use); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn scales_prices_by_ten_to_the_sixth_regardless_of_decimals() { + for (price, expected) in [ + ("77526.523154", 77_526_523_154u64), + ("0.00841", 8_410), + ("1558.9384", 1_558_938_400), + ] { + let preparation = build_goonfi_price_scenario(&market(), price).unwrap(); + assert_eq!(preparation.price_x1e6, expected, "price {price}"); + } + } + + #[test] + fn rejects_invalid_price_and_account_inputs() { + let market = market(); + for price in [ + "0", + "-1", + "1.2.3", + "not-a-price", + "", + "0.0000001", + "1.0000009", + ] { + assert!( + build_goonfi_price_scenario(&market, price).is_err(), + "price {price} must be refused" + ); + } + + // A pathological fraction must come back as an error, never a panic or a wrapped value. + let poison = format!("0.{}1", "0".repeat(133)); + assert!(build_goonfi_price_scenario(&market, &poison).is_err()); + let long_whole = "9".repeat(60); + assert!(build_goonfi_price_scenario(&market, &long_whole).is_err()); + + let uncataloged = GoonfiMarket { + address: Pubkey::new_unique(), + oracle: Pubkey::new_unique(), + }; + let preparation = build_goonfi_price_scenario(&uncataloged, "1").unwrap(); + assert!( + preparation + .scenario + .name + .contains(&uncataloged.address.to_string()) + ); + assert_eq!(preparation.oracle, uncataloged.oracle); + + let oracle = Pubkey::new_unique(); + let wrong_owner = Account { + owner: Pubkey::new_unique(), + ..market_account(&oracle) + }; + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &wrong_owner, &oracle_account()).is_err() + ); + // The raw guard cannot see the owner, which is the whole reason this check sits on top. + assert!(MARKET_LAYOUT.guard(&wrong_owner.data).is_ok()); + + let mut bad_magic = market_account(&oracle); + bad_magic.data[0] ^= 0xff; + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &bad_magic, &oracle_account()).is_err() + ); + + let no_pointer = market_account(&Pubkey::default()); + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &no_pointer, &oracle_account()).is_err() + ); + + // The oracle carries no magic at all, so the owner check is its only discriminator. + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle_account() + }; + assert!( + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&oracle), + &foreign_oracle + ) + .is_err() + ); + assert!(ORACLE_LAYOUT.guard(&foreign_oracle.data).is_ok()); + + let truncated_oracle = Account { + data: vec![0; 16], + ..oracle_account() + }; + assert!(validate_goonfi_oracle_layout(&truncated_oracle).is_err()); + } +} diff --git a/crates/core/src/scenarios/protocols/mod.rs b/crates/core/src/scenarios/protocols/mod.rs index 99f0b0967..dbcb89738 100644 --- a/crates/core/src/scenarios/protocols/mod.rs +++ b/crates/core/src/scenarios/protocols/mod.rs @@ -1 +1,2 @@ +pub mod goonfi; pub mod pump; diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 300e3557d..33c0905d3 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -25,6 +25,12 @@ pub const KAMINO_V1_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/v pub const BISONFI_OVERRIDES_CONTENT: &str = include_str!("./protocols/bisonfi/overrides.yaml"); +pub const GOONFI_V1_ORACLE_OVERRIDES_CONTENT: &str = + include_str!("./protocols/goonfi/v1/oracle_overrides.yaml"); + +pub const GOONFI_V1_MARKET_OVERRIDES_CONTENT: &str = + include_str!("./protocols/goonfi/v1/market_overrides.yaml"); + pub const KAMINO_SCOPE_IDL_CONTENT: &str = include_str!("./protocols/kamino/scope/v1/idl.json"); pub const KAMINO_SCOPE_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/scope/v1/overrides.yaml"); @@ -79,6 +85,7 @@ impl TemplateRegistry { default.load_meteora_overrides(); default.load_kamino_overrides(); default.load_bisonfi_overrides(); + default.load_goonfi_overrides(); default.load_drift_overrides(); default.load_whirlpool_overrides(); default.load_spl_token_overrides(); @@ -123,6 +130,12 @@ impl TemplateRegistry { self.load_raw_layout_overrides(BISONFI_OVERRIDES_CONTENT, "bisonfi"); } + /// GoonFi writes two account shapes - the price oracle and the market that band-guards it. + pub fn load_goonfi_overrides(&mut self) { + self.load_protocol_overrides_without_idl(GOONFI_V1_ORACLE_OVERRIDES_CONTENT, "goonfi"); + self.load_protocol_overrides_without_idl(GOONFI_V1_MARKET_OVERRIDES_CONTENT, "goonfi"); + } + pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); @@ -501,13 +514,19 @@ mod tests { // Pyth (1) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift (4) + Meteora (2) // + Kamino (Lend 17, Scope 3, Farms 5, Swap 2, Vault 5, Liquidity 4 = 36) - // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + BisonFi (4) = 66 + // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + BisonFi (4) + // + GoonFi (oracle 3 + market 1) = 70 assert_eq!( registry.count(), - 66, - "Registry should load 66 templates total" + 70, + "Registry should load 70 templates total" ); + assert!(registry.contains("goonfi-price")); + assert!(registry.contains("goonfi-stale-quote")); + assert!(registry.contains("goonfi-freshness")); + assert!(registry.contains("goonfi-reference-band")); + assert!(registry.contains("pyth-price-feed-v2")); assert!(registry.contains("jupiter-token-ledger-override")); diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 831432580..da32e51cb 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -3085,6 +3085,13 @@ impl SurfnetSvm { .cloned(); if let Some(template) = raw_template { let raw_layout = template.raw_layout.expect("filtered above"); + if let Err(e) = raw_layout.guard_owner(account.owner()) { + warn!( + "Raw-layout override {} refused on {}: {}", + override_instance.id, account_pubkey, e + ); + continue; + } let properties = template.properties; match raw_layout.materialize( account.data(), @@ -4529,10 +4536,6 @@ impl SurfnetSvm { Ok(fixtures) } - /// Registers a scenario for execution by scheduling its overrides - /// - /// The `slot` parameter is the base slot from which relative override slot heights are calculated. - /// If not provided, uses the current slot. pub fn register_scenario( &mut self, scenario: surfpool_types::Scenario, diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs new file mode 100644 index 000000000..9bbaaedc0 --- /dev/null +++ b/crates/core/src/tests/goonfi/mod.rs @@ -0,0 +1,1110 @@ +//! Behavioral proofs for GoonFi's oracle and market layouts against the current deployed program. +//! +//! GoonFi V2 prices swaps from a per-market oracle account owned by a companion publisher +//! program, not from the market account itself. The market account carries the pair's identities +//! (mints, vaults, oracle pointer) in cleartext plus the reference band that guards the oracle +//! price; the oracle carries bid/ask, a u32 freshness slot, and a dynamic staleness multiplier. +//! +//! Run serially against mainnet: +//! `cargo test -p surfpool-core --features integration-tests tests::goonfi -- --test-threads=1` + +use std::collections::HashMap; + +use sha2::{Digest, Sha256}; +use solana_account::Account; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_pack::Pack; +use solana_program_runtime::{ + declare_process_instruction, solana_sbpf::program::BuiltinFunctionDefinition, +}; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::{ + TemplateRegistry, + protocols::goonfi::v1::{ + GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, + }, + }, + surfnet::svm::SurfnetSvm, + tests::live, +}; + +const GOONFI_PROGRAM: &str = "goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"; +const GOONFI_PROGRAMDATA: &str = "124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s"; +const ORACLE_PROGRAMDATA: &str = "7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z"; +const GOONFI_ORACLE_PROGRAM: &str = "dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"; +const GOONFI_GLOBAL: &str = "BNrK9LpEn65QA4TyBLVSMdngW3XHj3xLfFPwGdCBv8wV"; +const JUPITER_PROGRAM: &str = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"; +const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +const CURRENT_DEPLOY_SLOT: u64 = 438_563_879; +const CURRENT_ELF_SHA256: &str = "73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e"; +const ORACLE_DEPLOY_SLOT: u64 = 404_369_628; +const ORACLE_ELF_SHA256: &str = "0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695"; + +/// Deployed-program error codes, proven by the replay runs below. +const ERROR_STALE_ORACLE: &str = "Custom(21)"; +const ERROR_PRICE_OUT_OF_BAND: &str = "Custom(36)"; +const ERROR_MIN_AMOUNT_OUT: &str = "Custom(15)"; +const ERROR_INSUFFICIENT_LIQUIDITY: &str = "Custom(1)"; + +/// Oracle layout: both prices are the human pair price times 10^6, independent of mint decimals. +/// The freshness slot is 4 bytes; the u32 beside it is the decay-rate multiplier around 10^6 - +/// it scales how fast a quote degrades with age and does not move the rejection boundary. +const ORACLE_BID_OFFSET: usize = 0; +const ORACLE_ASK_OFFSET: usize = 8; +const ORACLE_SLOT_OFFSET: usize = 16; +const ORACLE_MULTIPLIER_OFFSET: usize = 20; +const ORACLE_TS_MS_OFFSET: usize = 24; + +/// Market-account fields the flows touch or read. The two reference prices band-guard the oracle; +/// the mint and oracle pointers identify the pair. +const MARKET_BASE_MINT_OFFSET: usize = 80; +const MARKET_QUOTE_MINT_OFFSET: usize = 112; +const MARKET_ORACLE_OFFSET: usize = 208; +const MARKET_REF_A_OFFSET: usize = 1712; +const MARKET_REF_B_OFFSET: usize = 1720; + +#[derive(Clone, Copy)] +struct MarketSpec { + market: &'static str, + base_vault: &'static str, + quote_vault: &'static str, + base_mint: &'static str, + quote_mint: &'static str, + oracle: &'static str, + amount_in: u64, +} + +/// The pair the captured reference swap traded, so the replay mirrors a known-good transaction. +const PRIMARY_MARKET: MarketSpec = MarketSpec { + market: "HBDaV4ndLuVe6qK1vGCXReon4B1DJKa9UrbqP8cVqywx", + base_vault: "4KDPiofhBxLMuTuvaYtMAqY6e5DnzbHLB6i7eeU239f6", + quote_vault: "DAogoedaaCcn2SzTc3yi7bWgTWYv5MwoTj6ySgw9snLS", + base_mint: "A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + oracle: "vCDwWKdqPHYAP7q5zXY6xk3XC5Ct5oqCs5fdpoosPNq", + amount_in: 25_109_852, +}; + +const SOL_USDC_MARKET: MarketSpec = MarketSpec { + market: "GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC", + base_vault: "8ncU5YW1CQwvr4gs7buH57bW58e86TDau4STrCJBuz8z", + quote_vault: "EunHLeqeJKvxnCPQSytnBP63HJVk2fbHceiKKpngyAo8", + base_mint: "So11111111111111111111111111111111111111112", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + oracle: "7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3", + amount_in: 1_000_000_000, +}; + +#[derive(Clone)] +struct GoonfiFork { + spec: MarketSpec, + elf: Vec, + global: Account, + market: Account, + base_vault: Account, + quote_vault: Account, + base_mint: Account, + quote_mint: Account, + oracle: Account, +} + +declare_process_instruction!(GoonfiCpiWrapper, 1, |invoke_context| { + let instruction = { + let context = invoke_context + .transaction_context + .get_current_instruction_context()?; + let accounts = (1..context.get_number_of_instruction_accounts()) + .map(|index| { + Ok(AccountMeta { + pubkey: *context.get_key_of_instruction_account(index)?, + is_signer: context.is_instruction_account_signer(index)?, + is_writable: context.is_instruction_account_writable(index)?, + }) + }) + .collect::, solana_instruction::error::InstructionError>>()?; + Instruction { + program_id: Pubkey::from_str_const(GOONFI_PROGRAM), + accounts, + data: context.get_instruction_data().to_vec(), + } + }; + invoke_context.native_invoke_signed(instruction, &[]) +}); + +async fn fetch_accounts(addresses: &[&str]) -> Vec { + let pubkeys: Vec = addresses + .iter() + .map(|address| Pubkey::from_str_const(address)) + .collect(); + live::fetch(&pubkeys).await +} + +async fn goonfi_fork(spec: MarketSpec) -> GoonfiFork { + // The ProgramData and global accounts total near a megabyte, which the public endpoint + // refuses to return alongside the market graph. Fetch the two big slow-moving accounts + // separately and keep the price-coupled market graph in one same-slot batch. + let mut big = fetch_accounts(&[GOONFI_PROGRAMDATA, GOONFI_GLOBAL, ORACLE_PROGRAMDATA]).await; + let mut accounts = fetch_accounts(&[ + spec.market, + spec.base_vault, + spec.quote_vault, + spec.base_mint, + spec.quote_mint, + spec.oracle, + ]) + .await; + let programdata = big.remove(0); + assert_eq!(programdata.data.len(), 252_429, "ProgramData size changed"); + assert_eq!( + u64::from_le_bytes(programdata.data[4..12].try_into().unwrap()), + CURRENT_DEPLOY_SLOT, + "GoonFi was redeployed; revalidate the raw layout" + ); + let elf = programdata.data[45..].to_vec(); + assert_eq!( + hex::encode(Sha256::digest(&elf)), + CURRENT_ELF_SHA256, + "GoonFi ELF changed without a ProgramData address change" + ); + // The publisher's identity is pinned too: its oracle accounts are the price templates' write + // targets, so a redeploy there also voids the layout evidence. + let oracle_programdata = big.pop().expect("oracle programdata fetched"); + assert_eq!( + oracle_programdata.data.len(), + 557, + "oracle publisher ProgramData size changed" + ); + assert_eq!( + u64::from_le_bytes(oracle_programdata.data[4..12].try_into().unwrap()), + ORACLE_DEPLOY_SLOT, + "the oracle publisher was redeployed; revalidate the oracle layout" + ); + assert_eq!( + hex::encode(Sha256::digest(&oracle_programdata.data[45..])), + ORACLE_ELF_SHA256, + "oracle publisher ELF changed without a ProgramData address change" + ); + + GoonfiFork { + spec, + elf, + global: big.remove(0), + market: accounts.remove(0), + base_vault: accounts.remove(0), + quote_vault: accounts.remove(0), + base_mint: accounts.remove(0), + quote_mint: accounts.remove(0), + oracle: accounts.remove(0), + } +} + +fn with_controlled_inventory(mut fork: GoonfiFork) -> GoonfiFork { + // Publishers can drain live vaults to dust. Fund only the local fixture so price and age + // assertions measure those controls rather than unrelated, time-varying inventory limits. + for (address, vault, mint_address, mint) in [ + ( + fork.spec.base_vault, + &mut fork.base_vault, + fork.spec.base_mint, + &fork.base_mint, + ), + ( + fork.spec.quote_vault, + &mut fork.quote_vault, + fork.spec.quote_mint, + &fork.quote_mint, + ), + ] { + assert_eq!(vault.owner, spl_token_interface::ID); + assert_eq!(mint.owner, spl_token_interface::ID); + let mint_state = spl_token_interface::state::Mint::unpack(&mint.data) + .expect("controlled fixture mint must remain valid"); + let mut token = spl_token_interface::state::Account::unpack(&vault.data) + .expect("controlled fixture vault must remain valid"); + assert_eq!(token.mint, Pubkey::from_str_const(mint_address)); + assert_eq!(token.owner, Pubkey::from_str_const(fork.spec.market)); + let minimum_amount = 10u64 + .checked_pow(u32::from(mint_state.decimals)) + .and_then(|unit| unit.checked_mul(10_000)) + .expect("10,000 whole fixture tokens must fit u64"); + let original_amount = token.amount; + token.amount = token.amount.max(minimum_amount); + let original_data = vault.data.clone(); + spl_token_interface::state::Account::pack(token, &mut vault.data) + .expect("pack controlled fixture vault"); + if let solana_program_option::COption::Some(reserve) = token.is_native { + vault.lamports = reserve + .checked_add(token.amount) + .expect("controlled native vault funding fits u64"); + } + assert_only_ranges_changed(&original_data, &vault.data, &[(64, 72)]); + eprintln!( + "GoonFi controlled local inventory {address}: captured {original_amount}, prepared {} raw units; market, oracle and deployed ELF remain captured", + token.amount + ); + } + fork +} + +fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(owner.as_ref()); + data[64..72].copy_from_slice(&amount.to_le_bytes()); + data[108] = 1; + data +} + +fn native_token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = token_account(mint, owner, amount); + data[109..113].copy_from_slice(&1u32.to_le_bytes()); + data[113..121].copy_from_slice(&2_039_280u64.to_le_bytes()); + data +} + +fn token_amount(data: &[u8]) -> u64 { + u64::from_le_bytes(data[64..72].try_into().unwrap()) +} + +fn read_u64(data: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) +} + +fn read_u32(data: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) +} + +fn write_u64(data: &mut [u8], offset: usize, value: u64) { + data[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +fn oracle_slot(data: &[u8]) -> u64 { + u64::from(read_u32(data, ORACLE_SLOT_OFFSET)) +} + +fn scale_prices(data: &mut [u8], numerator: u64, denominator: u64) { + for offset in [ORACLE_BID_OFFSET, ORACLE_ASK_OFFSET] { + let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) + / u128::from(denominator)) as u64; + write_u64(data, offset, scaled); + } +} + +fn scale_refs(data: &mut [u8], numerator: u64, denominator: u64) { + for offset in [MARKET_REF_A_OFFSET, MARKET_REF_B_OFFSET] { + let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) + / u128::from(denominator)) as u64; + write_u64(data, offset, scaled); + } +} + +fn assert_only_ranges_changed(before: &[u8], after: &[u8], ranges: &[(usize, usize)]) { + assert_eq!(after.len(), before.len()); + for index in live::diff_indices(before, after) { + assert!( + ranges + .iter() + .any(|(start, end)| (*start..*end).contains(&index)), + "unexpected changed byte at {index}" + ); + } +} + +struct RunConfig { + amount_in: u64, + is_bid: u8, + min_amount_out: u64, + /// Slots past the oracle's snapshot update slot at which the swap executes. + clock_slot_age: u64, + /// Seconds past the oracle's snapshot publish time at which the swap executes. + clock_ts_age: i64, +} + +impl RunConfig { + fn sell(amount_in: u64) -> Self { + Self { + amount_in, + is_bid: 0, + min_amount_out: 1, + clock_slot_age: 1, + clock_ts_age: 1, + } + } + + fn buy(amount_in: u64) -> Self { + Self { + is_bid: 1, + ..Self::sell(amount_in) + } + } + + fn sell_at_age(amount_in: u64, clock_slot_age: u64) -> Self { + Self { + clock_slot_age, + ..Self::sell(amount_in) + } + } +} + +fn goonfi_run( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), +) -> Result { + goonfi_run_full(fork, config, mutate_oracle, |_| {}) +} + +fn goonfi_run_full( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), + mutate_market: impl FnOnce(&mut Vec), +) -> Result { + goonfi_run_capturing_oracle(fork, config, mutate_oracle, mutate_market) + .map(|(amount_out, _)| amount_out) +} + +/// Executes one GoonFi swap in LiteSVM against forked mainnet state: the deployed ELF, driven +/// through a wrapper builtin standing in for Jupiter, reproducing the aggregator-routed shape +/// every live swap has. Returns the fill and the oracle's post-execution bytes. +fn goonfi_run_capturing_oracle( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), + mutate_market: impl FnOnce(&mut Vec), +) -> Result<(u64, Vec), String> { + use litesvm::LiteSVM; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let program_id = Pubkey::from_str_const(GOONFI_PROGRAM); + let global_key = Pubkey::from_str_const(GOONFI_GLOBAL); + let market_key = Pubkey::from_str_const(fork.spec.market); + let base_vault_key = Pubkey::from_str_const(fork.spec.base_vault); + let quote_vault_key = Pubkey::from_str_const(fork.spec.quote_vault); + let base_mint_key = Pubkey::from_str_const(fork.spec.base_mint); + let quote_mint_key = Pubkey::from_str_const(fork.spec.quote_mint); + let oracle_key = Pubkey::from_str_const(fork.spec.oracle); + let token_program = Pubkey::from_str_const(TOKEN_PROGRAM); + + let mut oracle = fork.oracle.data.clone(); + mutate_oracle(&mut oracle); + let mut market = fork.market.data.clone(); + mutate_market(&mut market); + // Ages are measured from the snapshot the fork fetched, not from mutated bytes, so a + // re-stamped freshness field changes the account's age rather than moving the clock. + let oracle_update_slot = oracle_slot(&fork.oracle.data); + let oracle_ts_seconds = (read_u64(&fork.oracle.data, ORACLE_TS_MS_OFFSET) / 1_000) as i64; + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program_id, &fork.elf) + .map_err(|error| format!("add_program: {error:?}"))?; + svm.add_builtin( + Pubkey::from_str_const(JUPITER_PROGRAM), + GoonfiCpiWrapper::register, + ); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = oracle_update_slot + config.clock_slot_age; + clock.unix_timestamp = oracle_ts_seconds + config.clock_ts_age; + svm.set_sysvar(&clock); + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|error| format!("set last restart slot: {error:?}"))?; + + let mut oracle_account = fork.oracle.clone(); + oracle_account.data = oracle; + let mut market_account = fork.market.clone(); + market_account.data = market; + for (key, account) in [ + (global_key, fork.global.clone()), + (market_key, market_account), + (base_vault_key, fork.base_vault.clone()), + (quote_vault_key, fork.quote_vault.clone()), + (base_mint_key, fork.base_mint.clone()), + (quote_mint_key, fork.quote_mint.clone()), + (oracle_key, oracle_account), + ] { + svm.set_account(key, account) + .map_err(|error| format!("set {key}: {error:?}"))?; + } + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|error| format!("airdrop: {error:?}"))?; + let user_base_key = Pubkey::new_unique(); + let user_quote_key = Pubkey::new_unique(); + let (base_funds, quote_funds) = if config.is_bid == 0 { + (config.amount_in, 0) + } else { + (0, config.amount_in) + }; + let user_account = |mint: &Pubkey, amount: u64| { + let is_native = + mint == &Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + Account { + lamports: if is_native { + amount.saturating_add(2_039_280) + } else { + 10_000_000 + }, + data: if is_native { + native_token_account(mint, &taker.pubkey(), amount) + } else { + token_account(mint, &taker.pubkey(), amount) + }, + owner: token_program, + executable: false, + rent_epoch: 0, + } + }; + svm.set_account(user_base_key, user_account(&base_mint_key, base_funds)) + .map_err(|error| format!("set user base: {error:?}"))?; + svm.set_account(user_quote_key, user_account("e_mint_key, quote_funds)) + .map_err(|error| format!("set user quote: {error:?}"))?; + + let mut data = vec![1u8, config.is_bid]; + data.extend_from_slice(&config.amount_in.to_le_bytes()); + data.extend_from_slice(&config.min_amount_out.to_le_bytes()); + let mut budget = vec![2u8]; + budget.extend_from_slice(&1_400_000u32.to_le_bytes()); + let instructions = vec![ + Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }, + Instruction { + program_id: Pubkey::from_str_const(JUPITER_PROGRAM), + accounts: vec![ + AccountMeta::new_readonly(program_id, false), + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(market_key, false), + AccountMeta::new(user_base_key, false), + AccountMeta::new(user_quote_key, false), + AccountMeta::new(base_vault_key, false), + AccountMeta::new(quote_vault_key, false), + AccountMeta::new_readonly(base_mint_key, false), + AccountMeta::new_readonly(quote_mint_key, false), + AccountMeta::new_readonly(oracle_key, false), + AccountMeta::new_readonly(global_key, false), + AccountMeta::new_readonly( + Pubkey::from_str_const("Sysvar1nstructions1111111111111111111111111"), + false, + ), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(token_program, false), + ], + data, + }, + ]; + let mut message = solana_message::Message::new(&instructions, Some(&taker.pubkey())); + message.recent_blockhash = svm.latest_blockhash(); + let signature_count = message.header.num_required_signatures as usize; + let mut transaction = Transaction::new_unsigned(message); + transaction.signatures = vec![solana_signature::Signature::default(); signature_count]; + transaction.signatures[0] = taker.sign_message(&transaction.message.serialize()); + + svm.send_transaction(transaction) + .map_err(|error| format!("{error:?}"))?; + let destination = if config.is_bid == 0 { + user_quote_key + } else { + user_base_key + }; + let amount_out = token_amount( + &svm.get_account(&destination) + .expect("destination account") + .data, + ); + let oracle_after = svm.get_account(&oracle_key).expect("oracle account").data; + Ok((amount_out, oracle_after)) +} + +/// Forks a market by its address alone, resolving vaults, mints, and oracle from the market +/// account's own pointers. Used where a fixture market outside the two hardcoded specs is needed. +async fn fork_from_market(market: &'static str, amount_in: u64) -> GoonfiFork { + let accounts = fetch_accounts(&[market]).await; + let data = &accounts[0].data; + let field = |offset: usize| -> &'static str { + Box::leak( + Pubkey::new_from_array(data[offset..offset + 32].try_into().unwrap()) + .to_string() + .into_boxed_str(), + ) + }; + let spec = MarketSpec { + market, + base_vault: field(144), + quote_vault: field(176), + base_mint: field(MARKET_BASE_MINT_OFFSET), + quote_mint: field(MARKET_QUOTE_MINT_OFFSET), + oracle: field(MARKET_ORACLE_OFFSET), + amount_in, + }; + goonfi_fork(spec).await +} + +/// Materializes the goonfi-stale-quote template with its default lead onto the fork's live +/// oracle bytes, asserts the exact 4-byte slot it wrote, and proves the deployed program then +/// rejects the swap. This is the template's own default doing the aging, not a hand-picked age. +fn stale_template_default_rejects(fork: &GoonfiFork, amount: u64) { + let registry = TemplateRegistry::new(); + let stale = registry.get("goonfi-stale-quote").expect("stale template"); + let snapshot_slot = oracle_slot(&fork.oracle.data); + let aged = stale + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &stale.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + snapshot_slot, + ) + .expect("materialize stale default"); + assert_eq!( + oracle_slot(&aged), + snapshot_slot - 2_000, + "the default lead must write exactly slot minus 2000" + ); + assert_only_ranges_changed(&fork.oracle.data, &aged, &[(16, 20)]); + assert_rejects_with( + goonfi_run(fork, RunConfig::sell(amount), |oracle| { + *oracle = aged.clone() + }), + ERROR_STALE_ORACLE, + "a quote aged by the stale template's default lead", + ); +} + +fn assert_rejects_with(result: Result, code: &str, context: &str) { + match result { + Ok(amount) => panic!("{context}: expected {code}, got a fill of {amount}"), + Err(error) => assert!( + error.contains(code), + "{context}: expected {code} in: {error}" + ), + } +} + +#[tokio::test] +async fn goonfi_templates_guard_oracle_and_market_and_preserve_unwritten_bytes() { + let fork = goonfi_fork(PRIMARY_MARKET).await; + let registry = TemplateRegistry::new(); + let price = registry.get("goonfi-price").expect("price template"); + let stale = registry.get("goonfi-stale-quote").expect("stale template"); + let fresh = registry + .get("goonfi-freshness") + .expect("freshness template"); + let band = registry + .get("goonfi-reference-band") + .expect("reference-band template"); + + let oracle_layout = price.raw_layout.as_ref().expect("oracle raw layout"); + let market_layout = band.raw_layout.as_ref().expect("market raw layout"); + assert!(oracle_layout.guard(&fork.oracle.data).is_ok()); + assert!(market_layout.guard(&fork.market.data).is_ok()); + assert!(oracle_layout.guard(&fork.oracle.data[..16]).is_err()); + assert!(market_layout.guard(&fork.market.data[..2000]).is_err()); + let mut flipped = fork.market.data.clone(); + flipped[0] ^= 0xff; + assert!(market_layout.guard(&flipped).is_err()); + + let priced = oracle_layout + .materialize( + &fork.oracle.data, + &price.properties, + &HashMap::from([ + ("bid_price_x1e6".to_string(), serde_json::json!("123456789")), + ("ask_price_x1e6".to_string(), serde_json::json!("123456790")), + ]), + 0, + ) + .expect("materialize price"); + assert_eq!(read_u64(&priced, ORACLE_BID_OFFSET), 123_456_789); + assert_eq!(read_u64(&priced, ORACLE_ASK_OFFSET), 123_456_790); + assert_only_ranges_changed(&fork.oracle.data, &priced, &[(0, 16)]); + + // The freshness slot is 4 bytes wide: the dynamic multiplier right after it must survive. + let target_slot = 500_000_123; + for (template, label) in [(stale, "stale"), (fresh, "freshness")] { + let stamped = template + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &template.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + target_slot, + ) + .unwrap_or_else(|error| panic!("materialize {label}: {error}")); + assert_only_ranges_changed(&fork.oracle.data, &stamped, &[(16, 20)]); + assert_eq!( + read_u32(&stamped, ORACLE_MULTIPLIER_OFFSET), + read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET), + "{label} clobbered the staleness multiplier" + ); + } + + let banded = market_layout + .materialize( + &fork.market.data, + &band.properties, + &HashMap::from([ + ( + "reference_price_a_x1e6".to_string(), + serde_json::json!("123456789"), + ), + ( + "reference_price_b_x1e6".to_string(), + serde_json::json!("123456789"), + ), + ]), + 0, + ) + .expect("materialize reference band"); + assert_eq!(read_u64(&banded, MARKET_REF_A_OFFSET), 123_456_789); + assert_eq!(read_u64(&banded, MARKET_REF_B_OFFSET), 123_456_789); + assert_only_ranges_changed(&fork.market.data, &banded, &[(1712, 1728)]); +} + +/// Proves the exact state the real builder prepares, end to end: `build_goonfi_price_scenario` +/// output registers and materializes through the production path, touching only its declared +/// bytes, and the deployed program then fills at the prepared price. The scenario is anchored at +/// the oracle's snapshot slot so the materialized freshness stamp matches the replay clock. +async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { + let market_key = Pubkey::from_str_const(fork.spec.market); + let oracle_key = Pubkey::from_str_const(fork.spec.oracle); + let market = + GoonfiMarket::validate(market_key, &fork.market, &fork.oracle).expect("validate market"); + let live_bid = read_u64(&fork.oracle.data, ORACLE_BID_OFFSET); + let target = live_bid * 3 / 2; + let price = format!("{}.{:06}", target / 1_000_000, target % 1_000_000); + let preparation = + build_goonfi_price_scenario(&market, &price).expect("build GoonFi price scenario"); + assert_eq!(preparation.price_x1e6, target); + + let base_slot = oracle_slot(&fork.oracle.data); + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account(market_key, fork.market.clone()) + .expect("seed GoonFi market"); + svm.inner + .set_account(oracle_key, fork.oracle.clone()) + .expect("seed GoonFi oracle"); + svm.register_scenario(preparation.scenario, Some(base_slot)) + .expect("register GoonFi scenario"); + svm.materialize_overrides_for_slot(&None, base_slot) + .await + .expect("materialize GoonFi scenario"); + + let oracle = svm + .inner + .get_account(&oracle_key) + .expect("get oracle") + .expect("oracle present") + .data; + let market_data = svm + .inner + .get_account(&market_key) + .expect("get market") + .expect("market present") + .data; + assert_eq!(read_u64(&oracle, ORACLE_BID_OFFSET), target); + assert_eq!(read_u64(&oracle, ORACLE_ASK_OFFSET), target); + assert_eq!(oracle_slot(&oracle), base_slot); + assert_eq!(read_u64(&market_data, MARKET_REF_A_OFFSET), target); + assert_eq!(read_u64(&market_data, MARKET_REF_B_OFFSET), target); + assert_only_ranges_changed(&fork.oracle.data, &oracle, &[(0, 20)]); + assert_only_ranges_changed(&fork.market.data, &market_data, &[(1712, 1728)]); + + // The deployed program fills at the prepared price, against the exact materialized bytes. + let baseline = + goonfi_run(fork, RunConfig::sell(fork.spec.amount_in), |_| {}).expect("baseline sell"); + let prepared = goonfi_run_full( + fork, + RunConfig::sell(fork.spec.amount_in), + |data| *data = oracle.clone(), + |data| *data = market_data.clone(), + ) + .expect("sell against the builder-prepared state"); + let expected = (u128::from(baseline) * u128::from(target) / u128::from(live_bid)) as u64; + assert!( + prepared.abs_diff(expected) <= expected / 500, + "the prepared price must set the fill: {prepared} vs ~{expected}" + ); + + // Only the persistent freshness override re-applies on the next slot. + svm.materialize_overrides_for_slot(&None, base_slot + 1) + .await + .expect("materialize persistent GoonFi freshness"); + let next = svm + .inner + .get_account(&oracle_key) + .expect("get oracle") + .expect("oracle present") + .data; + assert_eq!(oracle_slot(&next), base_slot + 1); + assert_eq!(read_u64(&next, ORACLE_BID_OFFSET), target); + assert_only_ranges_changed(&oracle, &next, &[(16, 20)]); +} + +#[tokio::test] +async fn goonfi_builder_scenario_materializes_and_fills_across_oracle_and_market() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + builder_prepares_and_the_program_fills(&fork).await; +} + +#[tokio::test] +async fn goonfi_price_and_reference_band_control_the_deployed_program() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + let amount = fork.spec.amount_in; + + let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("baseline sell"); + assert!(baseline > 0); + + // No-op rewrite proves the encoding round-trips; the program cannot tell the bytes moved. + let noop = goonfi_run(&fork, RunConfig::sell(amount), |oracle| { + let restated = read_u64(oracle, ORACLE_BID_OFFSET); + write_u64(oracle, ORACLE_BID_OFFSET, restated); + }) + .expect("no-op sell"); + assert_eq!(noop, baseline); + + // Coupled halve and double move the fill linearly in both directions. + let halved = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("coupled halved sell"); + assert!( + (halved * 2).abs_diff(baseline) <= 4, + "halving the price must halve the fill: {halved} * 2 vs {baseline}" + ); + let doubled = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 2, 1), + |market| scale_refs(market, 2, 1), + ) + .expect("coupled doubled sell"); + assert!( + doubled.abs_diff(baseline * 2) <= baseline / 500, + "doubling the price must double the fill: {doubled} vs 2 * {baseline}" + ); + + // Decoupled moves reject: the band guards each direction against the venue-unfavorable side. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell(amount), |oracle| { + scale_prices(oracle, 2, 1) + }), + ERROR_PRICE_OUT_OF_BAND, + "sell with raised oracle and untouched reference band", + ); + assert_rejects_with( + goonfi_run(&fork, RunConfig::buy(100_000_000), |oracle| { + scale_prices(oracle, 1, 2) + }), + ERROR_PRICE_OUT_OF_BAND, + "buy with lowered oracle and untouched reference band", + ); + let coupled_buy = goonfi_run_full( + &fork, + RunConfig::buy(100_000_000), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("coupled halved buy"); + assert!(coupled_buy > 0); + + assert_rejects_with( + goonfi_run( + &fork, + RunConfig { + min_amount_out: u64::MAX, + ..RunConfig::sell(amount) + }, + |_| {}, + ), + ERROR_MIN_AMOUNT_OUT, + "sell with an impossible min_amount_out", + ); + + // Keep the successful trade size fixed so other input limits cannot mask vault depletion. + let mut limited = fork.clone(); + write_u64(&mut limited.quote_vault.data, 64, baseline); + let exact_inventory = goonfi_run(&limited, RunConfig::sell(amount), |_| {}) + .expect("sell with exactly enough quote inventory"); + assert_eq!(exact_inventory, baseline); + + write_u64(&mut limited.quote_vault.data, 64, baseline - 1); + assert_rejects_with( + goonfi_run(&limited, RunConfig::sell(amount), |_| {}), + ERROR_INSUFFICIENT_LIQUIDITY, + "sell with quote inventory one atomic unit below the measured output", + ); + write_u64(&mut limited.quote_vault.data, 64, 0); + assert_rejects_with( + goonfi_run(&limited, RunConfig::sell(amount), |_| {}), + ERROR_INSUFFICIENT_LIQUIDITY, + "sell against a drained quote vault", + ); +} + +fn stamp_multiplier(data: &mut [u8], multiplier: u32) { + data[ORACLE_MULTIPLIER_OFFSET..ORACLE_MULTIPLIER_OFFSET + 4] + .copy_from_slice(&multiplier.to_le_bytes()); +} + +/// First rejection age in 15..=40 under the given multiplier, asserting fills decay +/// monotonically before it and every rejection carries the staleness error. +fn rejection_boundary(fork: &GoonfiFork, amount: u64, multiplier: u32) -> u64 { + let mut previous = u64::MAX; + let mut first_rejection = None; + for age in 15..=40 { + let result = goonfi_run(fork, RunConfig::sell_at_age(amount, age), |oracle| { + stamp_multiplier(oracle, multiplier) + }); + match result { + Ok(output) => { + assert!( + first_rejection.is_none(), + "age {age} filled after the window closed at {first_rejection:?}" + ); + assert!(output <= previous, "decay reversed at age {age}"); + previous = output; + } + Err(error) => { + assert!( + error.contains(ERROR_STALE_ORACLE), + "age {age}: expected {ERROR_STALE_ORACLE} in: {error}" + ); + first_rejection.get_or_insert(age); + } + } + } + first_rejection.expect("no rejection up to age 40") +} + +#[tokio::test] +async fn goonfi_stale_quote_decays_then_rejects_and_freshness_restores() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + let amount = fork.spec.amount_in; + + let fresh = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("fresh sell"); + let aged = goonfi_run(&fork, RunConfig::sell_at_age(amount, 10), |_| {}).expect("aged sell"); + assert!( + aged < fresh, + "the program decays a quote with age: {aged} at age 10 vs {fresh} at age 1" + ); + + // The boundary's source is per-market and unidentified; this range is a safety canary + // around the observed value, not a fixed protocol constant. + let live_multiplier = read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET); + let boundary = rejection_boundary(&fork, amount, live_multiplier); + assert!( + (15..=35).contains(&boundary), + "rejection boundary {boundary} left the observed range" + ); + + // The multiplier at offset 20 scales the decay, not the window: at half and double the live + // value the boundary stays put, the decay rate scales with it, and the program leaves the + // oracle bytes untouched. + let mut decay_per_multiplier = Vec::new(); + for (label, numerator, denominator) in [("half", 1u64, 2u64), ("live", 1, 1), ("double", 2, 1)] + { + let multiplier = + u32::try_from(u64::from(live_multiplier) * numerator / denominator).expect("fits u32"); + let mut expected_oracle = fork.oracle.data.clone(); + stamp_multiplier(&mut expected_oracle, multiplier); + + let (at_age_1, oracle_after) = goonfi_run_capturing_oracle( + &fork, + RunConfig::sell(amount), + |oracle| stamp_multiplier(oracle, multiplier), + |_| {}, + ) + .unwrap_or_else(|error| panic!("sell at {label} multiplier: {error}")); + assert_eq!( + oracle_after, expected_oracle, + "the swap must not write the oracle ({label} multiplier)" + ); + let at_age_10 = goonfi_run(&fork, RunConfig::sell_at_age(amount, 10), |oracle| { + stamp_multiplier(oracle, multiplier) + }) + .unwrap_or_else(|error| panic!("aged sell at {label} multiplier: {error}")); + decay_per_multiplier.push(at_age_1 - at_age_10); + + assert_eq!( + rejection_boundary(&fork, amount, multiplier), + boundary, + "the {label} multiplier must not move the rejection boundary" + ); + } + let [half, live, double] = decay_per_multiplier[..] else { + unreachable!() + }; + assert!( + double.abs_diff(live * 2) <= live / 25, + "doubling the multiplier must double the decay: {double} vs 2 * {live}" + ); + assert!( + (half * 2).abs_diff(live) <= live / 25, + "halving the multiplier must halve the decay: {half} * 2 vs {live}" + ); + + // The wall-clock timestamp beside the slot is not consulted. + let ts_aged = goonfi_run( + &fork, + RunConfig { + clock_ts_age: 3_600, + ..RunConfig::sell(amount) + }, + |_| {}, + ) + .expect("sell an hour of wall-clock later"); + assert_eq!(ts_aged, fresh); + + // Deep staleness rejects; re-stamping the u32 slot alone restores the quote, which is what + // the goonfi-freshness template does at every materialization. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell_at_age(amount, 1_000), |_| {}), + ERROR_STALE_ORACLE, + "sell at age 1000", + ); + stale_template_default_rejects(&fork, amount); + let restamped_slot = oracle_slot(&fork.oracle.data) + 1_000; + let restamped = goonfi_run(&fork, RunConfig::sell_at_age(amount, 1_000), |oracle| { + oracle[ORACLE_SLOT_OFFSET..ORACLE_SLOT_OFFSET + 4] + .copy_from_slice(&(restamped_slot as u32).to_le_bytes()); + }) + .expect("sell at age 1000 with a re-stamped slot"); + assert!( + restamped * 100 >= fresh * 99, + "a re-stamped quote must fill near full price: {restamped} vs {fresh}" + ); +} + +#[tokio::test] +async fn goonfi_second_market_proves_generic_price_and_staleness_layout() { + let fork = with_controlled_inventory(goonfi_fork(SOL_USDC_MARKET).await); + let amount = fork.spec.amount_in; + + let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("SOL/USDC sell"); + let halved = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("SOL/USDC coupled halved sell"); + assert!( + (halved * 2).abs_diff(baseline) <= 4, + "halving must halve on the second market too: {halved} * 2 vs {baseline}" + ); + + let bought = goonfi_run(&fork, RunConfig::buy(100_000_000), |_| {}).expect("SOL/USDC buy"); + assert!(bought > 0); + + builder_prepares_and_the_program_fills(&fork).await; + + // Well past every observed window on this market tier; the stablecoin tier's deeper windows + // are covered by the stale-template default proof below. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell_at_age(amount, 200), |_| {}), + ERROR_STALE_ORACLE, + "SOL/USDC sell past the staleness window", + ); + + // The stablecoin tier fills at ages that reject every other market (USDT/USDC filled at age + // 100 live), so the stale template's -2000 default must out-age even that window. + let stable = with_controlled_inventory( + fork_from_market("EEUNhHsRoUVgJUFpkupmdF4v7uLUw1zhYLp7u9s8zFqG", 0).await, + ); + let stable_amount = 1_000_000; + let filled = goonfi_run(&stable, RunConfig::sell_at_age(stable_amount, 50), |_| {}) + .expect("USDT/USDC fills at an age that rejects every volatile market"); + assert!(filled > 0); + stale_template_default_rejects(&stable, stable_amount); +} + +#[tokio::test] +async fn goonfi_discovery_fetches_live_market_and_oracle_relationships() { + use std::collections::HashSet; + + let markets = discover_goonfi_markets(&live::client()) + .await + .expect("discover GoonFi markets through the real RPC client"); + assert!( + !markets.is_empty(), + "live GoonFi discovery returned no markets" + ); + let default = markets + .iter() + .find(|market| market.address == Pubkey::from_str_const(SOL_USDC_MARKET.market)) + .expect("live discovery must include the default SOL/USDC market"); + assert_eq!( + default.oracle, + Pubkey::from_str_const(SOL_USDC_MARKET.oracle) + ); + assert_eq!( + default.base_mint, + Pubkey::from_str_const(SOL_USDC_MARKET.base_mint) + ); + assert_eq!( + default.quote_mint, + Pubkey::from_str_const(SOL_USDC_MARKET.quote_mint) + ); + assert_eq!((default.base_decimals, default.quote_decimals), (9, 6)); + let mut addresses = HashSet::new(); + let mut oracles = HashSet::new(); + for market in &markets { + assert!( + addresses.insert(market.address), + "duplicate discovered market {}", + market.address + ); + assert!( + oracles.insert(market.oracle), + "duplicate discovered oracle {}", + market.oracle + ); + } + for chunk in markets.chunks(40) { + let addresses: Vec = chunk + .iter() + .flat_map(|market| [market.address, market.oracle]) + .collect(); + let accounts = live::fetch(&addresses).await; + for (discovered, accounts) in chunk.iter().zip(accounts.chunks_exact(2)) { + let validated = GoonfiMarket::validate(discovered.address, &accounts[0], &accounts[1]) + .expect("discovered market and oracle must retain their live owners and layouts"); + assert_eq!( + validated.oracle, discovered.oracle, + "live market oracle pointer changed" + ); + assert_eq!(&accounts[0].data[80..112], discovered.base_mint.as_ref()); + assert_eq!(&accounts[0].data[112..144], discovered.quote_mint.as_ref()); + } + } + eprintln!( + "GoonFi real RPC discovery verified {} unique live market/oracle pairs", + markets.len() + ); +} diff --git a/crates/core/src/tests/live.rs b/crates/core/src/tests/live.rs new file mode 100644 index 000000000..a9891ecfb --- /dev/null +++ b/crates/core/src/tests/live.rs @@ -0,0 +1,68 @@ +//! 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 mut errors = Vec::new(); + let results = loop { + match client() + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + { + Ok(results) => break results, + Err(error) if attempt < 4 => { + attempt += 1; + errors.push(format!("attempt {attempt}: {error}")); + tokio::time::sleep(std::time::Duration::from_millis(500 * attempt)).await; + } + Err(error) => { + errors.push(format!("attempt {}: {error}", attempt + 1)); + panic!( + "failed to fetch {addresses:?} from mainnet after {} attempts: {}", + errors.len(), + errors.join("; ") + ); + } + } + }; + + 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 eeb64407a..e239a147f 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,9 +1,13 @@ #[cfg(feature = "integration-tests")] pub mod bisonfi; +#[cfg(feature = "integration-tests")] +pub mod goonfi; pub mod helpers; pub mod integration; #[cfg(feature = "integration-tests")] pub mod kamino; +#[cfg(feature = "integration-tests")] +pub mod live; pub mod plugin; #[cfg(feature = "integration-tests")] pub mod pump; diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 0ef4ebe29..37651d608 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -21,6 +21,8 @@ rmcp = { workspace = true, features = ["transport-io", "transport-sse-server", " serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" +solana-account = { workspace = true } +solana-commitment-config = { workspace = true } solana-keypair = { workspace = true } solana-pubkey = { workspace = true } solana-signer = { workspace = true } diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 7ce4c3e08..6614db201 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -19,8 +19,14 @@ use start_surfnet::StartSurfnetResponse; use surfpool_core::{ scenarios::{ TemplateRegistry, - protocols::pump::v1::graduation_builder::{ - build_pump_graduation_scenario, pump_graduation_addresses, + protocols::{ + goonfi::v1::{ + GoonfiMarket, build_goonfi_liquidity_scenario, build_goonfi_price_scenario, + discover_goonfi_markets, vault_addresses, + }, + pump::v1::graduation_builder::{ + build_pump_graduation_scenario, pump_graduation_addresses, + }, }, }, solana_account::Account, @@ -37,6 +43,48 @@ use crate::helpers::find_next_available_surfnet_port; mod set_token_account; mod start_surfnet; +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListGoonfiMarketsParams { + #[schemars(description = "Port of the selected local Surfnet RPC; default 8899.")] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateGoonfiPriceScenarioParams { + #[schemars( + description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." + )] + pub market: Option, + #[schemars( + description = "The price of one base token in quote tokens, as a positive decimal string such as \"99.74\". Not atomic units: GoonFi prices are decimals-independent." + )] + pub price: 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 CreateGoonfiLiquidityScenarioParams { + #[schemars( + description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." + )] + pub market: Option, + #[schemars( + description = "Base vault liquidity to keep, in basis points: 0 drains the base vault so swaps are rejected for insufficient liquidity (0x1), 10000 leaves it unchanged. Defaults to 0." + )] + pub base_remaining_bps: Option, + #[schemars( + description = "Quote vault liquidity to keep, in basis points: 0 drains the quote vault, 10000 leaves it unchanged. Defaults to 0." + )] + pub quote_remaining_bps: Option, + #[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 StartSurfnetParams { #[schemars( @@ -399,6 +447,38 @@ impl Surfpool { .collect()) } + async fn fetch_goonfi_market( + &self, + address: Option<&str>, + surfnet_port: Option, + ) -> Result { + let market_address = match address.map(str::trim) { + None | Some("") => { + surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET + } + Some(value) => Pubkey::from_str(value) + .map_err(|error| format!("Invalid GoonFi market pubkey: {error}"))?, + }; + let market_account = self + .fetch_surfnet_accounts(surfnet_port, &[market_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi market account {market_address} was not found"))?; + let oracle_address = + GoonfiMarket::oracle_address(&market_account).map_err(|error| error.to_string())?; + let oracle_account = self + .fetch_surfnet_accounts(surfnet_port, &[oracle_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi oracle {oracle_address} was not found"))?; + GoonfiMarket::validate(market_address, &market_account, &oracle_account) + .map_err(|error| error.to_string()) + } + async fn stage_scenario(&self, scenario: Scenario) -> Result { let endpoint = format!( "http://127.0.0.1:{}/v1/scenarios", @@ -1001,6 +1081,141 @@ impl Surfpool { self.stage_scenario(preparation.scenario).await } + #[tool( + description = "Lists GoonFi markets discovered from program accounts on the selected Surfnet. Returns market and oracle addresses, pair labels, and base/quote mint identities and decimals. Validates market, oracle and mint accounts. Use address for scenario creation and oracle for oracle templates. Unknown symbols use full mint addresses." + )] + async fn list_goonfi_markets( + &self, + Parameters(params): Parameters, + ) -> Result { + let port = params.surfnet_port.unwrap_or(DEFAULT_RPC_PORT); + let client = SurfnetRemoteClient::new(format!("http://127.0.0.1:{port}")); + let markets = match discover_goonfi_markets(&client).await { + Ok(markets) => markets, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let markets = markets + .iter() + .map(|market| { + serde_json::json!({ + "address": market.address.to_string(), + "oracle": market.oracle.to_string(), + "label": market.label(), + "baseMint": market.base_mint.to_string(), + "quoteMint": market.quote_mint.to_string(), + "baseDecimals": market.base_decimals, + "quoteDecimals": market.quote_decimals, + }) + }) + .collect::>(); + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({"count": markets.len(), "markets": markets}).to_string(), + )])) + } + + #[tool( + description = "Creates one editable GoonFi price scenario for a live market. Reads the market account from the running surfnet, resolves its price oracle by the market's own pointer, and moves the oracle bid/ask together with the market's reference band while keeping the quote fresh. Prepares state; sends no swap. Resolve `market` through list_goonfi_markets." + )] + async fn create_goonfi_price_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market = match self + .fetch_goonfi_market(params.market.as_deref(), params.surfnet_port) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let preparation = match build_goonfi_price_scenario(&market, ¶ms.price) { + Ok(preparation) => preparation, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(preparation.scenario).await + } + + #[tool( + description = "Creates one editable GoonFi liquidity-drain scenario for a live market. Reads the market from the running surfnet, resolves its two token vaults by the market's own pointers, and scales each vault balance to the requested basis points (0 drains it so a swap is rejected for insufficient liquidity, 10000 leaves it unchanged), keeping the quote fresh. Prepares state; sends no swap. Resolve `market` through list_goonfi_markets." + )] + async fn create_goonfi_liquidity_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market_address = match params.market.as_deref().map(str::trim) { + None | Some("") => { + surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET + } + Some(value) => match Pubkey::from_str(value) { + Ok(market) => market, + Err(error) => { + return Ok(scenario_tool_error(format!( + "Invalid GoonFi market pubkey: {error}" + ))); + } + }, + }; + let market_account = match self + .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) + .await + { + Ok(mut accounts) => match accounts.remove(0) { + Some(account) => account, + None => { + return Ok(scenario_tool_error(format!( + "GoonFi market account {market_address} was not found" + ))); + } + }, + Err(error) => return Ok(scenario_tool_error(error)), + }; + // Vaults and oracle are read from the market's own pointers, never taken from the caller. + let [base_vault, quote_vault] = match vault_addresses(&market_account) { + Ok(addresses) => addresses, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let oracle = match GoonfiMarket::oracle_address(&market_account) { + Ok(oracle) => oracle, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let referenced = match self + .fetch_surfnet_accounts(params.surfnet_port, &[base_vault, quote_vault, oracle]) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let account = |index: usize, name: &str| { + referenced[index] + .as_ref() + .ok_or_else(|| format!("GoonFi {name} account was not found")) + }; + let (base_account, quote_account, oracle_account) = match ( + account(0, "base vault"), + account(1, "quote vault"), + account(2, "oracle"), + ) { + (Ok(base), Ok(quote), Ok(oracle)) => (base, quote, oracle), + (Err(error), ..) | (_, Err(error), _) | (.., Err(error)) => { + return Ok(scenario_tool_error(error)); + } + }; + let preparation = match build_goonfi_liquidity_scenario( + market_address, + &market_account, + base_account, + quote_account, + oracle_account, + params.base_remaining_bps.unwrap_or(0), + params.quote_remaining_bps.unwrap_or(0), + ) { + 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." )] @@ -1315,6 +1530,43 @@ mod tests { }) } + #[tokio::test] + async fn goonfi_price_rejects_a_bad_market_before_any_rpc() { + let surfpool = Surfpool::new(); + let result = surfpool + .create_goonfi_price_scenario(Parameters(CreateGoonfiPriceScenarioParams { + surfnet_port: None, + market: Some("not-a-pubkey".to_string()), + price: "99.74".to_string(), + })) + .await + .expect("the tool reports input errors in its payload, not as a protocol error"); + let text = format!("{:?}", result.content); + assert!( + text.contains("Invalid GoonFi market pubkey"), + "unexpected payload: {text}" + ); + } + + #[tokio::test] + async fn goonfi_liquidity_rejects_a_bad_market_before_any_rpc() { + let surfpool = Surfpool::new(); + let result = surfpool + .create_goonfi_liquidity_scenario(Parameters(CreateGoonfiLiquidityScenarioParams { + surfnet_port: None, + market: Some("not-a-pubkey".to_string()), + base_remaining_bps: Some(0), + quote_remaining_bps: Some(0), + })) + .await + .expect("the tool reports input errors in its payload, not as a protocol error"); + let text = format!("{:?}", result.content); + assert!( + text.contains("Invalid GoonFi market pubkey"), + "unexpected payload: {text}" + ); + } + #[tokio::test] async fn get_override_templates_summarizes_constants_instead_of_inlining_options() { let surfpool = Surfpool::new(); diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index 0e25a7a4a..29476fb30 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1032,11 +1032,20 @@ pub enum RawEncoding { /// A base58 pubkey, written as 32 bytes. Bytes32, /// The slot the override materializes at, plus `lead` (may be negative). + /// + /// `width` is the byte width of the on-chain slot field: 8, or 4 for programs that store + /// slots as u32 next to unrelated bytes. Only those two widths are accepted. Slot { lead: i64, + #[serde(default = "default_slot_width")] + width: usize, }, } +fn default_slot_width() -> usize { + 8 +} + impl RawEncoding { /// Byte width of this encoding. pub fn width(&self) -> usize { @@ -1044,7 +1053,8 @@ impl RawEncoding { RawEncoding::U8 => 1, RawEncoding::U16 => 2, RawEncoding::U32 | RawEncoding::I32 | RawEncoding::I32Strided { .. } => 4, - RawEncoding::U64 | RawEncoding::I64 | RawEncoding::Slot { .. } => 8, + RawEncoding::U64 | RawEncoding::I64 => 8, + RawEncoding::Slot { width, .. } => *width, RawEncoding::U128 | RawEncoding::I128 => 16, RawEncoding::Bytes32 => 32, } @@ -1107,7 +1117,7 @@ impl RawEncoding { .to_bytes() .to_vec() } - RawEncoding::Slot { lead } => { + RawEncoding::Slot { lead, width } => { let lead = match value { serde_json::Value::Null => *lead, _ => { @@ -1123,7 +1133,14 @@ impl RawEncoding { } else { target_slot.checked_sub(lead.unsigned_abs()).unwrap_or(0) }; - slot.to_le_bytes().to_vec() + match width { + 8 => slot.to_le_bytes().to_vec(), + 4 => u32::try_from(slot) + .map_err(|_| format!("slot {slot} does not fit a 4-byte slot field"))? + .to_le_bytes() + .to_vec(), + other => return Err(format!("slot width must be 4 or 8, not {other}")), + } } }) } @@ -1156,6 +1173,10 @@ pub struct RawLayout { /// Optional type tag. Omit for programs that have none. #[serde(default, skip_serializing_if = "Option::is_none")] pub magic: Option, + /// Base58 program id that must own the account. The byte guard cannot see the owner, so + /// without this a foreign account of the same size and magic passes a raw write. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, } impl RawLayout { @@ -1184,6 +1205,22 @@ impl RawLayout { } /// Writes `values` into a copy of `data` using each property's offset and encoding. + /// Rejects an account owned by the wrong program, when the layout names one. Split from + /// [`RawLayout::guard`] because the byte guard has no access to the owner. + pub fn guard_owner(&self, owner: &Pubkey) -> Result<(), String> { + let Some(required) = &self.owner else { + return Ok(()); + }; + let required = Pubkey::from_str(required) + .map_err(|e| format!("raw layout owner '{required}' is not a valid pubkey: {e}"))?; + if owner != &required { + return Err(format!( + "account owner {owner} is not the layout's program {required}" + )); + } + Ok(()) + } + pub fn materialize( &self, data: &[u8], @@ -1562,51 +1599,123 @@ mod tests { assert_eq!(i64::from_le_bytes(bytes.try_into().unwrap()) >> 32, -25599); // The supplied value is the lead, so one property covers live and stale. - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 500); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-5), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 495); // The manifest lead is the default, used when no value is given. - let bytes = RawEncoding::Slot { lead: -1 } + let bytes = RawEncoding::Slot { lead: -1, width: 8 } .encode(&json!(null), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 499); // A lead that would go below zero clamps rather than wrapping. - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-10), 3) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); // Slot is a u64. Values above i64::MAX must not wrap through a signed cast and become zero. let large_slot = i64::MAX as u64 + 1; - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), large_slot) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), large_slot); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-1), u64::MAX) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX - 1); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), u64::MAX) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX); - let err = RawEncoding::Slot { lead: 0 } + let err = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(1), u64::MAX) .expect_err("a positive lead must not wrap past u64::MAX"); assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); } + #[test] + fn slot_width_defaults_to_eight_and_narrows_to_four() { + use super::RawEncoding; + + // Manifests that spell no width keep the historical 8-byte slot bytes exactly. + let parsed: RawEncoding = serde_json::from_value(json!({"slot": {"lead": -20}})).unwrap(); + assert_eq!( + parsed, + RawEncoding::Slot { + lead: -20, + width: 8 + } + ); + assert_eq!( + parsed.encode(&json!(null), 500).unwrap(), + 480u64.to_le_bytes().to_vec() + ); + + let narrow: RawEncoding = + serde_json::from_value(json!({"slot": {"lead": 0, "width": 4}})).unwrap(); + assert_eq!(narrow.width(), 4); + assert_eq!( + narrow.encode(&json!(null), 500).unwrap(), + 500u32.to_le_bytes().to_vec() + ); + + let err = narrow + .encode(&json!(null), u64::from(u32::MAX) + 1) + .expect_err("a slot past u32::MAX must not be truncated"); + assert!(err.contains("4-byte"), "unexpected error: {err}"); + + let err = RawEncoding::Slot { lead: 0, width: 2 } + .encode(&json!(null), 500) + .expect_err("only widths 4 and 8 exist"); + assert!(err.contains("must be 4 or 8"), "unexpected error: {err}"); + } + + #[test] + fn raw_layout_owner_predicate_rejects_the_wrong_program() { + use super::{Pubkey, RawLayout}; + + let program = Pubkey::new_unique(); + let layout = RawLayout { + account_size: 32, + magic: None, + owner: Some(program.to_string()), + }; + assert!(layout.guard_owner(&program).is_ok()); + let err = layout + .guard_owner(&Pubkey::new_unique()) + .expect_err("a foreign owner must be refused"); + assert!( + err.contains("is not the layout's program"), + "unexpected error: {err}" + ); + + // No owner in the layout keeps the historical behavior: any owner passes. + let open = RawLayout { + account_size: 32, + magic: None, + owner: None, + }; + assert!(open.guard_owner(&Pubkey::new_unique()).is_ok()); + + let broken = RawLayout { + account_size: 32, + magic: None, + owner: Some("not-a-pubkey".to_string()), + }; + assert!(broken.guard_owner(&program).is_err()); + } + #[test] fn raw_layout_rejects_writes_past_the_end_of_the_account() { use super::{Property, RawEncoding, RawLayout}; @@ -1614,6 +1723,7 @@ mod tests { let layout = RawLayout { account_size: 16, magic: None, + owner: None, }; let mut property = Property::field("tail".to_string()); property.offset = Some(12); @@ -1636,6 +1746,7 @@ mod tests { let layout = RawLayout { account_size: 64, magic: None, + owner: None, }; let mut property = Property::field("ticks".to_string()); property.offset = Some(4); @@ -1679,6 +1790,7 @@ mod tests { let layout = RawLayout { account_size: 32, magic: None, + owner: None, }; let mut property = Property::field("ticks".to_string()); property.offset = Some(4); From 6aa7450cceb2fb0708ff01906d60d4751a914a83 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:37:58 +0300 Subject: [PATCH 2/7] fix(goonfi): address review findings - Catalog discovery skips a market that fails validation with a warning instead of aborting the whole scan, so one obsolete market can no longer hide every valid one. An empty catalog from a program that does own markets stays an error. - Vault balances are read through TokenAccount::unpack and checked against the market's own mint: an owner-and-length check also admits an 82-byte mint, whose bytes at the amount offset would be misread as a balance. - GoonfiMarket is non_exhaustive, so a market and its oracle can only be paired through validate, which reads the oracle from the market's own pointer. --- .../protocols/goonfi/v1/liquidity.rs | 120 +++++++++++++----- .../scenarios/protocols/goonfi/v1/markets.rs | 52 +++++--- .../src/scenarios/protocols/goonfi/v1/mod.rs | 4 +- .../scenarios/protocols/goonfi/v1/price.rs | 5 + 4 files changed, 126 insertions(+), 55 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index adf3f58bd..c02fdae3a 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -16,6 +16,7 @@ use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenari use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, + types::TokenAccount, }; use super::{ @@ -27,8 +28,6 @@ const BASE_MINT_OFFSET: usize = 80; const QUOTE_MINT_OFFSET: usize = 112; const BASE_VAULT_OFFSET: usize = 144; const QUOTE_VAULT_OFFSET: usize = 176; -/// The SPL token account amount field. -const AMOUNT_OFFSET: usize = 64; const LIQUIDITY_TEMPLATE: &str = "spl-token-account-balance"; const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; @@ -95,11 +94,10 @@ pub fn build_goonfi_liquidity_scenario( let oracle = GoonfiMarket::oracle_address(market_account)?; validate_goonfi_oracle_layout(oracle_account)?; - let base_amount = vault_amount(base_vault_account)?; - let quote_amount = vault_amount(quote_vault_account)?; - let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + let base_amount = vault_amount(base_vault_account, "base", &base_mint)?; + let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint)?; let label = market_label(&base_mint, "e_mint); let registry = TemplateRegistry::new(); @@ -168,18 +166,27 @@ pub fn build_goonfi_liquidity_scenario( }) } -/// The SPL token vaults are 32 undiscriminated-looking bytes at the front; the owner check is the -/// real discriminator that keeps a balance write out of a foreign account. -fn vault_amount(account: &Account) -> SurfpoolResult { +/// Reads a vault balance, proving first that the account really is that market's token vault. +/// +/// An owner-and-length check is not enough: a mint is also owned by the token program and is long +/// enough to read an amount out of, so it would pass and its bytes would be misread as a balance. +/// Unpacking rejects anything that is not a token account, and the mint comparison ties the vault +/// to the side of the market it is supposed to hold. +fn vault_amount(account: &Account, side: &str, expected_mint: &Pubkey) -> SurfpoolResult { if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { - return Err(invalid("vault is not owned by a supported token program")); + return Err(invalid(format!( + "{side} vault is not owned by a supported token program" + ))); } - let bytes: [u8; 8] = account - .data - .get(AMOUNT_OFFSET..AMOUNT_OFFSET + 8) - .and_then(|slice| slice.try_into().ok()) - .ok_or_else(|| invalid("vault is too small to be an SPL token account"))?; - Ok(u64::from_le_bytes(bytes)) + let vault = TokenAccount::unpack(&account.data) + .map_err(|error| invalid(format!("{side} vault is not a token account: {error}")))?; + if vault.mint() != *expected_mint { + return Err(invalid(format!( + "{side} vault holds mint {} but the market's {side} mint is {expected_mint}", + vault.mint() + ))); + } + Ok(vault.amount()) } fn remaining_label(bps: u16) -> String { @@ -230,9 +237,14 @@ mod tests { } } - fn vault(amount: u64) -> Account { + fn vault(mint: &Pubkey, amount: u64) -> Account { + const AMOUNT_OFFSET: usize = 64; + const STATE_OFFSET: usize = 108; let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(Pubkey::new_unique().as_ref()); data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); + data[STATE_OFFSET] = 1; Account { data, owner: spl_token_interface::ID, @@ -256,8 +268,8 @@ mod tests { let preparation = build_goonfi_liquidity_scenario( market, &market_account(&base_vault, "e_vault), - &vault(2_441_078_070_812), - &vault(216_136_231_615), + &vault(&WSOL, 2_441_078_070_812), + &vault(&USDC, 216_136_231_615), &oracle(), 0, 0, @@ -267,15 +279,15 @@ mod tests { assert_eq!(preparation.base_vault, base_vault); assert_eq!(preparation.quote_vault, quote_vault); // A friendly pair label, not the raw market pubkey. - assert_eq!( - preparation.scenario.name, - "GoonFi SOL/USDC liquidity drain" - ); + assert_eq!(preparation.scenario.name, "GoonFi SOL/USDC liquidity drain"); let [base, quote, freshness] = &preparation.scenario.overrides[..] else { panic!("expected base drain, quote drain and freshness overrides"); }; assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); - assert_eq!(quote.account, AccountAddress::Pubkey(quote_vault.to_string())); + assert_eq!( + quote.account, + AccountAddress::Pubkey(quote_vault.to_string()) + ); assert_eq!(base.values.get("amount"), Some(&serde_json::json!("0"))); assert_eq!(quote.values.get("amount"), Some(&serde_json::json!("0"))); assert!(!base.fetch_before_use); @@ -298,8 +310,8 @@ mod tests { let preparation = build_goonfi_liquidity_scenario( Pubkey::new_unique(), &market_account(&base_vault, "e_vault), - &vault(1_000), - &vault(999), + &vault(&WSOL, 1_000), + &vault(&USDC, 999), &oracle(), 2_500, FULL_BPS, @@ -326,8 +338,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), 10_001, 0 @@ -338,8 +350,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), FULL_BPS, FULL_BPS @@ -356,8 +368,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &foreign_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), 0, 0 @@ -368,14 +380,14 @@ mod tests { // A vault not owned by a token program is not a real vault. let foreign_vault = Account { owner: Pubkey::new_unique(), - ..vault(1) + ..vault(&WSOL, 1) }; assert!( build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, &foreign_vault, - &vault(1), + &vault(&USDC, 1), &oracle(), 0, 0 @@ -392,8 +404,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &foreign_oracle, 0, 0 @@ -402,6 +414,44 @@ mod tests { ); } + /// An owner-and-length check would pass a mint: it is token-program-owned and long enough to + /// misread an amount out of. Unpacking plus the mint comparison is what rejects it. + #[test] + fn rejects_a_vault_that_is_not_this_markets_token_account() { + let market = market_account(&Pubkey::new_unique(), &Pubkey::new_unique()); + let mint_account = Account { + data: vec![0u8; 82], + owner: spl_token_interface::ID, + ..Account::default() + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market, + &mint_account, + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A real token account holding the other side's mint is refused as well. + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market, + &vault(&USDC, 1), + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + } + #[test] fn resolves_vault_addresses_from_the_market() { let base_vault = Pubkey::new_unique(); diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index e4f4a6ba3..b8c71c728 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -121,19 +121,24 @@ pub async fn discover_goonfi_markets( ) .await? .into_result()?; - let accounts = accounts - .into_iter() - .map(|(address, encoded)| { - let account: Account = encoded.to_account().ok_or_else(|| { - SurfpoolError::internal(format!("Could not decode GoonFi market {address}")) - })?; - market_references(&account)?; - Ok((address, account)) - }) - .collect::>>()?; + // One obsolete or malformed market must not hide every valid one, so a market that fails + // validation is skipped with a warning and the rest of the catalog is still returned. This is + // the same warn-and-continue rule the materializer applies per override. + let candidates = accounts.len(); + let mut retained = Vec::new(); let mut addresses = Vec::new(); - for (_, account) in &accounts { - addresses.extend(market_references(account)?); + for (address, encoded) in accounts { + let Some(account) = encoded.to_account() else { + warn!("Skipping GoonFi market {address}: its account data could not be decoded"); + continue; + }; + match market_references(&account) { + Ok(references) => { + addresses.extend(references); + retained.push((address, account)); + } + Err(error) => warn!("Skipping GoonFi market {address}: {error}"), + } } addresses.sort_unstable(); addresses.dedup(); @@ -143,13 +148,26 @@ pub async fn discover_goonfi_markets( .get_multiple_accounts(batch, CommitmentConfig::confirmed()) .await?; for (address, account) in batch.iter().zip(fetched) { - references.insert(*address, account.map_account()?); + // A reference the fork cannot serve disqualifies only the markets pointing at it, + // which `resolve_market` reports below. + if let Ok(account) = account.map_account() { + references.insert(*address, account); + } } } - let mut markets = accounts - .iter() - .map(|(address, account)| resolve_market(*address, account, &references)) - .collect::>>()?; + let mut markets = Vec::new(); + for (address, account) in &retained { + match resolve_market(*address, account, &references) { + Ok(market) => markets.push(market), + Err(error) => warn!("Skipping GoonFi market {address}: {error}"), + } + } + // An empty catalog from a program that does own markets is a failure, not a partial result. + if markets.is_empty() && candidates > 0 { + return Err(SurfpoolError::internal(format!( + "none of the {candidates} discovered GoonFi markets validated; the integration needs a refresh" + ))); + } markets.sort_by_cached_key(|market| { ( market.address != GOONFI_DEFAULT_MARKET, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index c788b126d..b0852c772 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -2,9 +2,7 @@ mod liquidity; mod markets; mod price; -pub use liquidity::{ - GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses, -}; +pub use liquidity::{GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses}; pub use price::{ GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 61cda2863..43d731d81 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -57,7 +57,12 @@ const PREPARATION_SLOT: u64 = 0; /// The parts of a GoonFi market a price move needs: the market account itself and the oracle it /// points at. +/// +/// `non_exhaustive` so the pair can only be built through `validate`, which reads the oracle from +/// the market's own pointer. A caller assembling the two fields independently could otherwise +/// point a price move at one market's band and an unrelated market's oracle. #[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] pub struct GoonfiMarket { pub address: Pubkey, pub oracle: Pubkey, From 610556b431caab0a30d11a17b56dd5e06835475c Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:52:27 +0300 Subject: [PATCH 3/7] fix(goonfi): make the validated market pair unforgeable `non_exhaustive` only blocked building a GoonfiMarket from scratch outside the crate; the fields stayed public, so a caller could still re-point a validated pair's oracle. Making both private leaves `validate` as the only way to pair a market with an oracle, and it reads that oracle from the market's own pointer. --- .../core/src/scenarios/protocols/goonfi/v1/price.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 43d731d81..d005c9703 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -58,14 +58,15 @@ const PREPARATION_SLOT: u64 = 0; /// The parts of a GoonFi market a price move needs: the market account itself and the oracle it /// points at. /// -/// `non_exhaustive` so the pair can only be built through `validate`, which reads the oracle from -/// the market's own pointer. A caller assembling the two fields independently could otherwise -/// point a price move at one market's band and an unrelated market's oracle. +/// The two are private so the pair can only be built through `validate`, which reads the oracle +/// from the market's own pointer. Public fields would let a caller assemble the pair from scratch +/// or re-point a validated one, aiming a price move at one market's reference band and an +/// unrelated market's oracle - a combination the deployed program rejects with 0x24 at best, and +/// silently misprices at worst. #[derive(Clone, Debug, PartialEq)] -#[non_exhaustive] pub struct GoonfiMarket { - pub address: Pubkey, - pub oracle: Pubkey, + address: Pubkey, + oracle: Pubkey, } impl GoonfiMarket { From b75ede6fdcc05ce9eff0a778a3ed9eae953d8318 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 10 Sep 2026 07:26:59 +0300 Subject: [PATCH 4/7] fix(goonfi): expose the validated market pair through read-only getters The fields are private, so a caller that legitimately needs to know which market and oracle a validated pair refers to had no way to ask. --- crates/core/src/scenarios/protocols/goonfi/v1/price.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index d005c9703..5cfd72601 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -91,6 +91,15 @@ impl GoonfiMarket { validate_goonfi_oracle_layout(oracle_account)?; Ok(Self { address, oracle }) } + + /// Read-only: the pair is fixed at validation so a caller can inspect it but not re-point it. + pub fn address(&self) -> Pubkey { + self.address + } + + pub fn oracle(&self) -> Pubkey { + self.oracle + } } /// Rejects an account that is not a GoonFi market. From 95e5e34930a8f7611024a3f1a17b1dcb928b9e62 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 11 Sep 2026 10:44:36 +0300 Subject: [PATCH 5/7] fix(goonfi): tie a market's address to the bytes it was fetched with A market account does not record its own address, so `validate` had to take the caller's word that the address and the data describe the same market. Its vaults do record it: a GoonFi vault is a token account whose authority is the market. `validate` now takes the base vault the market itself names and compares that authority, the same shape of cross-check the pump graduation builder makes through its curve vault's mint. The liquidity builder applies it too, from accounts it already holds. Neither path costs an extra round trip: discovery carries the base vault alongside the mints and oracle so it batches with them, and the MCP market fetch reads it in the same call as the oracle. Verified against all 33 live markets - every one names its market as the base vault's authority. --- .../protocols/goonfi/v1/liquidity.rs | 45 +++++++--- .../scenarios/protocols/goonfi/v1/markets.rs | 31 +++++-- .../src/scenarios/protocols/goonfi/v1/mod.rs | 2 +- .../scenarios/protocols/goonfi/v1/price.rs | 89 +++++++++++++++++-- crates/core/src/tests/goonfi/mod.rs | 29 ++++-- crates/mcp/src/surfpool/mod.rs | 27 ++++-- 6 files changed, 186 insertions(+), 37 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index c02fdae3a..07d131024 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -21,6 +21,7 @@ use crate::{ use super::{ GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, + verify_market_address, }; /// Read, never written, so no template declares them. @@ -91,6 +92,7 @@ pub fn build_goonfi_liquidity_scenario( } let [base_vault, quote_vault] = vault_addresses(market_account)?; + verify_market_address(market, base_vault_account)?; let oracle = GoonfiMarket::oracle_address(market_account)?; validate_goonfi_oracle_layout(oracle_account)?; @@ -237,12 +239,15 @@ mod tests { } } + /// The market every fixture below belongs to; its vaults name it as their authority. + const MARKET: Pubkey = Pubkey::from_str_const("GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC"); + fn vault(mint: &Pubkey, amount: u64) -> Account { const AMOUNT_OFFSET: usize = 64; const STATE_OFFSET: usize = 108; let mut data = vec![0u8; 165]; data[0..32].copy_from_slice(mint.as_ref()); - data[32..64].copy_from_slice(Pubkey::new_unique().as_ref()); + data[32..64].copy_from_slice(MARKET.as_ref()); data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); data[STATE_OFFSET] = 1; Account { @@ -264,7 +269,7 @@ mod tests { fn drains_both_vaults_and_keeps_the_quote_fresh() { let base_vault = Pubkey::new_unique(); let quote_vault = Pubkey::new_unique(); - let market = Pubkey::new_unique(); + let market = MARKET; let preparation = build_goonfi_liquidity_scenario( market, &market_account(&base_vault, "e_vault), @@ -308,7 +313,7 @@ mod tests { let base_vault = Pubkey::new_unique(); let quote_vault = Pubkey::new_unique(); let preparation = build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &market_account(&base_vault, "e_vault), &vault(&WSOL, 1_000), &vault(&USDC, 999), @@ -336,7 +341,7 @@ mod tests { // Out of range and a no-op leave nothing to prepare. assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &good_market, &vault(&WSOL, 1), &vault(&USDC, 1), @@ -348,7 +353,7 @@ mod tests { ); assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &good_market, &vault(&WSOL, 1), &vault(&USDC, 1), @@ -366,7 +371,7 @@ mod tests { }; assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &foreign_market, &vault(&WSOL, 1), &vault(&USDC, 1), @@ -384,7 +389,7 @@ mod tests { }; assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &good_market, &foreign_vault, &vault(&USDC, 1), @@ -402,7 +407,7 @@ mod tests { }; assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &good_market, &vault(&WSOL, 1), &vault(&USDC, 1), @@ -426,7 +431,7 @@ mod tests { }; assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &market, &mint_account, &vault(&USDC, 1), @@ -440,7 +445,7 @@ mod tests { // A real token account holding the other side's mint is refused as well. assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &market, &vault(&USDC, 1), &vault(&USDC, 1), @@ -452,6 +457,26 @@ mod tests { ); } + /// The market account carries no self-address, so the vault's authority is what ties the + /// requested market to these bytes. + #[test] + fn rejects_a_market_address_that_does_not_hold_the_vault() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market_account(&base_vault, "e_vault), + &vault(&WSOL, 1), + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + } + #[test] fn resolves_vault_addresses_from_the_market() { let base_vault = Pubkey::new_unique(); diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index b8c71c728..0a73ee217 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -17,7 +17,7 @@ use crate::{ types::MintAccount, }; -use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket}; +use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket, vault_addresses}; #[derive(Debug, PartialEq)] pub struct GoonfiDiscoveredMarket { @@ -51,16 +51,19 @@ pub fn market_label(base_mint: &Pubkey, quote_mint: &Pubkey) -> String { format!("{}/{}", symbol(base_mint), symbol(quote_mint)) } -fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 3]> { +/// The accounts a market points at: its two mints, its oracle, and its base vault. The vault comes +/// along because it is what proves the market's address matches these bytes. +fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 4]> { let oracle = GoonfiMarket::oracle_address(account)?; let base = Pubkey::new_from_array(account.data[80..112].try_into().unwrap()); let quote = Pubkey::new_from_array(account.data[112..144].try_into().unwrap()); + let [base_vault, _] = vault_addresses(account)?; if base == Pubkey::default() || quote == Pubkey::default() || base == quote { return Err(SurfpoolError::internal( "GoonFi market has invalid mint identities", )); } - Ok([base, quote, oracle]) + Ok([base, quote, oracle, base_vault]) } fn mint_decimals(account: &Account) -> SurfpoolResult { @@ -77,13 +80,13 @@ fn resolve_market( account: &Account, references: &HashMap, ) -> SurfpoolResult { - let [base, quote, oracle] = market_references(account)?; + let [base, quote, oracle, base_vault] = market_references(account)?; let required = |address: &Pubkey| { references.get(address).ok_or_else(|| { SurfpoolError::internal(format!("GoonFi referenced account {address} was not found")) }) }; - GoonfiMarket::validate(address, account, required(&oracle)?)?; + GoonfiMarket::validate(address, account, required(&base_vault)?, required(&oracle)?)?; Ok(GoonfiDiscoveredMarket { address, oracle, @@ -190,6 +193,8 @@ mod tests { let base = Pubkey::new_unique(); let quote = Pubkey::new_unique(); let oracle = Pubkey::new_unique(); + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); let registry = TemplateRegistry::new(); let layout = registry .get("goonfi-reference-band") @@ -206,6 +211,8 @@ mod tests { market.data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); market.data[80..112].copy_from_slice(base.as_ref()); market.data[112..144].copy_from_slice(quote.as_ref()); + market.data[144..176].copy_from_slice(base_vault.as_ref()); + market.data[176..208].copy_from_slice(quote_vault.as_ref()); market.data[208..240].copy_from_slice(oracle.as_ref()); let mint = |decimals| { let mut account = Account { @@ -221,12 +228,24 @@ mod tests { .pack_into_slice(&mut account.data); account }; + let vault = { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(base.as_ref()); + data[32..64].copy_from_slice(address.as_ref()); + data[108] = 1; + Account { + owner: spl_token_interface::ID, + data, + ..Account::default() + } + }; ( address, market, HashMap::from([ (base, mint(9)), (quote, mint(6)), + (base_vault, vault), ( oracle, Account { @@ -279,7 +298,7 @@ mod tests { #[test] fn goonfi_discovery_rejects_missing_or_invalid_referenced_accounts() { let (address, account, references) = fixture(); - let [base, _, oracle] = market_references(&account).unwrap(); + let [base, _, oracle, _] = market_references(&account).unwrap(); for invalid in 0..5 { let mut references = references.clone(); match invalid { diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index b0852c772..db9eac150 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -7,7 +7,7 @@ pub use liquidity::{GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, pub use price::{ GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, GoonfiPricePreparation, build_goonfi_price_scenario, validate_goonfi_market_layout, - validate_goonfi_oracle_layout, + validate_goonfi_oracle_layout, verify_market_address, }; pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 5cfd72601..651b512c6 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -15,6 +15,7 @@ use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, RawLayo use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, + types::TokenAccount, }; pub const GOONFI_PROGRAM_ID: Pubkey = @@ -82,12 +83,16 @@ impl GoonfiMarket { Ok(oracle) } + /// `base_vault_account` must be the account at the base vault the market itself names; it is + /// what proves `address` belongs to `market_account` (see `verify_market_address`). pub fn validate( address: Pubkey, market_account: &Account, + base_vault_account: &Account, oracle_account: &Account, ) -> SurfpoolResult { let oracle = Self::oracle_address(market_account)?; + verify_market_address(address, base_vault_account)?; validate_goonfi_oracle_layout(oracle_account)?; Ok(Self { address, oracle }) } @@ -102,6 +107,26 @@ impl GoonfiMarket { } } +/// Proves that `address` really is the market whose bytes sit in the account it was fetched with. +/// +/// A market does not record its own address, but its vaults do: a GoonFi vault is a token account +/// whose authority is the market. Reading the vault the market itself names and comparing that +/// authority is what ties a caller-supplied address to the data - the same shape of cross-check the +/// pump graduation builder makes through its curve vault's mint. It catches the realistic mistake, +/// an address from one market paired with another market's bytes; it is not proof against a caller +/// who deliberately assembles three mutually inconsistent accounts. +pub fn verify_market_address(address: Pubkey, base_vault_account: &Account) -> SurfpoolResult<()> { + let vault = TokenAccount::unpack(&base_vault_account.data) + .map_err(|error| invalid(format!("market base vault is not a token account: {error}")))?; + if vault.owner() != address { + return Err(invalid(format!( + "market base vault is held by {}, not by the targeted market {address}", + vault.owner() + ))); + } + Ok(()) +} + /// Rejects an account that is not a GoonFi market. /// /// The shared raw-layout guard has no owner predicate, so a foreign account of the same size @@ -303,18 +328,52 @@ mod tests { } } + /// A market's base vault: a token account whose authority is the market itself. + fn vault_account(authority: &Pubkey) -> Account { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(Pubkey::new_unique().as_ref()); + data[32..64].copy_from_slice(authority.as_ref()); + data[108] = 1; + Account { + data, + owner: spl_token_interface::ID, + ..Account::default() + } + } + const FIXTURE_ORACLE: Pubkey = Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); fn market() -> GoonfiMarket { + let address = Pubkey::new_unique(); GoonfiMarket::validate( - Pubkey::new_unique(), + address, &market_account(&FIXTURE_ORACLE), + &vault_account(&address), &oracle_account(), ) .expect("valid GoonFi market") } + /// The market account does not carry its own address, so a caller could hand `validate` one + /// market's address with another market's bytes. The base vault's authority is what catches it. + #[test] + fn rejects_an_address_that_does_not_own_the_market_vault() { + let other_market = Pubkey::new_unique(); + assert!( + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&FIXTURE_ORACLE), + &vault_account(&other_market), + &oracle_account() + ) + .is_err() + ); + + // A vault that is not a token account at all is refused before the comparison. + assert!(verify_market_address(Pubkey::new_unique(), &oracle_account()).is_err()); + } + #[test] fn builds_price_scenario_across_both_accounts() { let market = market(); @@ -421,8 +480,15 @@ mod tests { owner: Pubkey::new_unique(), ..market_account(&oracle) }; + let address = Pubkey::new_unique(); assert!( - GoonfiMarket::validate(Pubkey::new_unique(), &wrong_owner, &oracle_account()).is_err() + GoonfiMarket::validate( + address, + &wrong_owner, + &vault_account(&address), + &oracle_account() + ) + .is_err() ); // The raw guard cannot see the owner, which is the whole reason this check sits on top. assert!(MARKET_LAYOUT.guard(&wrong_owner.data).is_ok()); @@ -430,12 +496,24 @@ mod tests { let mut bad_magic = market_account(&oracle); bad_magic.data[0] ^= 0xff; assert!( - GoonfiMarket::validate(Pubkey::new_unique(), &bad_magic, &oracle_account()).is_err() + GoonfiMarket::validate( + address, + &bad_magic, + &vault_account(&address), + &oracle_account() + ) + .is_err() ); let no_pointer = market_account(&Pubkey::default()); assert!( - GoonfiMarket::validate(Pubkey::new_unique(), &no_pointer, &oracle_account()).is_err() + GoonfiMarket::validate( + address, + &no_pointer, + &vault_account(&address), + &oracle_account() + ) + .is_err() ); // The oracle carries no magic at all, so the owner check is its only discriminator. @@ -445,8 +523,9 @@ mod tests { }; assert!( GoonfiMarket::validate( - Pubkey::new_unique(), + address, &market_account(&oracle), + &vault_account(&address), &foreign_oracle ) .is_err() diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs index 9bbaaedc0..696389fde 100644 --- a/crates/core/src/tests/goonfi/mod.rs +++ b/crates/core/src/tests/goonfi/mod.rs @@ -23,7 +23,7 @@ use crate::{ scenarios::{ TemplateRegistry, protocols::goonfi::v1::{ - GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, + GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, vault_addresses, }, }, surfnet::svm::SurfnetSvm, @@ -689,8 +689,8 @@ async fn goonfi_templates_guard_oracle_and_market_and_preserve_unwritten_bytes() async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { let market_key = Pubkey::from_str_const(fork.spec.market); let oracle_key = Pubkey::from_str_const(fork.spec.oracle); - let market = - GoonfiMarket::validate(market_key, &fork.market, &fork.oracle).expect("validate market"); + let market = GoonfiMarket::validate(market_key, &fork.market, &fork.base_vault, &fork.oracle) + .expect("validate market"); let live_bid = read_u64(&fork.oracle.data, ORACLE_BID_OFFSET); let target = live_bid * 3 / 2; let price = format!("{}.{:06}", target / 1_000_000, target % 1_000_000); @@ -1092,11 +1092,26 @@ async fn goonfi_discovery_fetches_live_market_and_oracle_relationships() { .flat_map(|market| [market.address, market.oracle]) .collect(); let accounts = live::fetch(&addresses).await; - for (discovered, accounts) in chunk.iter().zip(accounts.chunks_exact(2)) { - let validated = GoonfiMarket::validate(discovered.address, &accounts[0], &accounts[1]) - .expect("discovered market and oracle must retain their live owners and layouts"); + // The base vault is what proves each discovered address against the bytes at it, so the + // live check has to read it too. + let vaults: Vec = accounts + .chunks_exact(2) + .map(|pair| vault_addresses(&pair[0]).expect("a live market names its vaults")[0]) + .collect(); + let vault_accounts = live::fetch(&vaults).await; + for ((discovered, accounts), vault) in chunk + .iter() + .zip(accounts.chunks_exact(2)) + .zip(vault_accounts.iter()) + { + let validated = + GoonfiMarket::validate(discovered.address, &accounts[0], vault, &accounts[1]) + .expect( + "discovered market and oracle must retain their live owners and layouts", + ); assert_eq!( - validated.oracle, discovered.oracle, + validated.oracle(), + discovered.oracle, "live market oracle pointer changed" ); assert_eq!(&accounts[0].data[80..112], discovered.base_mint.as_ref()); diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 6614db201..09ba15790 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -468,15 +468,26 @@ impl Surfpool { .ok_or_else(|| format!("GoonFi market account {market_address} was not found"))?; let oracle_address = GoonfiMarket::oracle_address(&market_account).map_err(|error| error.to_string())?; - let oracle_account = self - .fetch_surfnet_accounts(surfnet_port, &[oracle_address]) - .await? - .into_iter() - .next() - .flatten() + // The base vault rides along in the same read: it is what proves `market_address` belongs + // to the bytes just fetched, since a market does not record its own address. + let [base_vault_address, _] = + vault_addresses(&market_account).map_err(|error| error.to_string())?; + let referenced = self + .fetch_surfnet_accounts(surfnet_port, &[oracle_address, base_vault_address]) + .await?; + let oracle_account = referenced[0] + .as_ref() .ok_or_else(|| format!("GoonFi oracle {oracle_address} was not found"))?; - GoonfiMarket::validate(market_address, &market_account, &oracle_account) - .map_err(|error| error.to_string()) + let base_vault_account = referenced[1] + .as_ref() + .ok_or_else(|| format!("GoonFi base vault {base_vault_address} was not found"))?; + GoonfiMarket::validate( + market_address, + &market_account, + base_vault_account, + oracle_account, + ) + .map_err(|error| error.to_string()) } async fn stage_scenario(&self, scenario: Scenario) -> Result { From aad98d69945bd65e975426a53d8823be5880b98f Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 11 Sep 2026 10:48:17 +0300 Subject: [PATCH 6/7] fix(goonfi): follow the raw-layout loader rename on the rebased base The BisonFi branch was squashed and renamed `load_protocol_overrides_without_idl` to `load_raw_layout_overrides`; GoonFi registers its two raw account shapes through it, the same call BisonFi makes for its own. --- crates/core/src/scenarios/registry.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 33c0905d3..db87e66fc 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -132,8 +132,8 @@ impl TemplateRegistry { /// GoonFi writes two account shapes - the price oracle and the market that band-guards it. pub fn load_goonfi_overrides(&mut self) { - self.load_protocol_overrides_without_idl(GOONFI_V1_ORACLE_OVERRIDES_CONTENT, "goonfi"); - self.load_protocol_overrides_without_idl(GOONFI_V1_MARKET_OVERRIDES_CONTENT, "goonfi"); + self.load_raw_layout_overrides(GOONFI_V1_ORACLE_OVERRIDES_CONTENT, "goonfi"); + self.load_raw_layout_overrides(GOONFI_V1_MARKET_OVERRIDES_CONTENT, "goonfi"); } pub fn load_kamino_overrides(&mut self) { From ddaa6fe521a8a4bc33cc5fdca9fb6868db12cc21 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 11 Sep 2026 10:58:58 +0300 Subject: [PATCH 7/7] fix(goonfi): tie both vaults to the market, not just the base one The balance is read from the account passed in but written to the vault address decoded from the market, so a vault of the right mint belonging to another market would scale the wrong balance into this one - a requested drain could top the vault up instead. The authority check now lives inside the balance read, where it covers the quote side too, and the base-only call it replaces is gone. --- .../protocols/goonfi/v1/liquidity.rs | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index 07d131024..19828e096 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -21,7 +21,6 @@ use crate::{ use super::{ GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, - verify_market_address, }; /// Read, never written, so no template declares them. @@ -92,14 +91,13 @@ pub fn build_goonfi_liquidity_scenario( } let [base_vault, quote_vault] = vault_addresses(market_account)?; - verify_market_address(market, base_vault_account)?; let oracle = GoonfiMarket::oracle_address(market_account)?; validate_goonfi_oracle_layout(oracle_account)?; let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; - let base_amount = vault_amount(base_vault_account, "base", &base_mint)?; - let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint)?; + let base_amount = vault_amount(base_vault_account, "base", &base_mint, market)?; + let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint, market)?; let label = market_label(&base_mint, "e_mint); let registry = TemplateRegistry::new(); @@ -168,13 +166,20 @@ pub fn build_goonfi_liquidity_scenario( }) } -/// Reads a vault balance, proving first that the account really is that market's token vault. +/// Reads a vault balance, proving first that the account really is this market's vault for `side`. /// -/// An owner-and-length check is not enough: a mint is also owned by the token program and is long -/// enough to read an amount out of, so it would pass and its bytes would be misread as a balance. -/// Unpacking rejects anything that is not a token account, and the mint comparison ties the vault -/// to the side of the market it is supposed to hold. -fn vault_amount(account: &Account, side: &str, expected_mint: &Pubkey) -> SurfpoolResult { +/// Three things have to line up, because the balance is read from the passed account but written to +/// the vault address decoded from the market. An owner-and-length check is not enough: a mint is +/// also token-program-owned and long enough to read an amount out of. Unpacking rejects anything +/// that is not a token account, the authority ties the vault to this market, and the mint ties it to +/// the right side of it - without all three, another market's vault of the same mint would pass and +/// its balance would be scaled into this market's vault. +fn vault_amount( + account: &Account, + side: &str, + expected_mint: &Pubkey, + market: Pubkey, +) -> SurfpoolResult { if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { return Err(invalid(format!( "{side} vault is not owned by a supported token program" @@ -182,6 +187,12 @@ fn vault_amount(account: &Account, side: &str, expected_mint: &Pubkey) -> Surfpo } let vault = TokenAccount::unpack(&account.data) .map_err(|error| invalid(format!("{side} vault is not a token account: {error}")))?; + if vault.owner() != market { + return Err(invalid(format!( + "{side} vault is held by {}, not by the market {market} this scenario targets", + vault.owner() + ))); + } if vault.mint() != *expected_mint { return Err(invalid(format!( "{side} vault holds mint {} but the market's {side} mint is {expected_mint}", @@ -457,6 +468,33 @@ mod tests { ); } + /// The balance is read from the passed account but written to the vault the market names, so a + /// vault of the right mint belonging to another market would scale the wrong balance into this + /// one - a drain that silently tops the vault up instead. + #[test] + fn rejects_a_vault_belonging_to_another_market() { + let other_market = Pubkey::new_unique(); + let foreign_quote = { + let mut account = vault(&USDC, 999_999_999); + account.data[32..64].copy_from_slice(other_market.as_ref()); + account + }; + let error = build_goonfi_liquidity_scenario( + MARKET, + &market_account(&Pubkey::new_unique(), &Pubkey::new_unique()), + &vault(&WSOL, 1_000), + &foreign_quote, + &oracle(), + 0, + 0, + ) + .expect_err("a vault held by another market must be refused"); + assert!( + error.to_string().contains("quote vault is held by"), + "unexpected error: {error}" + ); + } + /// The market account carries no self-address, so the vault's authority is what ties the /// requested market to these bytes. #[test]