From e489e5d4671ea8449c5c7b406d1cf0733dfc4e78 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Mon, 14 Sep 2026 13:27:25 +0300 Subject: [PATCH 1/5] feat(scenarios): add HumidiFi state preparation --- Cargo.lock | 1 + crates/cli/src/http/mod.rs | 55 + crates/core/Cargo.toml | 1 + crates/core/src/scenarios/README.md | 10 + .../scenarios/protocols/humidifi/README.md | 170 +++ .../src/scenarios/protocols/humidifi/mod.rs | 1 + .../protocols/humidifi/v1/fair_value.rs | 539 +++++++++ .../protocols/humidifi/v1/liquidity.rs | 585 ++++++++++ .../protocols/humidifi/v1/markets.rs | 126 ++ .../scenarios/protocols/humidifi/v1/mod.rs | 10 + .../protocols/humidifi/v1/overrides.yaml | 132 +++ crates/core/src/scenarios/protocols/mod.rs | 1 + crates/core/src/scenarios/registry.rs | 18 +- crates/core/src/tests/humidifi/mod.rs | 1029 +++++++++++++++++ crates/core/src/tests/live.rs | 68 ++ crates/core/src/tests/mod.rs | 4 + crates/mcp/src/surfpool/mod.rs | 308 +++++ crates/types/src/scenarios.rs | 146 ++- 18 files changed, 3200 insertions(+), 4 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/humidifi/README.md create mode 100644 crates/core/src/scenarios/protocols/humidifi/mod.rs create mode 100644 crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs create mode 100644 crates/core/src/scenarios/protocols/humidifi/v1/liquidity.rs create mode 100644 crates/core/src/scenarios/protocols/humidifi/v1/markets.rs create mode 100644 crates/core/src/scenarios/protocols/humidifi/v1/mod.rs create mode 100644 crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml create mode 100644 crates/core/src/tests/humidifi/mod.rs create mode 100644 crates/core/src/tests/live.rs diff --git a/Cargo.lock b/Cargo.lock index 141bce739..7b9e92d19 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", diff --git a/crates/cli/src/http/mod.rs b/crates/cli/src/http/mod.rs index f787883c7..cbcec12fa 100644 --- a/crates/cli/src/http/mod.rs +++ b/crates/cli/src/http/mod.rs @@ -417,6 +417,61 @@ mod tests { .set_json(body) } + #[actix_web::test] + async fn humidifi_builder_scenario_keeps_value_types_through_the_api() { + use surfpool_core::scenarios::protocols::humidifi::v1::{ + HumidiFiMarket, build_humidifi_fair_value_scenario, + }; + + let token_program = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap(); + let market = HumidiFiMarket { + address: solana_pubkey::Pubkey::new_unique(), + base_mint: "So11111111111111111111111111111111111111112" + .parse() + .unwrap(), + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + .parse() + .unwrap(), + base_token_program: token_program, + quote_token_program: token_program, + base_decimals: 9, + quote_decimals: 6, + max_staleness_slots: 2, + }; + let scenario = build_humidifi_fair_value_scenario(&market, "208") + .unwrap() + .scenario; + let expected = serde_json::to_value(&scenario).unwrap(); + let loaded_scenarios = Data::new(RwLock::new(LoadedScenarios::new())); + let app = test::init_service( + App::new() + .app_data(loaded_scenarios) + .configure(configure_api), + ) + .await; + + let created = test::call_service(&app, post_scenario(expected.clone()).to_request()).await; + assert_eq!(created.status(), 200); + let response = test::call_service( + &app, + test::TestRequest::get().uri("/v1/scenarios").to_request(), + ) + .await; + assert_eq!(response.status(), 200); + let stored: serde_json::Value = test::read_body_json(response).await; + assert_eq!(stored, serde_json::json!([expected])); + let overrides = stored[0]["overrides"].as_array().unwrap(); + assert_eq!(overrides[0]["templateId"], "humidifi-fair-value"); + assert_eq!(overrides[0]["values"]["fair_value"], "58546795155816"); + assert_eq!(overrides[0]["fetchBeforeUse"], true); + assert_eq!(overrides[1]["templateId"], "humidifi-freshness"); + assert!(overrides[1]["values"]["last_update_slot"].is_null()); + assert_eq!(overrides[1]["fetchBeforeUse"], false); + assert_eq!(overrides[1]["persist"], true); + } + #[actix_web::test] async fn creating_the_same_scenario_twice_is_a_no_op() { let loaded_scenarios = Data::new(RwLock::new(LoadedScenarios::new())); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 53f510da0..a095f5e5d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -120,6 +120,7 @@ p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } test-case = { workspace = true } env_logger = "0.11" solana-ed25519-program = { workspace = true } +solana-program-runtime = "4.2.1" solana-pubsub-client = { workspace = true } solana-secp256k1-program = { version = "3.0", default-features = false, features = ["bincode"] } solana-secp256r1-program = "3.0" diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 6a53395d5..37ed51241 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -18,6 +18,7 @@ Protocols that are natively supported by Surfpool will have their IDLs included - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template - **Kamino** – Lending (v1.23.0), Scope oracle, Farms, Swap/LIMO, Earn vaults and Liquidity, across six programs. See [protocols/kamino/README.md](./protocols/kamino/README.md) - **Drift v2** - Perp and spot markets, user state, and global state +- **HumidiFi** - Proprietary market maker with XOR-obfuscated accounts, fair-value, freshness and stale-quote templates, live market discovery, and a vault liquidity builder. See [protocols/humidifi/README.md](./protocols/humidifi/README.md) - **Pump v1** - Bonding curve launchpad with curve reserve and global config override templates - **PumpSwap v1** - Constant-product AMM with pool state and global config override templates, including canonical pool derivation for migrated pump.fun coins @@ -56,6 +57,15 @@ cargo test -p surfpool-core --features integration-tests kamino Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint instead of the public one. The default test run needs no network. +### HumidiFi integration tests + +HumidiFi coverage that forks real mainnet state lives in `crates/core/src/tests/humidifi/`. +Run HumidiFi serially because public RPC endpoints can shed requests after market discovery. + +``` +cargo test -p surfpool-core --features integration-tests tests::humidifi -- --test-threads=1 --nocapture +``` + ### Programs with no IDL Programs that publish no usable IDL can describe their account bytes directly in an override diff --git a/crates/core/src/scenarios/protocols/humidifi/README.md b/crates/core/src/scenarios/protocols/humidifi/README.md new file mode 100644 index 000000000..64cf50d58 --- /dev/null +++ b/crates/core/src/scenarios/protocols/humidifi/README.md @@ -0,0 +1,170 @@ +# HumidiFi + +HumidiFi is a proprietary market maker without a published IDL. Surfpool prepares its market +state through the raw layout in `v1/overrides.yaml`; it does not construct or submit a swap. + +## Deployment + +- Program: `9H6tua7jkLhdm3w8BvgpTn5LZNU7g4ZynDmCiNN3q6Rp` +- ProgramData: `G9S64i58RRWJA28vZiNhnP56Ux4Ef7hfMgHNREnZZSom` +- Deploy slot: `446544344` (2026-09-12 22:35:04 UTC) +- ProgramData length: 339485 bytes, including the 45-byte loader header +- ELF length: 339440 bytes +- ELF SHA-256: `4c2b4c29bce4ee4d2a0dfde28f6d511e60627e86ac3cd417e6734ff999ea4550` + +Mainnet RPC and PublicNode independently returned these values on 2026-09-13, and the running +Surfnet fork matched them. The focused live suite then passed all ten tests against mainnet, +including two-market layout and round-trip checks, materialization, fair-value and liquidity swap +replays, and the inclusive staleness boundary. A signed original-wallet DFlow route simulation also +succeeded against the same ELF; this is simulation evidence, not a committed transaction. These +results validate compatibility with the existing `v1` raw layout. A later redeploy voids the layout +evidence; the live suite pins ProgramData and fails when it moves. + +## The account is obfuscated + +A market is 1728 bytes. Its economic fields and public keys use per-offset XOR keys; the schema +version at offset 1720 is plaintext. The templates keep values plaintext and declare `xor_mask` +for each masked field. The shared raw-layout writer encodes a value, XORs the eight-byte word, +then writes it. Other encodings cannot carry a mask. + +The fair-value key is `b957ed15dc877426`. Freshness and the staleness limit use +`6e9de2b30b19f1ea`. The base mint at offset 416 and quote mint at 384 each occupy four words, +decoded with `fb5ce87aae443c38`, `04a2178451bac3c7`, `04a1178751b9c3c6`, and +`04a0178651b8c3c5`. These public-key keys are read-only in Rust. + +## The guard, and what it does not cover + +The raw-layout guard checks size 1728 and the masked tag `[44,90,19,124,56,111,47,150]` at +offset 8. This tag is shared by several schema versions. `validate_humidifi_market_layout` +also checks the program owner and requires plaintext schema version 8 at offset 1720. +Discovery applies the same size, tag, and version filters, then validates the referenced mints. + +The YAML magic guard supports one contiguous range, so owner and schema checks belong in Rust. +The fair-value tool uses them before reading mints or building a scenario. Direct raw-template +composition does not perform these additional checks; the raw scenario API is unvalidated by +contract. Schema versions other than 8 are unsupported. + +## Templates + +| Template | Prepared state | +| --- | --- | +| `humidifi-fair-value` | Quote-per-base atomic ratio at offset 576 | +| `humidifi-freshness` | Materialization slot at offset 616, default lead 0 | +| `humidifi-stale-quote` | Aged slot at offset 616, default lead -3 | + +The price conversion is `floor(price * 2^48 * 10^(quote_decimals - base_decimals))`. +The builder reads both mint decimals and computes this with integer arithmetic. A raw template +takes the resulting ratio as a decimal string. For SOL/USDC, price `208` gives +`"58546795155816"`. + +Offset 608 holds the maximum accepted quote age in slots. A supplied `last_update_slot` value is a +signed lead relative to materialization, not an absolute slot; `null` selects the template's +default lead. Pass `-(maxStalenessSlots + 1)` to reach the first stale slot: age equal to the +limit still fills, while the next slot fails with `Custom(1027565)` (`0xfaded`). Passing `0` +makes a quote fresh, including on the stale template. Staleness is applied +once; freshness can persist to keep the quote current over subsequent slots. + +## Live market discovery + +`list_humidifi_markets` uses the target Surfnet RPC's `getProgramAccounts`, then fetches the +referenced mints in batches of at most 100. Addresses identify markets; labels use verified-token +symbols with full mint addresses as a fallback. Discovery sorts by label and address. The templates +contain no static market list or default address; every override must target an explicitly selected market. + +On 2026-09-11, a mainnet scan found 93 accounts of size 1728: 36 with schema 8, 50 with schema 5, +two each with values 0, 2 and 4, and one with value 6. Schema membership is not proof of current +trading or liquidity. Discovery validates compatible accounts and mint metadata; it does not +promise that every market is quoting. The live test checks returned metadata without pinning a +market count. + +On 2026-09-13, the focused mainnet run discovered and validated 36 compatible markets. PublicNode +independently confirmed the ProgramData identity but returned HTTP 403 for `getProgramAccounts`, so +that provider did not verify discovery. + +To inspect all market-sized accounts, including unsupported schemas: + +```bash +curl -s -X POST "$RPC_URL" -H 'Content-Type: application/json' -d '{ + "jsonrpc":"2.0","id":1,"method":"getProgramAccounts", + "params":["9H6tua7jkLhdm3w8BvgpTn5LZNU7g4ZynDmCiNN3q6Rp", + {"encoding":"base64","commitment":"confirmed","filters":[{"dataSize":1728}]}]}' +``` + +Decode the little-endian u64 at offset 1720 without an XOR mask. Normal discovery additionally +filters the tag at offset 8 and version 8 at offset 1720. + +## Builders and tools + +`build_humidifi_fair_value_scenario` is a pure conversion over validated market metadata. +`create_humidifi_fair_value_scenario` reads the market and both mints through the selected +Surfnet RPC, where local accounts take precedence and missing accounts fall back to the +datasource. It stages the result through the shared scenario path. + +The price override sets `fetchBeforeUse: true`: on Play the shared materializer fetches the +market from the Surfnet datasource before applying the requested price. It does not require +the market account read at creation to remain in local state. A successful fetch replaces earlier +local edits to that market; it does not reset its vaults or the rest of the fork. The shared fetch +path is best effort: on a remote failure, an existing local account may still be used. +A second override sets `fetchBeforeUse: false` and persists freshness with a `null` value, +so the encoder uses its zero lead at every materialization slot without fetching over the price. + +`build_humidifi_liquidity_scenario` scales the market's vault balances through the generic +`spl-token-account-balance` template, one override per side that changes, from 0 to 10000 remaining +basis points with integer floor, and pairs them with persisted freshness. The vault addresses come +from the market's masked words at offsets 448 (quote) and 480 (base); each vault must be a token +account for the market's mint on that side, owned by that mint's token program, initialized and +controlled by the market. `create_humidifi_liquidity_scenario` +reads the market, both mints and both vaults through the Surfnet RPC and stages the result. + +`list_humidifi_markets` returns addresses, labels, both mint identities and decimals, and +`maxStalenessSlots`. Both creation tools require a non-empty `market` address from this list. +All tools accept an optional `surfnet_port`, defaulting to 8899. Studio's PMM +fair-value preset uses the market list and fair-value tools; the stale-quote and liquidity chips +request editable state scenarios. + +## Behavioral evidence + +The live suite checks byte-limited template writes on two markets, guarded layout rejection, +discovered metadata, and scenario materialization with persisted freshness. It loads the pinned +deployed ELF into LiteSVM for swap replay. A native DFlow wrapper re-emits the captured HumidiFi +CPI; its system-owned authority must remain a read-only signer, with signature verification off. +The captured instruction comes from transaction +[`3zevqw…g1Si`](https://explorer.solana.com/tx/3zevqwAa8u136UGE1bdBzP1o2dpFuc7X333dC1ut3T1uCgY6iidihfNJNoJ7tuyj3tHNin1i7HTFCUSFWqK7g1Si) +at slot 444225745: DFlow outer instruction 2, HumidiFi CPI, 2427890 USDC atoms of input. +The replay uses those instruction bytes and account identities, with live protocol-state contents +and ELF. Test user token accounts and the signer are synthesized; it does not replay the entire +DFlow transaction or load frozen protocol account snapshots. + +The fair-value replay checks unchanged output after re-encoding the current ratio, increased +base output after halving the price, and decreased output or rejection after doubling it. +An independent absolute-price test builds scenarios at 100 and 208 USDC per SOL, registers and +materializes them, then compares actual swap output with the human price and mint decimals within +1%. This expectation does not use the encoded fair-value word or the `2^48` conversion. +The staleness replay uses an explicit clock: with the tested SOL/USDC market's limit of 2, age 3 fails with +`Custom(1027565)` (`0xfaded`) and age 2 fills. Changing offset 608 to 10 moves those boundaries +to ages 11 and 10; the stale template's default age 3 then fills. The limit is inclusive. + +The liquidity replay materializes the builder's scenarios through the production path and swaps +against the prepared accounts. A drained base vault fails the transfer with the token program's +insufficient-funds error. On the tested SOL/USDC market in the 2026-09-11 replay, a base vault cut +to 0.5% of its live balance still filled at less than a hundredth of baseline output. Draining +the quote vault left quote-to-base fills unchanged. These observations are tested against live +state; the tool scales balances without assuming a fixed inventory threshold or output multiplier. + +```bash +SURFPOOL_TEST_RPC_URL= cargo test -p surfpool-core --features integration-tests \ + tests::humidifi -- --test-threads=1 --nocapture +``` + +Run serially. Public endpoints can shed requests after discovery, sometimes reporting HTTP 413. +`SURFPOOL_TEST_RPC_URL` defaults to the public mainnet endpoint; use a private endpoint when it +rate-limits. + +## Known boundaries + +Depth and curve fields are not exposed; vault balances are, through the generic token template. +Inventory response depends on current market state and trade direction; no universal price or +depth formula is inferred from the vault-balance control. The behavioral replay covers USDC into +WSOL on the replay's SOL/USDC market: the quote side's +own depletion is proven only to leave that direction unchanged, and the opposite direction and other +pairs are not replayed. Other market schema versions remain unsupported. diff --git a/crates/core/src/scenarios/protocols/humidifi/mod.rs b/crates/core/src/scenarios/protocols/humidifi/mod.rs new file mode 100644 index 000000000..a3a6d96c3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/humidifi/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs b/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs new file mode 100644 index 000000000..63adc409d --- /dev/null +++ b/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs @@ -0,0 +1,539 @@ +//! HumidiFi fair-value state preparation. +//! +//! HumidiFi publishes no IDL, and its quoted price and state fields are XOR-obfuscated: each word +//! is stored as `plaintext XOR key` with a fixed per-offset key. Every write goes through the raw layout in +//! `overrides.yaml`, whose properties carry those keys. This module exists for the one thing a +//! template cannot express: turning a human price into the raw ratio the program reads, which needs +//! both mints' decimals. + +use std::{collections::HashMap, sync::LazyLock}; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{ + AccountAddress, OverrideInstance, OverrideTemplate, RawLayout, Scenario, + verified_tokens::VERIFIED_TOKENS, +}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, + types::MintAccount, +}; + +pub const HUMIDIFI_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("9H6tua7jkLhdm3w8BvgpTn5LZNU7g4ZynDmCiNN3q6Rp"); + +/// The mints are read for their decimals, never written, so no template declares them. +const BASE_MINT_OFFSET: usize = 416; +const QUOTE_MINT_OFFSET: usize = 384; +const MAX_STALENESS_OFFSET: usize = 608; +const STATE_XOR_KEY: u64 = 0x6e9d_e2b3_0b19_f1ea; +pub(super) const SCHEMA_VERSION_OFFSET: usize = 1720; +const SCHEMA_VERSION_XOR_KEY: u64 = 0; +const ACTIVE_SCHEMA_VERSION: u64 = 8; + +/// The four per-word XOR keys the program uses to obfuscate a 32-byte pubkey field. Global across +/// the supported markets. Used only to READ the mints for their decimals, never to +/// write, which is why they live here rather than as template masks. +pub(super) const PUBKEY_XOR_KEYS: [u64; 4] = [ + 0xfb5c_e87a_ae44_3c38, + 0x04a2_1784_51ba_c3c7, + 0x04a1_1787_51b9_c3c6, + 0x04a0_1786_51b8_c3c5, +]; + +/// Fair value is quote atoms per base atom, scaled by 2^48. +const FAIR_VALUE_SCALE: u128 = 1u128 << 48; + +const FAIR_VALUE_TEMPLATE: &str = "humidifi-fair-value"; +pub(super) const FRESHNESS_TEMPLATE: &str = "humidifi-freshness"; + +/// Both overrides apply on Play, before any slot advance. +pub(super) const PREPARATION_SLOT: u64 = 0; + +/// The size and layout tag a HumidiFi market must have, taken from the manifest the raw templates +/// are written against so there is one definition of them. Built once; the manifest is compiled in. +pub(super) static MARKET_LAYOUT: LazyLock = LazyLock::new(|| { + template(&TemplateRegistry::new(), FAIR_VALUE_TEMPLATE) + .and_then(|template| { + template.raw_layout.clone().ok_or_else(|| { + SurfpoolError::internal("the HumidiFi manifest carries no raw layout") + }) + }) + .expect("the HumidiFi manifest is compiled in and always parses") +}); + +/// The parts of a HumidiFi market a price needs: which mints it quotes, and at what scale. +#[derive(Clone, Debug, PartialEq)] +pub struct HumidiFiMarket { + pub address: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub base_token_program: Pubkey, + pub quote_token_program: Pubkey, + pub base_decimals: u8, + pub quote_decimals: u8, + pub max_staleness_slots: u64, +} + +impl HumidiFiMarket { + pub fn mint_addresses(market_account: &Account) -> SurfpoolResult<(Pubkey, Pubkey)> { + validate_humidifi_market_layout(market_account)?; + let base_mint = read_masked_pubkey(&market_account.data, BASE_MINT_OFFSET)?; + let quote_mint = read_masked_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + if base_mint == Pubkey::default() + || quote_mint == Pubkey::default() + || base_mint == quote_mint + { + return Err(invalid("market has invalid mint identities")); + } + Ok((base_mint, quote_mint)) + } + + pub fn validate( + address: Pubkey, + market_account: &Account, + base_mint_account: &Account, + quote_mint_account: &Account, + ) -> SurfpoolResult { + let (base_mint, quote_mint) = Self::mint_addresses(market_account)?; + + validate_mint_owner(base_mint_account, "base")?; + validate_mint_owner(quote_mint_account, "quote")?; + let base_decimals = MintAccount::unpack(&base_mint_account.data) + .map_err(|_| invalid("base mint account is invalid"))? + .decimals(); + let quote_decimals = MintAccount::unpack("e_mint_account.data) + .map_err(|_| invalid("quote mint account is invalid"))? + .decimals(); + + Ok(Self { + address, + base_mint, + quote_mint, + base_token_program: base_mint_account.owner, + quote_token_program: quote_mint_account.owner, + base_decimals, + quote_decimals, + max_staleness_slots: read_masked_u64( + &market_account.data, + MAX_STALENESS_OFFSET, + STATE_XOR_KEY, + )?, + }) + } + + pub fn label(&self) -> String { + let symbol = |mint: &Pubkey| { + let address = mint.to_string(); + VERIFIED_TOKENS + .iter() + .filter(|token| token.address == address) + .map(|token| token.symbol.as_str()) + .min() + .map(str::to_string) + .unwrap_or(address) + }; + format!("{}/{}", symbol(&self.base_mint), symbol(&self.quote_mint)) + } +} + +/// Rejects an account that is not a HumidiFi market. +/// +/// The shared raw-layout guard has no owner predicate, so a foreign account of the same size +/// carrying the same masked layout tag would pass it. Every builder-made scenario comes through +/// here, which also gates the separate schema-version word. +pub fn validate_humidifi_market_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != HUMIDIFI_PROGRAM_ID { + return Err(invalid("market is not owned by HumidiFi")); + } + MARKET_LAYOUT.guard(&account.data).map_err(invalid)?; + let version = read_masked_u64(&account.data, SCHEMA_VERSION_OFFSET, SCHEMA_VERSION_XOR_KEY)?; + if version != ACTIVE_SCHEMA_VERSION { + return Err(invalid(format!( + "HumidiFi market schema version {version} is not supported; expected version 8" + ))); + } + Ok(()) +} + +pub(super) fn schema_version_bytes() -> [u8; 8] { + (ACTIVE_SCHEMA_VERSION ^ SCHEMA_VERSION_XOR_KEY).to_le_bytes() +} + +#[derive(Clone, Debug, PartialEq)] +pub struct HumidiFiFairValuePreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub fair_value: u64, +} + +pub fn build_humidifi_fair_value_scenario( + market: &HumidiFiMarket, + price: &str, +) -> SurfpoolResult { + let fair_value = human_price_to_fair_value(price, market.base_decimals, market.quote_decimals)?; + + let registry = TemplateRegistry::new(); + let fair_value_template = template(®istry, FAIR_VALUE_TEMPLATE)?; + let freshness = template(®istry, FRESHNESS_TEMPLATE)?; + let market_name = market.label(); + let target = AccountAddress::Pubkey(market.address.to_string()); + + let mut price_override = OverrideInstance::new( + fair_value_template.id.clone(), + PREPARATION_SLOT, + target.clone(), + ) + .with_values(HashMap::from([( + "fair_value".to_string(), + serde_json::json!(fair_value.to_string()), + )])) + .with_label(format!("HumidiFi {market_name} fair value")); + price_override.fetch_before_use = true; + + // 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 market's + // freshness window however long the scenario is left running. + let freshness_override = OverrideInstance::new(freshness.id.clone(), PREPARATION_SLOT, target) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep HumidiFi quote fresh".to_string()) + .with_persist(true); + + let normalized_price = price.trim(); + let mut scenario = Scenario::new( + format!("HumidiFi {market_name} at {normalized_price}"), + format!( + "Prepare HumidiFi market {} to quote one base token at {normalized_price} quote tokens; no swap is sent.", + market.address + ), + ); + scenario.tags = vec![ + "humidifi".to_string(), + "pmm".to_string(), + "price-dislocation".to_string(), + ]; + scenario.add_override(price_override); + scenario.add_override(freshness_override); + + Ok(HumidiFiFairValuePreparation { + scenario, + market: market.address, + fair_value, + }) +} + +/// Unmasks a 32-byte pubkey stored as four XOR-obfuscated words. +pub(super) fn read_masked_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let end = offset + .checked_add(32) + .ok_or_else(|| invalid("market mint offset overflow"))?; + let slice = data + .get(offset..end) + .ok_or_else(|| invalid("market mint bytes are truncated"))?; + let mut bytes = [0u8; 32]; + for (i, key) in PUBKEY_XOR_KEYS.iter().enumerate() { + let word = u64::from_le_bytes(slice[i * 8..i * 8 + 8].try_into().unwrap()); + bytes[i * 8..i * 8 + 8].copy_from_slice(&(word ^ key).to_le_bytes()); + } + Ok(Pubkey::new_from_array(bytes)) +} + +fn read_masked_u64(data: &[u8], offset: usize, key: u64) -> SurfpoolResult { + let end = offset + .checked_add(8) + .ok_or_else(|| invalid("market word offset overflow"))?; + let bytes = data + .get(offset..end) + .ok_or_else(|| invalid("market word bytes are truncated"))?; + Ok(u64::from_le_bytes(bytes.try_into().unwrap()) ^ key) +} + +fn validate_mint_owner(account: &Account, side: &str) -> SurfpoolResult<()> { + if account.owner != spl_token_interface::id() && account.owner != spl_token_2022_interface::id() + { + return Err(invalid(format!( + "{side} mint is not owned by a supported token program" + ))); + } + Ok(()) +} + +/// `raw = floor(price * 2^48 * 10^(quote_decimals - base_decimals))`, computed on integers so a +/// long price never passes through f64. +fn human_price_to_fair_value( + price: &str, + base_decimals: u8, + quote_decimals: u8, +) -> 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")); + } + + let digits = format!("{whole}{fractional}") + .parse::() + .map_err(|_| invalid("price is too large"))?; + let scaled = digits + .checked_mul(FAIR_VALUE_SCALE) + .ok_or_else(|| invalid("price is too large"))?; + let exponent = i32::from(quote_decimals) + - i32::from(base_decimals) + - i32::try_from(fractional.len()).map_err(|_| invalid("price is too precise"))?; + let raw = if exponent >= 0 { + scaled + .checked_mul(checked_power_of_ten(exponent as u32)?) + .ok_or_else(|| invalid("price is too large"))? + } else { + scaled / checked_power_of_ten(exponent.unsigned_abs())? + }; + if raw == 0 { + return Err(invalid( + "price is too small for this market's mint decimals", + )); + } + u64::try_from(raw).map_err(|_| invalid("price is too large for HumidiFi's fair-value field")) +} + +fn checked_power_of_ten(exponent: u32) -> SurfpoolResult { + 10u128 + .checked_pow(exponent) + .ok_or_else(|| invalid("price scale exceeds supported precision")) +} + +pub(super) fn template<'a>( + registry: &'a TemplateRegistry, + id: &str, +) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("HumidiFi template {id} is unavailable"))) +} + +pub(super) fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use solana_program_pack::Pack; + + use super::*; + + fn mint_account(decimals: u8) -> Account { + let mut data = vec![0; spl_token_interface::state::Mint::LEN]; + spl_token_interface::state::Mint { + decimals, + is_initialized: true, + ..Default::default() + } + .pack_into_slice(&mut data); + Account { + data, + owner: spl_token_interface::id(), + ..Account::default() + } + } + + fn write_masked_pubkey(data: &mut [u8], offset: usize, pubkey: &Pubkey) { + let bytes = pubkey.to_bytes(); + for (i, key) in PUBKEY_XOR_KEYS.iter().enumerate() { + let word = u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().unwrap()); + data[offset + i * 8..offset + i * 8 + 8].copy_from_slice(&(word ^ key).to_le_bytes()); + } + } + + fn market_account(base_mint: &Pubkey, quote_mint: &Pubkey) -> Account { + let mut data = vec![0; MARKET_LAYOUT.account_size]; + let magic = MARKET_LAYOUT.magic.as_ref().unwrap(); + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[SCHEMA_VERSION_OFFSET..SCHEMA_VERSION_OFFSET + 8] + .copy_from_slice(&schema_version_bytes()); + data[MAX_STALENESS_OFFSET..MAX_STALENESS_OFFSET + 8] + .copy_from_slice(&(6u64 ^ STATE_XOR_KEY).to_le_bytes()); + write_masked_pubkey(&mut data, BASE_MINT_OFFSET, base_mint); + write_masked_pubkey(&mut data, QUOTE_MINT_OFFSET, quote_mint); + Account { + data, + owner: HUMIDIFI_PROGRAM_ID, + ..Account::default() + } + } + + fn market(base_decimals: u8, quote_decimals: u8) -> HumidiFiMarket { + let base_mint = Pubkey::new_unique(); + let quote_mint = Pubkey::new_unique(); + HumidiFiMarket::validate( + Pubkey::new_unique(), + &market_account(&base_mint, "e_mint), + &mint_account(base_decimals), + &mint_account(quote_decimals), + ) + .expect("valid HumidiFi market") + } + + #[test] + fn reads_metadata_for_a_market_outside_the_token_catalog() { + let base_mint = Pubkey::new_unique(); + let quote_mint = Pubkey::new_unique(); + let account = market_account(&base_mint, "e_mint); + assert_eq!( + HumidiFiMarket::mint_addresses(&account).unwrap(), + (base_mint, quote_mint) + ); + let address = Pubkey::new_unique(); + let market = + HumidiFiMarket::validate(address, &account, &mint_account(9), &mint_account(6)) + .unwrap(); + assert_eq!(market.address, address); + assert_eq!(market.base_mint, base_mint); + assert_eq!(market.quote_mint, quote_mint); + assert_eq!(market.base_decimals, 9); + assert_eq!(market.quote_decimals, 6); + assert_eq!(market.max_staleness_slots, 6); + assert_eq!(market.label(), format!("{base_mint}/{quote_mint}")); + } + + #[test] + fn labels_known_mints_when_tokens_share_a_symbol() { + let mut market = market(6, 6); + market.quote_mint = Pubkey::from_str_const("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + for base_mint in [ + "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + "HaP8r3ksG76PhQLTqR8FYBeNiQpejcFbQmiHbg787Ut1", + ] { + market.base_mint = Pubkey::from_str_const(base_mint); + assert_eq!(market.label(), "TRUMP/USDC", "mint {base_mint}"); + } + } + + #[test] + fn builds_fair_value_for_sol_usdc_decimals() { + let market = market(9, 6); + let preparation = build_humidifi_fair_value_scenario(&market, "208").unwrap(); + // 208 * 2^48 * 10^(6-9), floored. + assert_eq!(preparation.fair_value, 58_546_795_155_816); + assert_eq!(preparation.scenario.overrides.len(), 2); + + let [price, _] = &preparation.scenario.overrides[..] else { + panic!("expected exactly a price and a freshness override"); + }; + let stored: u64 = price + .values + .get("fair_value") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .unwrap(); + assert_eq!(stored, 58_546_795_155_816); + } + + #[test] + fn price_fetches_the_market_before_use_and_freshness_preserves_the_price() { + let preparation = build_humidifi_fair_value_scenario(&market(9, 6), "100.25").unwrap(); + let [price, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected exactly a price and a freshness override"); + }; + assert!(price.fetch_before_use); + assert!(!price.persist); + assert!(!freshness.fetch_before_use); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn derives_fair_value_from_market_mint_decimals() { + // A d6/d6 pair: the decimals cancel, so raw is price * 2^48 directly. + let market = market(6, 6); + let preparation = + build_humidifi_fair_value_scenario(&market, "0.4433").expect("build JUP/USDC price"); + assert_eq!( + preparation.fair_value, + (4433u128 * FAIR_VALUE_SCALE / 10_000) as u64 + ); + } + + #[test] + fn rejects_invalid_price_and_market_inputs() { + let market = market(9, 6); + for price in ["0", "-1", "1.2.3", "not-a-price", ""] { + assert!(build_humidifi_fair_value_scenario(&market, price).is_err()); + } + + let base_mint = mint_account(9); + let quote_mint = mint_account(6); + let wrong_owner = Account { + owner: Pubkey::new_unique(), + ..market_account(&Pubkey::new_unique(), &Pubkey::new_unique()) + }; + assert!( + HumidiFiMarket::validate(Pubkey::new_unique(), &wrong_owner, &base_mint, "e_mint) + .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()); + assert!(validate_humidifi_market_layout(&wrong_owner).is_err()); + + let same_mint = Pubkey::new_unique(); + assert!( + HumidiFiMarket::validate( + Pubkey::new_unique(), + &market_account(&same_mint, &same_mint), + &base_mint, + "e_mint, + ) + .is_err() + ); + } + + #[test] + fn rejects_a_version_5_market_the_guard_admits() { + let mut account = market_account(&Pubkey::new_unique(), &Pubkey::new_unique()); + account.data[SCHEMA_VERSION_OFFSET..SCHEMA_VERSION_OFFSET + 8] + .copy_from_slice(&(5u64 ^ SCHEMA_VERSION_XOR_KEY).to_le_bytes()); + assert!(MARKET_LAYOUT.guard(&account.data).is_ok()); + let error = validate_humidifi_market_layout(&account).unwrap_err(); + assert!( + error + .to_string() + .contains("schema version 5 is not supported") + ); + assert!(HumidiFiMarket::mint_addresses(&account).is_err()); + } + + #[test] + fn templates_require_an_explicit_market() { + let registry = TemplateRegistry::new(); + for id in [ + FAIR_VALUE_TEMPLATE, + FRESHNESS_TEMPLATE, + "humidifi-stale-quote", + ] { + assert_eq!( + template(®istry, id).unwrap().address, + AccountAddress::Pubkey(String::new()), + "{id}" + ); + } + } + + #[test] + fn state_key_matches_the_freshness_template_mask() { + let registry = TemplateRegistry::new(); + let freshness = registry.get(FRESHNESS_TEMPLATE).unwrap(); + assert_eq!(STATE_XOR_KEY, freshness.properties[0].xor_mask.unwrap()); + } +} diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/humidifi/v1/liquidity.rs new file mode 100644 index 000000000..69d5bf5a4 --- /dev/null +++ b/crates/core/src/scenarios/protocols/humidifi/v1/liquidity.rs @@ -0,0 +1,585 @@ +//! HumidiFi liquidity stress. +//! +//! Scales the market's current vault balances through the generic SPL token balance template while +//! keeping the market fresh. Every override derives an exact integer balance from current state. + +use std::collections::HashMap; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + +use crate::{error::SurfpoolResult, scenarios::TemplateRegistry, types::TokenAccount}; + +use super::fair_value::{ + FRESHNESS_TEMPLATE, HumidiFiMarket, PREPARATION_SLOT, invalid, read_masked_pubkey, template, + validate_humidifi_market_layout, +}; + +/// The vault addresses are read, never written: their balances ride the generic token template. +const QUOTE_VAULT_OFFSET: usize = 448; +const BASE_VAULT_OFFSET: usize = 480; + +const TOKEN_BALANCE_TEMPLATE: &str = "spl-token-account-balance"; +const BPS: u128 = 10_000; + +/// The market's `[base, quote]` vault addresses, unmasked. +pub fn humidifi_vault_addresses(market_account: &Account) -> SurfpoolResult<[Pubkey; 2]> { + validate_humidifi_market_layout(market_account)?; + let base = read_masked_pubkey(&market_account.data, BASE_VAULT_OFFSET)?; + let quote = read_masked_pubkey(&market_account.data, QUOTE_VAULT_OFFSET)?; + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(invalid("market has invalid vault identities")); + } + Ok([base, quote]) +} + +pub fn build_humidifi_liquidity_scenario( + market: &HumidiFiMarket, + market_account: &Account, + base_vault: &Account, + quote_vault: &Account, + base_remaining_bps: u16, + quote_remaining_bps: u16, +) -> SurfpoolResult { + if u128::from(base_remaining_bps) > BPS || u128::from(quote_remaining_bps) > BPS { + return Err(invalid( + "remaining liquidity must be between 0 and 10000 basis points", + )); + } + if u128::from(base_remaining_bps) == BPS && u128::from(quote_remaining_bps) == BPS { + return Err(invalid( + "liquidity stress changes nothing; lower at least one side", + )); + } + + let [base_vault_address, quote_vault_address] = humidifi_vault_addresses(market_account)?; + let (base_mint, quote_mint) = HumidiFiMarket::mint_addresses(market_account)?; + if base_mint != market.base_mint || quote_mint != market.quote_mint { + return Err(invalid( + "market account mint identities do not match validated market metadata", + )); + } + let base_balance = vault_balance( + base_vault, + market, + market.base_mint, + market.base_token_program, + "base", + )?; + let quote_balance = vault_balance( + quote_vault, + market, + market.quote_mint, + market.quote_token_program, + "quote", + )?; + + let registry = TemplateRegistry::new(); + let balance_template = template(®istry, TOKEN_BALANCE_TEMPLATE)?; + let freshness = template(®istry, FRESHNESS_TEMPLATE)?; + let label = market.label(); + + let mut scenario = Scenario::new( + format!("HumidiFi {label} liquidity stress"), + format!( + "Keep {} of the base and {} of the quote vault balance on HumidiFi market {}, preserving the price and keeping the quote fresh; no swap is sent.", + percent(base_remaining_bps), + percent(quote_remaining_bps), + market.address + ), + ); + scenario.tags = vec![ + "humidifi".to_string(), + "pmm".to_string(), + "liquidity-stress".to_string(), + ]; + + for (side, address, balance, bps) in [ + ("base", base_vault_address, base_balance, base_remaining_bps), + ( + "quote", + quote_vault_address, + quote_balance, + quote_remaining_bps, + ), + ] { + if u128::from(bps) == BPS { + continue; + } + let remaining = u64::try_from(u128::from(balance) * u128::from(bps) / BPS) + .map_err(|_| invalid(format!("{side} vault balance overflow")))?; + scenario.add_override( + OverrideInstance::new( + balance_template.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(address.to_string()), + ) + .with_values(HashMap::from([( + "amount".to_string(), + serde_json::json!(remaining.to_string()), + )])) + .with_label(format!("Reduce HumidiFi {side} liquidity")), + ); + } + + scenario.add_override( + OverrideInstance::new( + freshness.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(market.address.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep HumidiFi quote fresh".to_string()) + .with_persist(true), + ); + Ok(scenario) +} + +fn vault_balance( + vault: &Account, + market: &HumidiFiMarket, + mint: Pubkey, + token_program: Pubkey, + side: &str, +) -> SurfpoolResult { + if vault.owner != token_program { + return Err(invalid(format!( + "{side} vault token program does not match the market's {side} mint" + ))); + } + if vault.owner != spl_token_interface::id() && vault.owner != spl_token_2022_interface::id() { + return Err(invalid(format!( + "{side} vault is not owned by a supported token program" + ))); + } + let token = TokenAccount::unpack(&vault.data) + .map_err(|_| invalid(format!("{side} vault is not an initialized token account")))?; + if !token_account_is_initialized(&token) { + return Err(invalid(format!("{side} vault is not initialized"))); + } + if token.mint() != mint { + return Err(invalid(format!( + "{side} vault does not hold the market's {side} mint" + ))); + } + if token.owner() != market.address { + return Err(invalid(format!( + "{side} vault is not controlled by the market" + ))); + } + Ok(token.amount()) +} + +fn token_account_is_initialized(token: &TokenAccount) -> bool { + match token { + TokenAccount::SplToken2022(account) => { + account.state == spl_token_2022_interface::state::AccountState::Initialized + } + TokenAccount::SplToken(account) => { + account.state == spl_token_interface::state::AccountState::Initialized + } + } +} + +fn percent(bps: u16) -> String { + format!("{}.{:02}%", bps / 100, bps % 100) +} + +#[cfg(test)] +mod tests { + use solana_program_pack::Pack; + + use super::{ + super::fair_value::{HUMIDIFI_PROGRAM_ID, PUBKEY_XOR_KEYS, schema_version_bytes}, + *, + }; + + const STATE_KEY: u64 = 0x6e9d_e2b3_0b19_f1ea; + + struct Fixture { + market: HumidiFiMarket, + market_account: Account, + base_vault_address: Pubkey, + quote_vault_address: Pubkey, + base_vault: Account, + quote_vault: Account, + } + + fn write_masked_pubkey(data: &mut [u8], offset: usize, pubkey: &Pubkey) { + let bytes = pubkey.to_bytes(); + for (i, key) in PUBKEY_XOR_KEYS.iter().enumerate() { + let word = u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().unwrap()); + data[offset + i * 8..offset + i * 8 + 8].copy_from_slice(&(word ^ key).to_le_bytes()); + } + } + + fn mint_account(decimals: u8, token_program: Pubkey) -> Account { + let mut data = vec![0; spl_token_interface::state::Mint::LEN]; + if token_program == spl_token_2022_interface::id() { + spl_token_2022_interface::state::Mint { + decimals, + is_initialized: true, + ..Default::default() + } + .pack_into_slice(&mut data); + } else { + spl_token_interface::state::Mint { + decimals, + is_initialized: true, + ..Default::default() + } + .pack_into_slice(&mut data); + } + Account { + data, + owner: token_program, + ..Account::default() + } + } + + fn token_account( + mint: Pubkey, + authority: Pubkey, + amount: u64, + token_program: Pubkey, + state: &str, + ) -> Account { + let mut token = TokenAccount::new(&token_program, authority, mint, None); + token.set_amount(amount); + token.set_state_from_str(state).unwrap(); + Account { + lamports: 2_039_280, + data: token.pack_into_vec(), + owner: token_program, + ..Account::default() + } + } + + fn fixture(base_amount: u64, quote_amount: u64) -> Fixture { + fixture_with_token_programs( + base_amount, + quote_amount, + spl_token_interface::id(), + spl_token_interface::id(), + ) + } + + fn fixture_with_token_programs( + base_amount: u64, + quote_amount: u64, + base_token_program: Pubkey, + quote_token_program: Pubkey, + ) -> Fixture { + let address = Pubkey::new_unique(); + let base_mint = Pubkey::new_unique(); + let quote_mint = Pubkey::new_unique(); + let base_vault_address = Pubkey::new_unique(); + let quote_vault_address = Pubkey::new_unique(); + + let registry = TemplateRegistry::new(); + let layout = registry + .get("humidifi-fair-value") + .and_then(|template| template.raw_layout.clone()) + .unwrap(); + let magic = layout.magic.unwrap(); + let mut data = vec![0; layout.account_size]; + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[1720..1728].copy_from_slice(&schema_version_bytes()); + data[608..616].copy_from_slice(&(2u64 ^ STATE_KEY).to_le_bytes()); + write_masked_pubkey(&mut data, 416, &base_mint); + write_masked_pubkey(&mut data, 384, "e_mint); + write_masked_pubkey(&mut data, BASE_VAULT_OFFSET, &base_vault_address); + write_masked_pubkey(&mut data, QUOTE_VAULT_OFFSET, "e_vault_address); + let market_account = Account { + data, + owner: HUMIDIFI_PROGRAM_ID, + ..Account::default() + }; + let market = HumidiFiMarket::validate( + address, + &market_account, + &mint_account(9, base_token_program), + &mint_account(6, quote_token_program), + ) + .unwrap(); + Fixture { + base_vault: token_account( + base_mint, + address, + base_amount, + base_token_program, + "initialized", + ), + quote_vault: token_account( + quote_mint, + address, + quote_amount, + quote_token_program, + "initialized", + ), + market, + market_account, + base_vault_address, + quote_vault_address, + } + } + + fn amount_of(instance: &OverrideInstance) -> u64 { + instance + .values + .get("amount") + .and_then(|value| value.as_str()) + .and_then(|value| value.parse().ok()) + .unwrap() + } + + #[test] + fn reads_the_vault_addresses_the_market_stores() { + let fixture = fixture(1, 1); + assert_eq!( + humidifi_vault_addresses(&fixture.market_account).unwrap(), + [fixture.base_vault_address, fixture.quote_vault_address] + ); + } + + #[test] + fn scales_only_the_selected_side_and_keeps_the_quote_fresh() { + let fixture = fixture(1_000_000, 2_000_000); + let scenario = build_humidifi_liquidity_scenario( + &fixture.market, + &fixture.market_account, + &fixture.base_vault, + &fixture.quote_vault, + 50, + 10_000, + ) + .unwrap(); + + let [base, freshness] = &scenario.overrides[..] else { + panic!("expected one vault override and the freshness override"); + }; + assert_eq!(base.template_id, TOKEN_BALANCE_TEMPLATE); + assert_eq!( + base.account, + AccountAddress::Pubkey(fixture.base_vault_address.to_string()) + ); + assert_eq!(amount_of(base), 5_000); + assert!(!base.fetch_before_use); + assert!(!base.persist); + assert_eq!(freshness.template_id, FRESHNESS_TEMPLATE); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(fixture.market.address.to_string()) + ); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + assert!(scenario.name.contains(&fixture.market.label())); + assert!(scenario.description.contains("0.50% of the base")); + assert!(scenario.tags.contains(&"liquidity-stress".to_string())); + } + + #[test] + fn drains_a_side_floors_the_remainder_and_rejects_noops() { + let fixture = fixture(1_000_001, 2_000_000); + let build = |base: u16, quote: u16| { + build_humidifi_liquidity_scenario( + &fixture.market, + &fixture.market_account, + &fixture.base_vault, + &fixture.quote_vault, + base, + quote, + ) + }; + + let drained = build(0, 10_000).unwrap(); + assert_eq!(amount_of(&drained.overrides[0]), 0); + + let both = build(3_333, 2_500).unwrap(); + assert_eq!(both.overrides.len(), 3); + assert_eq!(amount_of(&both.overrides[0]), 333_300); + assert_eq!( + both.overrides[1].account, + AccountAddress::Pubkey(fixture.quote_vault_address.to_string()) + ); + assert_eq!(amount_of(&both.overrides[1]), 500_000); + + assert!(build(10_000, 10_000).is_err()); + assert!(build(10_001, 10_000).is_err()); + } + + #[test] + fn rejects_vaults_that_do_not_belong_to_the_market() { + let fixture = fixture(1_000_000, 2_000_000); + let build = |base_vault: &Account, quote_vault: &Account| { + build_humidifi_liquidity_scenario( + &fixture.market, + &fixture.market_account, + base_vault, + quote_vault, + 500, + 10_000, + ) + }; + + let wrong_mint = token_account( + Pubkey::new_unique(), + fixture.market.address, + 1, + spl_token_interface::id(), + "initialized", + ); + assert!(build(&wrong_mint, &fixture.quote_vault).is_err()); + + let wrong_authority = token_account( + fixture.market.base_mint, + Pubkey::new_unique(), + 1, + spl_token_interface::id(), + "initialized", + ); + assert!(build(&wrong_authority, &fixture.quote_vault).is_err()); + + let foreign_program = Account { + owner: Pubkey::new_unique(), + ..fixture.base_vault.clone() + }; + assert!(build(&foreign_program, &fixture.quote_vault).is_err()); + + let not_a_token_account = Account { + data: vec![0; 10], + ..fixture.base_vault.clone() + }; + assert!(build(¬_a_token_account, &fixture.quote_vault).is_err()); + + let mut version_5 = fixture.market_account.clone(); + version_5.data[1720..1728].copy_from_slice(&5u64.to_le_bytes()); + assert!(humidifi_vault_addresses(&version_5).is_err()); + } + + #[test] + fn rejects_vault_token_program_mismatches_on_both_sides() { + let fixture = fixture(1_000_000, 2_000_000); + let mut base_mismatch = fixture.base_vault.clone(); + base_mismatch.owner = spl_token_2022_interface::id(); + let base_error = build_humidifi_liquidity_scenario( + &fixture.market, + &fixture.market_account, + &base_mismatch, + &fixture.quote_vault, + 500, + 10_000, + ) + .unwrap_err(); + assert!(base_error.to_string().contains("base vault token program")); + + let mut quote_mismatch = fixture.quote_vault.clone(); + quote_mismatch.owner = spl_token_2022_interface::id(); + let quote_error = build_humidifi_liquidity_scenario( + &fixture.market, + &fixture.market_account, + &fixture.base_vault, + "e_mismatch, + 500, + 10_000, + ) + .unwrap_err(); + assert!( + quote_error + .to_string() + .contains("quote vault token program") + ); + } + + #[test] + fn rejects_frozen_and_uninitialized_vaults() { + let fixture = fixture(1_000_000, 2_000_000); + let frozen = token_account( + fixture.market.base_mint, + fixture.market.address, + 1_000_000, + spl_token_interface::id(), + "frozen", + ); + let frozen_error = build_humidifi_liquidity_scenario( + &fixture.market, + &fixture.market_account, + &frozen, + &fixture.quote_vault, + 500, + 10_000, + ) + .unwrap_err(); + assert!( + frozen_error + .to_string() + .contains("base vault is not initialized") + ); + + let uninitialized = token_account( + fixture.market.quote_mint, + fixture.market.address, + 2_000_000, + spl_token_interface::id(), + "uninitialized", + ); + let uninitialized_error = build_humidifi_liquidity_scenario( + &fixture.market, + &fixture.market_account, + &fixture.base_vault, + &uninitialized, + 500, + 10_000, + ) + .unwrap_err(); + assert!( + uninitialized_error + .to_string() + .contains("quote vault is not an initialized token account") + ); + } + + #[test] + fn accepts_initialized_token_2022_vaults() { + let fixture = fixture_with_token_programs( + 1_000_000, + 2_000_000, + spl_token_2022_interface::id(), + spl_token_2022_interface::id(), + ); + let scenario = build_humidifi_liquidity_scenario( + &fixture.market, + &fixture.market_account, + &fixture.base_vault, + &fixture.quote_vault, + 500, + 10_000, + ) + .unwrap(); + assert_eq!(amount_of(&scenario.overrides[0]), 50_000); + } + + #[test] + fn rejects_market_metadata_from_a_different_account_graph() { + let fixture = fixture(1_000_000, 2_000_000); + let mut mismatched_market = fixture.market.clone(); + mismatched_market.base_mint = Pubkey::new_unique(); + let error = build_humidifi_liquidity_scenario( + &mismatched_market, + &fixture.market_account, + &fixture.base_vault, + &fixture.quote_vault, + 500, + 10_000, + ) + .unwrap_err(); + assert!(error.to_string().contains("mint identities do not match")); + } +} diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/markets.rs b/crates/core/src/scenarios/protocols/humidifi/v1/markets.rs new file mode 100644 index 000000000..6c59bdabc --- /dev/null +++ b/crates/core/src/scenarios/protocols/humidifi/v1/markets.rs @@ -0,0 +1,126 @@ +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 crate::{ + error::{SurfpoolError, SurfpoolResult}, + surfnet::remote::SurfnetRemoteClient, +}; + +use super::{ + HUMIDIFI_PROGRAM_ID, HumidiFiMarket, + fair_value::{MARKET_LAYOUT, SCHEMA_VERSION_OFFSET, schema_version_bytes}, +}; + +pub async fn discover_humidifi_markets( + client: &SurfnetRemoteClient, +) -> SurfpoolResult> { + let accounts = client + .get_program_accounts( + &HUMIDIFI_PROGRAM_ID, + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + commitment: Some(CommitmentConfig::confirmed()), + ..Default::default() + }, + Some(discovery_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 HumidiFi market {address}")) + })?; + let mints = HumidiFiMarket::mint_addresses(&account)?; + Ok((address, account, mints)) + }) + .collect::>>()?; + + let mut mints = accounts + .iter() + .flat_map(|(_, _, (base, quote))| [*base, *quote]) + .collect::>(); + mints.sort_unstable(); + mints.dedup(); + let mut mint_accounts = HashMap::new(); + for batch in mints.chunks(100) { + let fetched = client + .get_multiple_accounts(batch, CommitmentConfig::confirmed()) + .await?; + for (address, account) in batch.iter().zip(fetched) { + mint_accounts.insert(*address, account.map_account()?); + } + } + + let mut markets = accounts + .iter() + .map(|(address, account, (base, quote))| { + let mint = |address| { + mint_accounts.get(address).ok_or_else(|| { + SurfpoolError::internal(format!("HumidiFi mint {address} was not found")) + }) + }; + HumidiFiMarket::validate(*address, account, mint(base)?, mint(quote)?) + }) + .collect::>>()?; + markets.sort_by_cached_key(|market| (market.label(), market.address)); + Ok(markets) +} + +pub(super) fn discovery_filters() -> Vec { + let mut filters = vec![RpcFilterType::DataSize(MARKET_LAYOUT.account_size as u64)]; + if let Some(magic) = &MARKET_LAYOUT.magic { + filters.push(RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + magic.offset, + magic.bytes.clone(), + ))); + } + filters.push(RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + SCHEMA_VERSION_OFFSET, + schema_version_bytes().to_vec(), + ))); + filters +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovery_filters_match_the_validator_gate() { + let filters = discovery_filters(); + let magic = MARKET_LAYOUT.magic.as_ref().unwrap(); + let [ + RpcFilterType::DataSize(size), + RpcFilterType::Memcmp(tag), + RpcFilterType::Memcmp(version), + ] = &filters[..] + else { + panic!("expected size, magic and schema-version filters"); + }; + assert_eq!(*size, 1728); + assert_eq!(tag.offset(), 8); + assert_eq!(tag.bytes().unwrap().as_ref(), &magic.bytes); + assert_eq!(version.offset(), 1720); + assert_eq!(version.bytes().unwrap().as_slice(), schema_version_bytes()); + assert_eq!(schema_version_bytes(), 8u64.to_le_bytes()); + + let mut data = vec![0; MARKET_LAYOUT.account_size]; + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[SCHEMA_VERSION_OFFSET..SCHEMA_VERSION_OFFSET + 8].copy_from_slice(&8u64.to_le_bytes()); + assert!(tag.bytes_match(&data)); + assert!(version.bytes_match(&data)); + data[SCHEMA_VERSION_OFFSET..SCHEMA_VERSION_OFFSET + 8].copy_from_slice(&5u64.to_le_bytes()); + assert!(tag.bytes_match(&data)); + assert!(!version.bytes_match(&data)); + } +} diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/mod.rs b/crates/core/src/scenarios/protocols/humidifi/v1/mod.rs new file mode 100644 index 000000000..8d5b7044a --- /dev/null +++ b/crates/core/src/scenarios/protocols/humidifi/v1/mod.rs @@ -0,0 +1,10 @@ +mod fair_value; +mod liquidity; +mod markets; + +pub use fair_value::{ + HUMIDIFI_PROGRAM_ID, HumidiFiFairValuePreparation, HumidiFiMarket, + build_humidifi_fair_value_scenario, validate_humidifi_market_layout, +}; +pub use liquidity::{build_humidifi_liquidity_scenario, humidifi_vault_addresses}; +pub use markets::discover_humidifi_markets; diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml b/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml new file mode 100644 index 000000000..ee2cc0a6f --- /dev/null +++ b/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml @@ -0,0 +1,132 @@ +protocol: HumidiFi +version: deployed-446544344 +account_type: MarketState + +# HumidiFi publishes no IDL and none is reconstructed here. Every write goes through the byte +# layout below. The quoted price and state fields store each word as `plaintext XOR key`, +# with a fixed per-offset key compiled into the program. The templates keep their values plaintext +# and declare the key as `xor_mask`; the engine masks on write. See ../README.md. +raw_layout: + account_size: 1728 + magic: + # Masked structural word at offset 8, shared by the program's market accounts. Size 1728 + # separates them from its 2440- and 560-byte accounts. + # The separate schema word at 1720 is gated in Rust and discovery; raw magic is contiguous. + offset: 8 + bytes: [44, 90, 19, 124, 56, 111, 47, 150] + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: humidifi-fair-value + name: Override HumidiFi Fair Value + description: Move a HumidiFi market's quoted price + idl_account_name: MarketState + address: + type: pubkey + properties: + - path: fair_value + offset: 576 + encoding: u64 + # 0xb957ed15dc877426, the fixed key HumidiFi XORs this word with. Fair value = quote-per-base ratio x 2^48. + xor_mask: 13355403898140455974 + label: Fair value + description: >- + Quote atomic units per base atomic unit, multiplied by 2^48, as a decimal string. Use the + HumidiFi fair-value builder to derive this from a human price and the market's mint decimals. + llm_context: | + PRECONDITION - THE QUOTE MUST BE FRESH. HumidiFi rejects a quote once its age exceeds the market's + staleness limit. Discover that limit with list_humidifi_markets. A forked market goes stale + on its own, because nothing in a fork republishes the quote, and then this override sits + correctly in the account but the swap fails on staleness. Pair it with humidifi-freshness, + persisted, whenever the scenario spends more than a slot before executing. + + THE VALUE IS A RAW RATIO, NOT A HUMAN PRICE. It is quote atoms per base atom times 2^48. To go + from a human price: raw = price * 2^48 * 10^(quote_decimals - base_decimals), floored. Because + that needs both mints' decimals, prefer the fair-value builder, which reads them from the + market and does the conversion exactly. If composing the raw template directly, pass the ratio + as a decimal STRING, since it can exceed what a JSON number holds exactly. + + Discover markets with list_humidifi_markets and set the override account to the chosen address. + A market address is required; there is no default. Set fetchBeforeUse: true, as the builder does, + so the market is fetched from the Surfnet datasource before applying the requested price. + This replaces earlier local edits to the market. The following humidifi-freshness override + uses fetchBeforeUse: false to preserve that price, with persist: true to keep it fresh. + + EXAMPLE - SOL/USDC at 208 quote tokens per base token (9 base decimals, 6 quote): + fair_value: "58546795155816" + - id: humidifi-freshness + name: Refresh HumidiFi Quote + description: Publish the materialization slot into HumidiFi's freshness field + idl_account_name: MarketState + address: + type: pubkey + properties: + - path: last_update_slot + offset: 616 + encoding: + slot: + lead: 0 + # 0x6e9de2b30b19f1ea, the market's state-word key. + xor_mask: 7970776174128919018 + label: Slot lead + description: >- + Slot lead, as an integer. Pass null to take the lead of zero and write the materialization + slot itself, which keeps the quote live. + llm_context: | + This field is the venue's liveness signal, compared against the chain slot. HumidiFi accepts a + quote through the market's staleness limit. Discover markets and their maxStalenessSlots with + list_humidifi_markets and set the override account to the chosen address; there is no default. + At age maxStalenessSlots + 1 the swap fails with Custom(1027565), or 0xfaded; + at age maxStalenessSlots it still fills. + + A FORKED MARKET GOES STALE BY ITSELF. Surfpool caches fetched accounts and does not + continuously republish the quote. Once past the + limit the market stops quoting and the fair-value override is silently ineffective - the swap + fails on staleness. That makes this template the precondition for humidifi-fair-value. + + THE VALUE IS A LEAD, NOT A SLOT NUMBER. It is resolved against the slot the override + materializes at, so 0 means "published this slot". Pass null to take the lead of zero. + + HOW TO USE: + 1. Set last_update_slot to null (or 0). The venue resumes quoting the price it already holds. + 2. Set persist: true, so every slot re-stamps itself and the quote stays live indefinitely. A + fixed absolute slot would age by one slot per slot and go stale anyway. + 3. Set fetchBeforeUse: true so the live market is forked first. Use false only for a later + override that builds on state an earlier one prepared in the same scenario, which is why the + fair-value builder pairs this override at false after the price write. + + EXAMPLE - keep the maker quoting for the whole run: + last_update_slot: null, persist: true + - id: humidifi-stale-quote + name: Make HumidiFi Quote Stale + description: Age a HumidiFi quote to its market's rejection boundary + idl_account_name: MarketState + address: + type: pubkey + properties: + - path: last_update_slot + offset: 616 + encoding: + slot: + lead: -3 + xor_mask: 7970776174128919018 + label: Slot lead + description: >- + How far behind the materialization slot to place the quote, as a negative integer. Pass + null to use -3. Use -(maxStalenessSlots + 1) for the chosen market's rejection boundary. + llm_context: | + The value you pass IS the lead: Surfpool writes the materialization slot plus it, clamped at + zero. Pass null to take the -3 default. This is one template for every market, not one per limit. + + Rejection is at age strictly greater than the market's own limit. Discover markets with + list_humidifi_markets and set the override account to the chosen address; there is no default. + Read maxStalenessSlots for that market (the market's masked + word at offset 608), and pass -(maxStalenessSlots + 1) to reach the first rejection slot. + At that age the swap fails with Custom(1027565), or 0xfaded; one slot younger it fills. + + Do not persist this override: the quote should stay stale. Set fetchBeforeUse: true so the live + market is forked first. Keep override labels short ("SOL/USDC stale quote"). diff --git a/crates/core/src/scenarios/protocols/mod.rs b/crates/core/src/scenarios/protocols/mod.rs index 99f0b0967..6a8b7b049 100644 --- a/crates/core/src/scenarios/protocols/mod.rs +++ b/crates/core/src/scenarios/protocols/mod.rs @@ -1 +1,2 @@ +pub mod humidifi; pub mod pump; diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index a421a3f91..d21219503 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -23,6 +23,9 @@ pub const METEORA_DLMM_OVERRIDES_CONTENT: &str = pub const KAMINO_V1_IDL_CONTENT: &str = include_str!("./protocols/kamino/v1/idl.json"); pub const KAMINO_V1_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/v1/overrides.yaml"); +pub const HUMIDIFI_V1_OVERRIDES_CONTENT: &str = + include_str!("./protocols/humidifi/v1/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"); @@ -76,6 +79,7 @@ impl TemplateRegistry { default.load_raydium_overrides(); default.load_meteora_overrides(); default.load_kamino_overrides(); + default.load_humidifi_overrides(); default.load_drift_overrides(); default.load_whirlpool_overrides(); default.load_spl_token_overrides(); @@ -116,6 +120,10 @@ impl TemplateRegistry { ); } + pub fn load_humidifi_overrides(&mut self) { + self.load_raw_layout_overrides(HUMIDIFI_V1_OVERRIDES_CONTENT, "humidifi"); + } + pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); @@ -516,13 +524,17 @@ 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) = 62 + // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + HumidiFi (3) = 65 assert_eq!( registry.count(), - 62, - "Registry should load 62 templates total" + 65, + "Registry should load 65 templates total" ); + assert!(registry.contains("humidifi-fair-value")); + assert!(registry.contains("humidifi-freshness")); + assert!(registry.contains("humidifi-stale-quote")); + assert!(registry.contains("pyth-price-feed-v2")); assert!(registry.contains("jupiter-token-ledger-override")); diff --git a/crates/core/src/tests/humidifi/mod.rs b/crates/core/src/tests/humidifi/mod.rs new file mode 100644 index 000000000..6bfbe4be4 --- /dev/null +++ b/crates/core/src/tests/humidifi/mod.rs @@ -0,0 +1,1029 @@ +//! HumidiFi live integration tests. +//! +//! cargo test -p surfpool-core --features integration-tests humidifi -- --test-threads=1 --nocapture +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. +//! +//! HumidiFi market fields are XOR-obfuscated: an 8-byte word is stored as `plaintext XOR key`. These +//! tests fetch live markets and prove the shipped templates write exactly their target fields, that +//! the masked write round-trips to the plaintext the caller asked for, that the guard rejects a +//! foreign account, and that discovery matches the chain. They pin the deployed ProgramData +//! so a redeploy that could move the keys or offsets fails loudly rather than writing garbage. +//! +//! Swap replays execute the deployed HumidiFi program through a native DFlow shim to prove the +//! fair-value effect and the configured staleness boundary. + +use std::collections::HashMap; + +use litesvm::LiteSVM; +use sha2::{Digest, Sha256}; +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_instruction::{AccountMeta, Instruction}; +use solana_message::Message; +use solana_program_pack::Pack; +use solana_program_runtime::{ + declare_process_instruction, solana_sbpf::program::BuiltinFunctionDefinition, +}; +use solana_pubkey::Pubkey; +use solana_signature::Signature; +use solana_transaction::Transaction; + +use super::live::{self, diff_indices, fetch}; +use crate::{ + scenarios::{ + TemplateRegistry, + protocols::humidifi::v1::{ + HumidiFiMarket, build_humidifi_fair_value_scenario, build_humidifi_liquidity_scenario, + discover_humidifi_markets, humidifi_vault_addresses, validate_humidifi_market_layout, + }, + }, + surfnet::svm::SurfnetSvm, +}; + +const HUMIDIFI_PROGRAM: Pubkey = + Pubkey::from_str_const("9H6tua7jkLhdm3w8BvgpTn5LZNU7g4ZynDmCiNN3q6Rp"); +const HUMIDIFI_PROGRAMDATA: Pubkey = + Pubkey::from_str_const("G9S64i58RRWJA28vZiNhnP56Ux4Ef7hfMgHNREnZZSom"); +/// Pinned deployment. A change here means HumidiFi was upgraded and the keys and offsets below must +/// be revalidated before the templates are trusted again. +const DEPLOY_SLOT: u64 = 446_544_344; +const PROGRAMDATA_SIZE: usize = 339_485; +const ELF_SHA256: &str = "4c2b4c29bce4ee4d2a0dfde28f6d511e60627e86ac3cd417e6734ff999ea4550"; + +const SOL_USDC_MARKET: Pubkey = + Pubkey::from_str_const("FksffEqnBRixYGR791Qw2MgdU7zNCpHVFYBL4Fa4qVuH"); +/// A second, differently-scaled market, so a claim of generic support is tested on two assets. +const SECOND_MARKET: Pubkey = Pubkey::from_str_const("hKgG7iEDRFNsJSwLYqz8ETHuZwzh6qMMLow8VXa8pLm"); + +const FAIR_VALUE_OFFSET: usize = 576; +const LAST_UPDATE_SLOT_OFFSET: usize = 616; +const MAX_STALENESS_OFFSET: usize = 608; +const MAGIC_OFFSET: usize = 8; +const BASE_MINT_OFFSET: usize = 416; +const QUOTE_MINT_OFFSET: usize = 384; +const SCHEMA_VERSION_OFFSET: usize = 1720; + +const FAIR_VALUE_KEY: u64 = 0xb957_ed15_dc87_7426; +const STATE_KEY: u64 = 0x6e9d_e2b3_0b19_f1ea; +const PUBKEY_XOR_KEYS: [u64; 4] = [ + 0xfb5c_e87a_ae44_3c38, + 0x04a2_1784_51ba_c3c7, + 0x04a1_1787_51b9_c3c6, + 0x04a0_1786_51b8_c3c5, +]; + +const MARKET_SIZE: usize = 1728; + +// ---- decode helpers ---- + +fn decode_u64(data: &[u8], offset: usize, key: u64) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) ^ key +} + +fn encode_masked_u64(data: &mut [u8], offset: usize, key: u64, value: u64) { + data[offset..offset + 8].copy_from_slice(&(value ^ key).to_le_bytes()); +} + +fn unmask_pubkey(data: &[u8], offset: usize) -> Pubkey { + let mut bytes = [0u8; 32]; + for (i, key) in PUBKEY_XOR_KEYS.iter().enumerate() { + let word = u64::from_le_bytes(data[offset + i * 8..offset + i * 8 + 8].try_into().unwrap()); + bytes[i * 8..i * 8 + 8].copy_from_slice(&(word ^ key).to_le_bytes()); + } + Pubkey::new_from_array(bytes) +} + +/// Asserts every changed byte lies inside one of `ranges`. A masked write can leave a byte equal to +/// the original, so the touched set is a subset of the field, not always the whole field; what must +/// hold is that nothing OUTSIDE the field moved. +fn assert_only_within(diffs: &[usize], ranges: &[std::ops::Range], context: &str) { + for index in diffs { + assert!( + ranges.iter().any(|range| range.contains(index)), + "{context}: byte {index} changed outside the target field(s) {ranges:?}" + ); + } +} + +/// The single-field case of [`assert_only_within`]. +fn assert_within(diffs: &[usize], range: std::ops::Range, context: &str) { + for index in diffs { + assert!( + range.contains(index), + "{context}: byte {index} changed outside the target field {range:?}" + ); + } +} + +fn template_raw_apply( + template_id: &str, + values: HashMap, + slot: u64, + data: &[u8], +) -> Vec { + let registry = TemplateRegistry::new(); + let template = registry.get(template_id).expect("HumidiFi template"); + let materialized = template + .raw_layout + .as_ref() + .expect("HumidiFi raw layout") + .materialize(data, &template.properties, &values, slot) + .expect("materialize"); + assert_eq!(materialized.len(), data.len(), "account length changed"); + materialized +} + +// ---- tests ---- + +/// The upgrade canary: pin the deployed program so a redeploy that could move the keys or offsets +/// fails loudly, exactly where the layout evidence would otherwise silently rot. +#[tokio::test] +async fn humidifi_programdata_identity_is_pinned() { + let data = fetch(&[HUMIDIFI_PROGRAMDATA]).await.remove(0).data; + assert_eq!(data.len(), PROGRAMDATA_SIZE, "ProgramData size changed"); + let deploy_slot = u64::from_le_bytes(data[4..12].try_into().unwrap()); + assert_eq!( + deploy_slot, DEPLOY_SLOT, + "HumidiFi was redeployed; revalidate the layout and XOR keys before trusting the templates" + ); + let sha = hex::encode(Sha256::digest(&data[45..])); + assert_eq!( + sha, ELF_SHA256, + "HumidiFi ELF changed; revalidate the layout and XOR keys before trusting the templates" + ); +} + +/// Each template writes only its target field on live data, and the masked write round-trips to the +/// plaintext the caller asked for. Proven on two markets so generic support is not assumed. +#[tokio::test] +async fn humidifi_templates_write_only_their_fields_and_round_trip() { + for address in [SOL_USDC_MARKET, SECOND_MARKET] { + let market = fetch(&[address]).await.remove(0); + assert_eq!( + market.data.len(), + MARKET_SIZE, + "{address} is not a HumidiFi market layout" + ); + let data = market.data; + + // Fair value: a chosen raw ratio lands at offset 576, masked, and nothing else moves. + let chosen: u64 = 12_345_678_901_234; + let out = template_raw_apply( + "humidifi-fair-value", + HashMap::from([( + "fair_value".to_string(), + serde_json::json!(chosen.to_string()), + )]), + 0, + &data, + ); + assert_eq!( + decode_u64(&out, FAIR_VALUE_OFFSET, FAIR_VALUE_KEY), + chosen, + "{address}: the masked fair value must unmask to the chosen ratio" + ); + assert_within( + &diff_indices(&out, &data), + FAIR_VALUE_OFFSET..FAIR_VALUE_OFFSET + 8, + &format!("{address} fair value"), + ); + + // Freshness: the materialization slot lands at offset 616, masked. + let slot = 500_000_000u64; + let out = template_raw_apply( + "humidifi-freshness", + HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + slot, + &data, + ); + assert_eq!( + decode_u64(&out, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), + slot, + "{address}: freshness must publish the materialization slot" + ); + assert_within( + &diff_indices(&out, &data), + LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, + &format!("{address} freshness"), + ); + + // Stale: the default lead of -3 ages the quote past the tested SOL/USDC market's inclusive limit. + let out = template_raw_apply( + "humidifi-stale-quote", + HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + slot, + &data, + ); + assert_eq!( + decode_u64(&out, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), + slot - 3, + "{address}: the stale template must age the quote by its lead" + ); + assert_within( + &diff_indices(&out, &data), + LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, + &format!("{address} stale quote"), + ); + } +} + +/// The guard admits a real market and rejects everything else, and the owner check the builder adds +/// catches a foreign account the raw guard cannot see. +#[tokio::test] +async fn humidifi_guard_admits_markets_and_rejects_others() { + let market = fetch(&[SOL_USDC_MARKET]).await.remove(0); + let registry = TemplateRegistry::new(); + let layout = registry + .get("humidifi-fair-value") + .and_then(|t| t.raw_layout.clone()) + .expect("HumidiFi raw layout"); + + assert!( + layout.guard(&market.data).is_ok(), + "the real market must pass" + ); + assert!(validate_humidifi_market_layout(&market).is_ok()); + + // Wrong size. + let mut short = market.data.clone(); + short.truncate(MARKET_SIZE - 8); + let err = layout.guard(&short).unwrap_err(); + assert!(err.contains("bytes"), "unexpected error: {err}"); + + // Right size, wrong magic. + let mut tampered = market.data.clone(); + tampered[MAGIC_OFFSET] ^= 0xff; + let err = layout.guard(&tampered).unwrap_err(); + assert!(err.contains("magic"), "unexpected error: {err}"); + + // Right bytes, wrong owner: the raw guard passes, the owner check does not. + let foreign = Account { + owner: Pubkey::new_unique(), + ..market.clone() + }; + assert!(layout.guard(&foreign.data).is_ok()); + assert!(validate_humidifi_market_layout(&foreign).is_err()); + + let mut version_5 = market.clone(); + encode_masked_u64(&mut version_5.data, SCHEMA_VERSION_OFFSET, 0, 5); + assert!(layout.guard(&version_5.data).is_ok()); + assert!(validate_humidifi_market_layout(&version_5).is_err()); +} + +#[tokio::test] +async fn humidifi_discovers_live_markets() { + let markets = discover_humidifi_markets(&live::client()) + .await + .expect("discover live markets"); + assert!(!markets.is_empty(), "HumidiFi must expose market accounts"); + let addresses = markets + .iter() + .map(|market| market.address) + .collect::>(); + let unique = addresses.iter().collect::>(); + assert_eq!(unique.len(), markets.len()); + let mut accounts = Vec::new(); + for batch in addresses.chunks(100) { + accounts.extend(fetch(batch).await); + } + let mut mints = markets + .iter() + .flat_map(|market| [market.base_mint, market.quote_mint]) + .collect::>(); + mints.sort_unstable(); + mints.dedup(); + let mut mint_accounts = Vec::new(); + for batch in mints.chunks(100) { + mint_accounts.extend(fetch(batch).await); + } + for (market, account) in markets.iter().zip(&accounts) { + assert_eq!(account.owner, HUMIDIFI_PROGRAM); + let (base, quote) = + HumidiFiMarket::mint_addresses(account).expect("valid discovered market"); + let index = |mint| mints.binary_search(mint).expect("fetched mint"); + let expected = HumidiFiMarket::validate( + market.address, + account, + &mint_accounts[index(&base)], + &mint_accounts[index("e)], + ) + .unwrap(); + assert_eq!(*market, expected); + assert_eq!( + unmask_pubkey(&account.data, BASE_MINT_OFFSET), + market.base_mint + ); + assert_eq!( + unmask_pubkey(&account.data, QUOTE_MINT_OFFSET), + market.quote_mint + ); + assert_eq!( + market.max_staleness_slots, + decode_u64(&account.data, MAX_STALENESS_OFFSET, STATE_KEY) + ); + assert_eq!(decode_u64(&account.data, SCHEMA_VERSION_OFFSET, 0), 8); + assert!(!market.label().is_empty()); + } + let registry = TemplateRegistry::new(); + assert!( + !registry + .get("humidifi-fair-value") + .unwrap() + .constants + .contains_key("market") + ); + eprintln!( + "Discovered {} HumidiFi markets from program accounts", + markets.len() + ); +} + +/// The fair-value scale, checked against reality. Offset 576 is the quote-per-base ratio times +/// 2^48; decoding the live SOL/USDC market and converting through the mints' decimals must land in a +/// sane price band. A layout drift or a wrong scale (the earlier 2^47 guess was 2x off) blows past +/// this. The 2^48 scale itself was proven behaviorally: a live SOL/USDC swap executed at a rate that +/// equals the decoded fair value divided by 2^48, and the deployed program shifts by 48 (not 47). +#[tokio::test] +async fn humidifi_fair_value_scale_yields_a_sane_price() { + let market_account = fetch(&[SOL_USDC_MARKET]).await.remove(0); + let (base_mint, quote_mint) = HumidiFiMarket::mint_addresses(&market_account).expect("mints"); + let mints = fetch(&[base_mint, quote_mint]).await; + let market = HumidiFiMarket::validate(SOL_USDC_MARKET, &market_account, &mints[0], &mints[1]) + .expect("valid market"); + + let raw = decode_u64(&market_account.data, FAIR_VALUE_OFFSET, FAIR_VALUE_KEY); + // human = raw / 2^48 * 10^(base_decimals - quote_decimals) + let ratio = raw as f64 / (1u64 << 48) as f64; + let human = + ratio * 10f64.powi(i32::from(market.base_decimals) - i32::from(market.quote_decimals)); + eprintln!("HumidiFi {SOL_USDC_MARKET} decoded price ~= {human:.2} quote per base"); + assert!( + (1.0..100_000.0).contains(&human), + "SOL/USDC decoded to {human}, outside a sane band; the scale or the layout drifted" + ); +} + +/// The builder's full production path: read the live market and both mints, build the scenario, then +/// register and materialize it through the real materializer. The fair value lands from the human +/// price and the persisted freshness re-stamps itself on the next slot. +#[tokio::test] +async fn humidifi_builder_scenario_fetches_market_and_keeps_quote_fresh() { + let market_account = fetch(&[SOL_USDC_MARKET]).await.remove(0); + let base_slot = decode_u64(&market_account.data, LAST_UPDATE_SLOT_OFFSET, STATE_KEY) + 100; + let (base_mint, quote_mint) = HumidiFiMarket::mint_addresses(&market_account).expect("mints"); + let mints = fetch(&[base_mint, quote_mint]).await; + let market_key = SOL_USDC_MARKET; + let market = HumidiFiMarket::validate(market_key, &market_account, &mints[0], &mints[1]) + .expect("valid market"); + + let preparation = build_humidifi_fair_value_scenario(&market, "175.5").expect("build scenario"); + let expected_fair_value = preparation.fair_value; + assert!(preparation.scenario.name.contains(&market.label())); + + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + assert!(svm.inner.get_account(&market_key).unwrap().is_none()); + let remote = Some((live::client(), CommitmentConfig::confirmed())); + svm.register_scenario(preparation.scenario, Some(base_slot)) + .expect("register scenario"); + + // Play: both overrides apply at the base slot. + svm.materialize_overrides_for_slot(&remote, base_slot) + .await + .expect("materialize"); + let applied_account = svm.inner.get_account(&market_key).unwrap().unwrap(); + assert_eq!(applied_account.owner, market_account.owner); + assert_eq!(applied_account.data.len(), market_account.data.len()); + let applied = &applied_account.data; + assert_eq!( + decode_u64(applied, FAIR_VALUE_OFFSET, FAIR_VALUE_KEY), + expected_fair_value, + "the built fair value must land, masked" + ); + assert_eq!( + decode_u64(applied, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), + base_slot, + "freshness must publish the base slot" + ); + // Next slot: the persisted freshness re-stamps offset 616 to the new slot, and nothing else. + svm.materialize_overrides_for_slot(&remote, base_slot + 1) + .await + .expect("materialize next slot"); + let next_account = svm.inner.get_account(&market_key).unwrap().unwrap(); + assert_eq!(next_account.lamports, applied_account.lamports); + assert_eq!(next_account.owner, applied_account.owner); + let next = next_account.data; + assert_eq!(next.len(), applied.len()); + assert_eq!( + decode_u64(&next, FAIR_VALUE_OFFSET, FAIR_VALUE_KEY), + expected_fair_value, + "persisted freshness must preserve the prepared price" + ); + assert_eq!( + decode_u64(&next, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), + base_slot + 1, + "the persisted freshness must track the slot" + ); + assert_within( + &diff_indices(&next, applied), + LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, + "persisted freshness next slot", + ); +} + +// Swap-replay: the controlled experiment that proves the program reacts to the override. +// +// HumidiFi is only reachable through a router (DFlow), so this registers a native builtin under the +// router's id that re-emits a captured live HumidiFi CPI and runs the deployed program against the +// prepared market. It then re-encodes, doubles, and halves the fair value and asserts the real swap +// output moves exactly the predicted way. The one non-obvious setup detail: DFlow signs for a +// system-owned authority account (SYS0) as a read-only PDA signer, which the deployed program +// requires; sigverify is off, so the replay marks it a read-only signer. +// +// Source: transaction 3zevqwAa8u136UGE1bdBzP1o2dpFuc7X333dC1ut3T1uCgY6iidihfNJNoJ7tuyj3tHNin1i7HTFCUSFWqK7g1Si, +// slot 444225745, DFlow outer instruction 2 and its HumidiFi CPI. Only instruction metadata is +// captured: market, vaults, mints and executable bytes are fetched live on every run. + +const DFLOW: Pubkey = Pubkey::from_str_const("DF1ow4tspfHX9JwWJsAb9epbkA8hmpSEAtxXy1V27QBH"); + +// The eighteen accounts of the captured HumidiFi CPI, in order. +const SIGNER0: Pubkey = Pubkey::from_str_const("4HJaX8K9mH9fMLGn4Xc5DjGdjDWXNSnnX5kkXFuzk2ET"); +const BASE_VAULT: Pubkey = Pubkey::from_str_const("C3FzbX9n1YD2dow2dCmEv5uNyyf22Gb3TLAEqGBhw5fY"); +const QUOTE_VAULT: Pubkey = Pubkey::from_str_const("3RWFAQBRkNGq7CMGcTLK3kXDgFTe9jgMeFYqk8nHwcWh"); +const DEST_WSOL: Pubkey = Pubkey::from_str_const("CsqNfUnwbVbDQRQFUGCRCik8auK1ExfvwTYvM6Zc1uPe"); +const USER_USDC: Pubkey = Pubkey::from_str_const("FMc3ZxJSYyT9JuJ21iDzQs5P2wRpS6JSwvT6ZRkYv6J9"); +const CLOCK: Pubkey = Pubkey::from_str_const("SysvarC1ock11111111111111111111111111111111"); +const TOKEN: Pubkey = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); +const SYS0: Pubkey = Pubkey::from_str_const("8xeaWCsJYxRoudEZGJWURdfrtFhLYZz9b4iHJnW5tb3d"); +const WSOL_MINT: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); +const USDC_MINT: Pubkey = Pubkey::from_str_const("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); +const AUX12: Pubkey = Pubkey::from_str_const("8vqruQc1wB3YQpaP4fr1woJGULBQG3c7uj8A8nnSWo9"); +const VOTE: Pubkey = Pubkey::from_str_const("J1to1yufRnoWn81KYg1XkTWzmKjnYSnmE2VY8DGUJ9Qv"); +const ROUTE_STATE: Pubkey = Pubkey::from_str_const("EXNBiVYTJErnLRaz9hae8P4nePswG21qZJZX9wJLUDnY"); +const AUX15: Pubkey = Pubkey::from_str_const("7Qca6CS6sExGXKh3UmJ5fpapc3YFwScVpjfhmi4ScWUM"); +const AUX16: Pubkey = Pubkey::from_str_const("6iL7bcqz6tmLo821xcvgDDSvrPh7knjoMQpyZEuSxML3"); +const AUX17: Pubkey = Pubkey::from_str_const("1kUMdzAeH1uEdNch7ZJrtfvSo351p3b2DJ6qf5TUWhC"); +const LIVE_KEYS: [Pubkey; 7] = [ + SOL_USDC_MARKET, + BASE_VAULT, + QUOTE_VAULT, + WSOL_MINT, + USDC_MINT, + VOTE, + ROUTE_STATE, +]; + +const INPUT_QUOTE_AMOUNT: u64 = 2_427_890; +/// The captured swap instruction data (obfuscated; it encodes a fixed input of 2,427,890 USDC atoms). +const SWAP_DATA: [u8; 25] = [ + 0x6f, 0x86, 0xa3, 0x50, 0xa2, 0x1a, 0xc2, 0xb9, 0xc9, 0xf4, 0x0b, 0xff, 0xe3, 0xba, 0xea, 0xc3, + 0x39, 0xff, 0x2d, 0xff, 0xe0, 0xba, 0xe9, 0xc3, 0x09, +]; + +/// The captured DFlow route data, so the Instructions sysvar shows a faithful router instruction. +const DFLOW_DATA_HEX: &str = "f8c69e91e17587c80600000025af1df3ba698aa144c79fb835e10649c7698eed21f706d1d24eb9a14f7dd0ce4c3fcd6752db35e4c5ec7f9c670b824cf28236bd4ab7af472aa63da2a26212570664597a1a000024bc9a000000323c31020000006a9991000000000006835faa1a00000000091f020000000381878ec3c80af75c0355798caf40a0297af20b25000000000001118e5964010000000000feb4a000000000003c020000"; + +fn dflow_data() -> Vec { + (0..DFLOW_DATA_HEX.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&DFLOW_DATA_HEX[i..i + 2], 16).unwrap()) + .collect() +} + +/// (pubkey, writable, signer) for each of the eighteen CPI accounts. DFlow signs for two system-owned +/// authority accounts (SIGNER0 and SYS0) as PDAs; sigverify is off, so the replay marks them signers. +fn cpi_layout() -> [(Pubkey, bool, bool); 18] { + [ + (SIGNER0, true, true), + (SOL_USDC_MARKET, true, false), + (BASE_VAULT, true, false), + (QUOTE_VAULT, true, false), + (DEST_WSOL, true, false), + (USER_USDC, true, false), + (CLOCK, false, false), + (TOKEN, false, false), + (TOKEN, false, false), + (SYS0, false, true), + (WSOL_MINT, false, false), + (USDC_MINT, false, false), + (AUX12, false, false), + (VOTE, false, false), + (ROUTE_STATE, false, false), + (AUX15, false, false), + (AUX16, false, false), + (AUX17, false, false), + ] +} + +fn humidifi_metas() -> Vec { + cpi_layout() + .into_iter() + .map(|(key, writable, signer)| { + if writable { + AccountMeta::new(key, signer) + } else { + AccountMeta::new_readonly(key, signer) + } + }) + .collect() +} + +// The builtin stands in for DFlow: it re-emits the captured HumidiFi CPI verbatim. Registered under +// the router id so HumidiFi's Instructions-sysvar caller check sees a router instruction. +declare_process_instruction!(HumidiFiRouteShim, 1, |invoke_context| { + invoke_context.native_invoke_signed( + Instruction { + program_id: HUMIDIFI_PROGRAM, + accounts: humidifi_metas(), + data: SWAP_DATA.to_vec(), + }, + &[], + )?; + Ok(()) +}); + +fn token_account(mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + let mut data = vec![0u8; spl_token_interface::state::Account::LEN]; + spl_token_interface::state::Account { + mint, + owner, + amount, + state: spl_token_interface::state::AccountState::Initialized, + ..Default::default() + } + .pack_into_slice(&mut data); + Account { + lamports: 2_039_280, + data, + owner: TOKEN, + executable: false, + rent_epoch: 0, + } +} + +fn system_account(lamports: u64) -> Account { + Account { + lamports, + data: vec![], + owner: solana_pubkey::Pubkey::from_str_const("11111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + } +} + +/// Runs the captured swap against a market whose data has been `mutate`d, returning the WSOL +/// the user received (the increase of the destination account). +fn run_swap( + programdata: &Account, + live: &[(Pubkey, Account)], + clock_slot: Option, + mutate: impl FnOnce(&mut Vec), +) -> Result { + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(HUMIDIFI_PROGRAM, &programdata.data[45..]) + .map_err(|e| format!("load HumidiFi ELF: {e:?}"))?; + svm.add_builtin(DFLOW, HumidiFiRouteShim::register); + + let mut market = live + .iter() + .find(|(key, _)| *key == SOL_USDC_MARKET) + .map(|(_, account)| account.clone()) + .ok_or("market not in the live set")?; + mutate(&mut market.data); + for (key, account) in live { + let account = if *key == SOL_USDC_MARKET { + market.clone() + } else { + account.clone() + }; + svm.set_account(*key, account) + .map_err(|e| format!("seed {key}: {e:?}"))?; + } + + // An implicit clock follows the mutated quote; staleness proofs must supply a fixed clock. + let market_slot = decode_u64(&market.data, LAST_UPDATE_SLOT_OFFSET, STATE_KEY); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = clock_slot.unwrap_or(market_slot); + clock.unix_timestamp = 1_788_000_000; + svm.set_sysvar(&clock); + + // The user pays USDC and receives WSOL; synthesize both token accounts plus the plain signers. + svm.set_account(SIGNER0, system_account(1_000_000_000)) + .unwrap(); + svm.set_account(SYS0, system_account(1_244_010)).unwrap(); + for aux in [AUX12, AUX15, AUX16, AUX17] { + svm.set_account(aux, system_account(1_000_000)).unwrap(); + } + svm.set_account(USER_USDC, token_account(USDC_MINT, SIGNER0, 1_000_000_000)) + .unwrap(); + svm.set_account(DEST_WSOL, token_account(WSOL_MINT, SIGNER0, 0)) + .unwrap(); + + let mut accounts = vec![AccountMeta::new_readonly(HUMIDIFI_PROGRAM, false)]; + accounts.extend(humidifi_metas()); + let shim_ix = Instruction { + program_id: DFLOW, + accounts, + data: dflow_data(), + }; + let compute = |tag: u8, value: &[u8]| Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: [vec![tag], value.to_vec()].concat(), + }; + let message = Message::new_with_blockhash( + &[compute(2, &1_400_000u32.to_le_bytes()), shim_ix], + Some(&SIGNER0), + &svm.latest_blockhash(), + ); + let tx = Transaction { + signatures: vec![Signature::default(); message.header.num_required_signatures as usize], + message, + }; + svm.send_transaction(tx) + .map_err(|e| format!("{:?}\n{}", e.err, e.meta.logs.join("\n")))?; + + let source = svm.get_account(&USER_USDC).unwrap(); + assert_eq!( + 1_000_000_000 - token_amount(&source), + INPUT_QUOTE_AMOUNT, + "the captured instruction's quote input changed" + ); + let dest = svm.get_account(&DEST_WSOL).unwrap(); + let amount = u64::from_le_bytes(dest.data[64..72].try_into().unwrap()); + Ok(amount) +} + +#[tokio::test] +async fn humidifi_fair_value_moves_the_swap_output() { + let fetched = live::fetch(&LIVE_KEYS).await; + let programdata = live::fetch(&[HUMIDIFI_PROGRAMDATA]).await.remove(0); + let live: Vec<(Pubkey, Account)> = LIVE_KEYS.into_iter().zip(fetched).collect(); + + let market = &live.iter().find(|(k, _)| *k == SOL_USDC_MARKET).unwrap().1; + let fair_value = decode_u64(&market.data, FAIR_VALUE_OFFSET, FAIR_VALUE_KEY); + assert!(fair_value > 0, "the live market must carry a fair value"); + + let set_fair_value = |value: u64| { + move |data: &mut Vec| { + *data = template_raw_apply( + "humidifi-fair-value", + std::collections::HashMap::from([( + "fair_value".to_string(), + serde_json::json!(value.to_string()), + )]), + 0, + data, + ); + } + }; + + let baseline = run_swap(&programdata, &live, None, |_| {}) + .unwrap_or_else(|e| panic!("baseline swap did not execute:\n{e}")); + let no_op = + run_swap(&programdata, &live, None, set_fair_value(fair_value)).expect("re-encode current"); + let cheaper = run_swap(&programdata, &live, None, set_fair_value(fair_value / 2)) + .expect("halve fair value"); + let dearer = run_swap(&programdata, &live, None, set_fair_value(fair_value * 2)); + + eprintln!( + "HumidiFi swap output WSOL: baseline={baseline} no_op={no_op} half_price={cheaper} double_price={dearer:?}" + ); + assert!(baseline > 0, "the captured swap must return WSOL"); + assert_eq!( + no_op, baseline, + "re-encoding the same value must not change the output" + ); + assert!( + cheaper > baseline, + "halving the price (base cheaper) must return MORE base for the same quote input" + ); + // Doubling the price makes the base twice as dear; the fixed quote input buys less, and the swap + // either returns less or trips its own minimum-output guard. Either way the fair value gated it. + match dearer { + Ok(dearer) => assert!( + dearer < baseline, + "doubling the price must return less base" + ), + Err(error) => assert!( + error.contains("Custom") || error.contains("insufficient") || error.contains("0x"), + "doubling the price should reduce output or revert on min-out, got: {error}" + ), + } +} + +#[tokio::test] +async fn humidifi_builder_price_matches_the_executed_exchange_rate() { + let fetched = live::fetch(&LIVE_KEYS).await; + let programdata = live::fetch(&[HUMIDIFI_PROGRAMDATA]).await.remove(0); + let live: Vec<(Pubkey, Account)> = LIVE_KEYS.into_iter().zip(fetched).collect(); + let account = |address| &live.iter().find(|(key, _)| *key == address).unwrap().1; + let market = HumidiFiMarket::validate( + SOL_USDC_MARKET, + account(SOL_USDC_MARKET), + account(WSOL_MINT), + account(USDC_MINT), + ) + .expect("valid live market"); + let slot = decode_u64( + &account(SOL_USDC_MARKET).data, + LAST_UPDATE_SLOT_OFFSET, + STATE_KEY, + ) + 100; + let remote = Some((live::client(), CommitmentConfig::confirmed())); + + for price in [100u64, 208] { + let preparation = build_humidifi_fair_value_scenario(&market, &price.to_string()) + .expect("build human-price scenario"); + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + assert!(svm.inner.get_account(&SOL_USDC_MARKET).unwrap().is_none()); + svm.register_scenario(preparation.scenario, Some(slot)) + .expect("register price scenario"); + svm.materialize_overrides_for_slot(&remote, slot) + .await + .expect("materialize price scenario"); + let mut prepared = live.clone(); + prepared + .iter_mut() + .find(|(key, _)| *key == SOL_USDC_MARKET) + .unwrap() + .1 = svm.inner.get_account(&SOL_USDC_MARKET).unwrap().unwrap(); + let output = run_swap(&programdata, &prepared, Some(slot), |_| {}) + .expect("builder-priced swap must fill"); + let expected = u64::try_from( + u128::from(INPUT_QUOTE_AMOUNT) * 10u128.pow(u32::from(market.base_decimals)) + / (u128::from(price) * 10u128.pow(u32::from(market.quote_decimals))), + ) + .unwrap(); + assert!( + output.abs_diff(expected) <= expected / 100, + "price {price}: output {output} differs from independently priced output {expected} by more than 1%" + ); + eprintln!("HumidiFi builder price={price}: WSOL output={output}, expected={expected}"); + } +} + +#[tokio::test] +async fn humidifi_stale_quote_lands_the_rejection_boundary() { + let fetched = live::fetch(&LIVE_KEYS).await; + let programdata = live::fetch(&[HUMIDIFI_PROGRAMDATA]).await.remove(0); + let live: Vec<(Pubkey, Account)> = LIVE_KEYS.into_iter().zip(fetched).collect(); + let account = |address| &live.iter().find(|(key, _)| *key == address).unwrap().1; + let market_account = account(SOL_USDC_MARKET); + let market = HumidiFiMarket::validate( + SOL_USDC_MARKET, + market_account, + account(WSOL_MINT), + account(USDC_MINT), + ) + .expect("valid live market"); + let limit = decode_u64(&market_account.data, MAX_STALENESS_OFFSET, STATE_KEY); + assert_eq!(limit, market.max_staleness_slots); + assert_eq!( + limit, 2, + "the tested SOL/USDC market's staleness limit changed" + ); + let clock = decode_u64(&market_account.data, LAST_UPDATE_SLOT_OFFSET, STATE_KEY) + 100; + + let fresh = template_raw_apply( + "humidifi-freshness", + std::collections::HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )]), + clock, + &market_account.data, + ); + assert_eq!( + decode_u64(&fresh, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), + clock + ); + let control = run_swap(&programdata, &live, Some(clock), |data| *data = fresh) + .expect("fresh quote must fill at the fixed clock"); + assert!(control > 0); + + for configured_limit in [limit, 10] { + let mut configured = market_account.data.clone(); + encode_masked_u64( + &mut configured, + MAX_STALENESS_OFFSET, + STATE_KEY, + configured_limit, + ); + for age in [configured_limit + 1, configured_limit] { + let staged = template_raw_apply( + "humidifi-stale-quote", + std::collections::HashMap::from([( + "last_update_slot".to_string(), + serde_json::json!(-i64::try_from(age).unwrap()), + )]), + clock, + &configured, + ); + assert_eq!( + decode_u64(&staged, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), + clock - age, + ); + assert_only_within( + &live::diff_indices(&market_account.data, &staged), + &[ + MAX_STALENESS_OFFSET..MAX_STALENESS_OFFSET + 8, + LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, + ], + "staleness boundary", + ); + let result = run_swap(&programdata, &live, Some(clock), |data| *data = staged); + if age > configured_limit { + let error = + result.expect_err("a quote older than the configured limit must reject"); + assert!( + error.contains("Custom(1027565)"), + "unexpected error: {error}" + ); + eprintln!("HumidiFi staleness limit={configured_limit} age={age}: {error}"); + } else { + let output = result.expect("a quote at the configured maximum age must fill"); + assert!(output > 0); + eprintln!( + "HumidiFi staleness limit={configured_limit} age={age}: WSOL output={output}" + ); + } + } + + if configured_limit == 10 { + let default_age = template_raw_apply( + "humidifi-stale-quote", + std::collections::HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )]), + clock, + &configured, + ); + assert_eq!( + decode_u64(&default_age, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), + clock - 3, + ); + let output = run_swap(&programdata, &live, Some(clock), |data| *data = default_age) + .expect( + "the template's default age three must fill when the configured limit is ten", + ); + assert!(output > 0); + eprintln!("HumidiFi staleness limit=10 default age=3: WSOL output={output}"); + } + } +} + +/// Builds a liquidity scenario against the live accounts, materializes it through the production +/// path in a fresh Surfnet, and returns the live set with the prepared market and vaults swapped in. +async fn prepared_liquidity( + live: &[(Pubkey, Account)], + market: &HumidiFiMarket, + base_remaining_bps: u16, + quote_remaining_bps: u16, +) -> Vec<(Pubkey, Account)> { + let account = |address| &live.iter().find(|(key, _)| *key == address).unwrap().1; + let base_slot = decode_u64( + &account(SOL_USDC_MARKET).data, + LAST_UPDATE_SLOT_OFFSET, + STATE_KEY, + ) + 100; + let scenario = build_humidifi_liquidity_scenario( + market, + account(SOL_USDC_MARKET), + account(BASE_VAULT), + account(QUOTE_VAULT), + base_remaining_bps, + quote_remaining_bps, + ) + .expect("build liquidity scenario"); + + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + for key in [ + SOL_USDC_MARKET, + BASE_VAULT, + QUOTE_VAULT, + WSOL_MINT, + USDC_MINT, + ] { + svm.inner + .set_account(key, account(key).clone()) + .expect("seed account"); + } + svm.register_scenario(scenario, Some(base_slot)) + .expect("register liquidity scenario"); + svm.materialize_overrides_for_slot(&None, base_slot) + .await + .expect("materialize liquidity scenario"); + + live.iter() + .map(|(key, original)| { + let prepared = if [SOL_USDC_MARKET, BASE_VAULT, QUOTE_VAULT].contains(key) { + svm.inner.get_account(key).unwrap().unwrap() + } else { + original.clone() + }; + assert_eq!( + prepared.data.len(), + original.data.len(), + "{key}: account length changed" + ); + assert_eq!( + prepared.owner, original.owner, + "{key}: account owner changed" + ); + assert_eq!( + prepared.lamports, original.lamports, + "{key}: lamports changed" + ); + (*key, prepared) + }) + .collect() +} + +fn token_amount(account: &Account) -> u64 { + u64::from_le_bytes(account.data[64..72].try_into().unwrap()) +} + +#[tokio::test] +async fn humidifi_liquidity_builder_exhausts_only_the_selected_side() { + let fetched = live::fetch(&LIVE_KEYS).await; + let programdata = live::fetch(&[HUMIDIFI_PROGRAMDATA]).await.remove(0); + let live: Vec<(Pubkey, Account)> = LIVE_KEYS.into_iter().zip(fetched).collect(); + let account = |set: &[(Pubkey, Account)], address| { + set.iter() + .find(|(key, _)| *key == address) + .unwrap() + .1 + .clone() + }; + let market = HumidiFiMarket::validate( + SOL_USDC_MARKET, + &account(&live, SOL_USDC_MARKET), + &account(&live, WSOL_MINT), + &account(&live, USDC_MINT), + ) + .expect("valid live market"); + assert_eq!( + humidifi_vault_addresses(&account(&live, SOL_USDC_MARKET)).unwrap(), + [BASE_VAULT, QUOTE_VAULT] + ); + let live_base = token_amount(&account(&live, BASE_VAULT)); + let baseline = run_swap(&programdata, &live, None, |_| {}).expect("baseline swap"); + assert!(baseline > 0); + + // Draining the base vault leaves the swap nothing to pay out: the token transfer fails. + let drained = prepared_liquidity(&live, &market, 0, 10_000).await; + let base_vault = account(&drained, BASE_VAULT); + assert_eq!(token_amount(&base_vault), 0); + assert_within( + &live::diff_indices(&account(&live, BASE_VAULT).data, &base_vault.data), + 64..72, + "drained base vault", + ); + assert_eq!(base_vault.lamports, account(&live, BASE_VAULT).lamports); + assert_eq!(account(&drained, QUOTE_VAULT), account(&live, QUOTE_VAULT)); + assert_within( + &live::diff_indices( + &account(&live, SOL_USDC_MARKET).data, + &account(&drained, SOL_USDC_MARKET).data, + ), + LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, + "liquidity scenario market", + ); + let error = run_swap(&programdata, &drained, None, |_| {}) + .expect_err("a drained base vault must fail the swap"); + assert!( + error.contains("insufficient funds"), + "unexpected error: {error}" + ); + + // A sliver of base inventory pushes the maker into its skew regime: it still fills, but at a + // fraction of the fair-value output. + let thin = prepared_liquidity(&live, &market, 50, 10_000).await; + assert_eq!( + token_amount(&account(&thin, BASE_VAULT)), + live_base / 200, + "0.50% of the live base balance, floored" + ); + assert_eq!(account(&thin, QUOTE_VAULT), account(&live, QUOTE_VAULT)); + let thin_output = run_swap(&programdata, &thin, None, |_| {}).expect("thin base vault fills"); + eprintln!("HumidiFi liquidity: baseline={baseline} base at 0.50%={thin_output}"); + assert!( + thin_output * 100 < baseline, + "a thin base vault must collapse the output, got {thin_output} against {baseline}" + ); + + // The quote vault only receives this direction's input, so draining it changes nothing. + let quote_drained = prepared_liquidity(&live, &market, 10_000, 0).await; + assert_eq!(token_amount(&account("e_drained, QUOTE_VAULT)), 0); + assert_eq!( + account("e_drained, BASE_VAULT), + account(&live, BASE_VAULT) + ); + let unchanged = + run_swap(&programdata, "e_drained, None, |_| {}).expect("drained quote vault fills"); + assert_eq!(unchanged, baseline); +} 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 b2dd37925..036ffd945 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,7 +1,11 @@ pub mod helpers; +#[cfg(feature = "integration-tests")] +pub mod humidifi; 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/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 7ce4c3e08..074800f5a 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -19,6 +19,10 @@ use start_surfnet::StartSurfnetResponse; use surfpool_core::{ scenarios::{ TemplateRegistry, + protocols::humidifi::v1::{ + HumidiFiMarket, build_humidifi_fair_value_scenario, build_humidifi_liquidity_scenario, + discover_humidifi_markets, humidifi_vault_addresses, + }, protocols::pump::v1::graduation_builder::{ build_pump_graduation_scenario, pump_graduation_addresses, }, @@ -130,6 +134,44 @@ pub struct CreatePumpGraduationScenarioParams { pub surfnet_port: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListHumidiFiMarketsParams { + #[schemars(description = "The target local Surfnet RPC port. Omit to use 8899.")] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateHumidiFiFairValueScenarioParams { + #[schemars( + description = "Required HumidiFi market account address. Select one returned by list_humidifi_markets; there is no default market." + )] + pub market: String, + #[schemars( + description = "The price of one base token in quote tokens, as a positive decimal string such as \"175.5\". Not atomic units: the builder derives the 2^48 scale from the market's mint decimals." + )] + pub price: String, + #[schemars(description = "The target local Surfnet RPC port. Omit to use 8899.")] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateHumidiFiLiquidityScenarioParams { + #[schemars( + description = "Required HumidiFi market account address. Select one returned by list_humidifi_markets; there is no default market." + )] + pub market: String, + #[schemars( + description = "Remaining base vault balance in basis points, 0..10000. 10000 leaves the base side unchanged, 0 drains it." + )] + pub base_remaining_bps: u16, + #[schemars( + description = "Remaining quote vault balance in basis points, 0..10000. 10000 leaves the quote side unchanged, 0 drains it." + )] + pub quote_remaining_bps: u16, + #[schemars(description = "The target local Surfnet RPC port. Omit to use 8899.")] + pub surfnet_port: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct StartSurfnetWithTokenAccountsParams { #[schemars( @@ -332,6 +374,16 @@ impl RegisterScenarioResponse { } } +fn humidifi_market_address(market: &str) -> Result { + let address = market.trim(); + if address.is_empty() { + return Err( + "HumidiFi market is required; select an address from list_humidifi_markets".to_string(), + ); + } + Pubkey::from_str(address).map_err(|error| format!("Invalid HumidiFi market pubkey: {error}")) +} + fn scenario_tool_error(message: String) -> CallToolResult { let response = RegisterScenarioResponse::error(message); CallToolResult::success(vec![Content::text( @@ -1001,6 +1053,182 @@ impl Surfpool { self.stage_scenario(preparation.scenario).await } + #[tool( + description = "Lists HumidiFi markets discovered from program accounts on the target Surfnet. Returns market addresses, pair labels, base/quote mints and decimals, and each market's staleness limit. Use addresses to create scenarios; labels are display names and unknown symbols use mint addresses." + )] + async fn list_humidifi_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_humidifi_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(), + "label": market.label(), + "baseMint": market.base_mint.to_string(), + "quoteMint": market.quote_mint.to_string(), + "baseDecimals": market.base_decimals, + "quoteDecimals": market.quote_decimals, + "maxStalenessSlots": market.max_staleness_slots, + }) + }) + .collect::>(); + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ "count": markets.len(), "markets": markets }).to_string(), + )])) + } + + #[tool( + description = "Creates one editable HumidiFi fair-value scenario for a live market. Reads the market and both mint accounts from the running surfnet to derive the 2^48-scaled quote-per-base ratio from their decimals. On Play, fetchBeforeUse refreshes the market from the Surfnet datasource before applying the requested price; a second override keeps the quote fresh without fetching it again. Prepares state; sends no swap. Resolve `market` through list_humidifi_markets." + )] + async fn create_humidifi_fair_value_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market_address = match humidifi_market_address(¶ms.market) { + Ok(market) => market, + Err(error) => return Ok(scenario_tool_error(error)), + }; + + let accounts = match self + .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let Some(market_account) = accounts[0].as_ref() else { + return Ok(scenario_tool_error(format!( + "HumidiFi market account {market_address} was not found on the surfnet" + ))); + }; + let (base_mint, quote_mint) = match HumidiFiMarket::mint_addresses(market_account) { + Ok(mints) => mints, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + let mints = match self + .fetch_surfnet_accounts(params.surfnet_port, &[base_mint, quote_mint]) + .await + { + Ok(mints) => mints, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let (Some(base_account), Some(quote_account)) = (mints[0].as_ref(), mints[1].as_ref()) + else { + return Ok(scenario_tool_error(format!( + "HumidiFi market {market_address} references a mint that was not found on the surfnet" + ))); + }; + + let market = match HumidiFiMarket::validate( + market_address, + market_account, + base_account, + quote_account, + ) { + Ok(market) => market, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let preparation = match build_humidifi_fair_value_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 HumidiFi liquidity-stress scenario from the selected market's current Surfnet state. Scales the market's base and quote vault balances to the remaining basis points with exact integer arithmetic (10000 leaves a side unchanged, 0 drains it), preserves the fair-value field and keeps the quote fresh. Execution and inventory effects depend on the market and swap direction. Prepares state; sends no swap. Resolve `market` through list_humidifi_markets." + )] + async fn create_humidifi_liquidity_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market_address = match humidifi_market_address(¶ms.market) { + Ok(market) => market, + Err(error) => return Ok(scenario_tool_error(error)), + }; + + let accounts = match self + .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let Some(market_account) = accounts[0].as_ref() else { + return Ok(scenario_tool_error(format!( + "HumidiFi market account {market_address} was not found on the surfnet" + ))); + }; + let (base_mint, quote_mint) = match HumidiFiMarket::mint_addresses(market_account) { + Ok(mints) => mints, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let [base_vault, quote_vault] = match humidifi_vault_addresses(market_account) { + Ok(vaults) => vaults, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + let graph = match self + .fetch_surfnet_accounts( + params.surfnet_port, + &[base_mint, quote_mint, base_vault, quote_vault], + ) + .await + { + Ok(graph) => graph, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let ( + Some(base_mint_account), + Some(quote_mint_account), + Some(base_vault_account), + Some(quote_vault_account), + ) = ( + graph[0].as_ref(), + graph[1].as_ref(), + graph[2].as_ref(), + graph[3].as_ref(), + ) + else { + return Ok(scenario_tool_error(format!( + "HumidiFi market {market_address} references a mint or vault that was not found on the surfnet" + ))); + }; + + let market = match HumidiFiMarket::validate( + market_address, + market_account, + base_mint_account, + quote_mint_account, + ) { + Ok(market) => market, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let scenario = match build_humidifi_liquidity_scenario( + &market, + market_account, + base_vault_account, + quote_vault_account, + params.base_remaining_bps, + params.quote_remaining_bps, + ) { + Ok(scenario) => scenario, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(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 +1543,86 @@ mod tests { }) } + #[tokio::test] + async fn humidifi_fair_value_rejects_a_bad_market_before_any_rpc() { + let result = Surfpool::new() + .create_humidifi_fair_value_scenario(Parameters( + CreateHumidiFiFairValueScenarioParams { + market: "not-a-pubkey".to_string(), + price: "100.25".to_string(), + surfnet_port: None, + }, + )) + .await + .expect("tool result"); + assert!( + json_of(&result)["error"] + .as_str() + .expect("error payload") + .contains("Invalid HumidiFi market pubkey") + ); + } + + #[test] + fn humidifi_tools_require_an_explicit_market() { + let fair_value = serde_json::json!({ "price": "100" }); + assert!( + serde_json::from_value::(fair_value).is_err() + ); + let liquidity = serde_json::json!({ + "base_remaining_bps": 500, + "quote_remaining_bps": 10000, + }); + assert!( + serde_json::from_value::(liquidity).is_err() + ); + + for schema in [ + schemars::schema_for!(CreateHumidiFiFairValueScenarioParams), + schemars::schema_for!(CreateHumidiFiLiquidityScenarioParams), + ] { + let schema = serde_json::to_value(schema).unwrap(); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&serde_json::json!("market")) + ); + } + + for market in ["", " "] { + assert!( + humidifi_market_address(market) + .unwrap_err() + .contains("market is required") + ); + } + let market = Pubkey::new_unique(); + assert_eq!( + humidifi_market_address(&format!(" {market} ")).unwrap(), + market + ); + } + + #[tokio::test] + async fn humidifi_liquidity_rejects_a_bad_market_before_any_rpc() { + let result = Surfpool::new() + .create_humidifi_liquidity_scenario(Parameters(CreateHumidiFiLiquidityScenarioParams { + market: "not-a-pubkey".to_string(), + base_remaining_bps: 500, + quote_remaining_bps: 10_000, + surfnet_port: None, + })) + .await + .expect("tool result"); + assert!( + json_of(&result)["error"] + .as_str() + .expect("error payload") + .contains("Invalid HumidiFi market pubkey") + ); + } + #[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 fab64d27b..85820cfad 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -283,6 +283,9 @@ pub struct Property { /// Raw-layout only: how this field's bytes are produced. #[serde(default, skip_serializing_if = "Option::is_none")] pub encoding: Option, + /// XOR key applied before writing; only 8-byte raw encodings can be masked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xor_mask: Option, } impl Property { @@ -296,6 +299,7 @@ impl Property { constant: None, offset: None, encoding: None, + xor_mask: None, } } @@ -309,6 +313,7 @@ impl Property { constant: Some(constant.into()), offset: None, encoding: None, + xor_mask: None, } } @@ -890,6 +895,9 @@ pub enum YamlProperty { /// Raw-layout only: how this field's bytes are produced #[serde(default)] encoding: Option, + /// Raw-layout only: XOR key applied to the encoded word before writing + #[serde(default)] + xor_mask: Option, }, } @@ -905,6 +913,7 @@ impl From for Property { constant, offset, encoding, + xor_mask, } => { let kind = match kind.as_deref() { Some("constant_ref") => PropertyKind::ConstantRef, @@ -918,6 +927,7 @@ impl From for Property { constant, offset, encoding, + xor_mask, } } } @@ -1270,7 +1280,19 @@ impl RawLayout { else { return Err(format!("property '{name}' has no offset or encoding")); }; - let bytes = encoding.encode(value, target_slot)?; + let mut bytes = encoding.encode(value, target_slot)?; + if let Some(mask) = property.xor_mask { + if bytes.len() != 8 { + return Err(format!( + "property '{name}' declares an xor_mask but its encoding is {} bytes; only \ + 8-byte encodings can be masked", + bytes.len() + )); + } + for (byte, key) in bytes.iter_mut().zip(mask.to_le_bytes()) { + *byte ^= key; + } + } let (count, stride) = encoding.placements(); for i in 0..count { let at = offset @@ -1675,6 +1697,128 @@ mod tests { assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); } + #[test] + fn xor_mask_stores_the_masked_word_and_reads_back_plaintext() { + use super::{Property, RawEncoding, RawLayout}; + + // A program that keeps every word as `plaintext XOR key`. The template value stays + // plaintext; the engine masks it on write, so the account holds value ^ key. + let key: u64 = 0xb957_ed15_dc87_7426; + let layout = RawLayout { + account_size: 16, + magic: None, + }; + let mut property = Property::field("fair_value".to_string()); + property.offset = Some(8); + property.encoding = Some(RawEncoding::U64); + property.xor_mask = Some(key); + + let plaintext: u64 = 29_278_243_997_902; + let out = layout + .materialize( + &[0u8; 16], + &[property], + &HashMap::from([("fair_value".to_string(), json!(plaintext.to_string()))]), + 0, + ) + .expect("masked write"); + + let stored = u64::from_le_bytes(out[8..16].try_into().unwrap()); + assert_eq!( + stored, + plaintext ^ key, + "the account must hold the masked word" + ); + assert_eq!( + stored ^ key, + plaintext, + "unmasking reproduces the plaintext" + ); + } + + #[test] + fn xor_mask_masks_a_materialization_slot() { + use super::{Property, RawEncoding, RawLayout}; + + let key: u64 = 0x6e9d_e2b3_0b19_f1ea; + let layout = RawLayout { + account_size: 8, + magic: None, + }; + let mut property = Property::field("last_update_slot".to_string()); + property.offset = Some(0); + property.encoding = Some(RawEncoding::Slot { lead: 0 }); + property.xor_mask = Some(key); + + let out = layout + .materialize( + &[0u8; 8], + &[property.clone()], + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + 444_223_940, + ) + .expect("masked slot write"); + assert_eq!( + u64::from_le_bytes(out[0..8].try_into().unwrap()) ^ key, + 444_223_940, + "the slot must round-trip through the mask" + ); + + // A negative lead ages the quote, still masked. + property.encoding = Some(RawEncoding::Slot { lead: -3 }); + let out = layout + .materialize( + &[0u8; 8], + &[property], + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + 444_223_940, + ) + .expect("masked stale slot"); + assert_eq!( + u64::from_le_bytes(out[0..8].try_into().unwrap()) ^ key, + 444_223_937 + ); + } + + #[test] + fn xor_mask_leaves_unmasked_properties_untouched_and_rejects_narrow_encodings() { + use super::{Property, RawEncoding, RawLayout}; + + let layout = RawLayout { + account_size: 16, + magic: None, + }; + + // No mask: bytes are written verbatim, exactly as before this feature existed. + let mut plain = Property::field("value".to_string()); + plain.offset = Some(0); + plain.encoding = Some(RawEncoding::U64); + let out = layout + .materialize( + &[0u8; 16], + &[plain], + &HashMap::from([("value".to_string(), json!(7u64.to_string()))]), + 0, + ) + .expect("plain write"); + assert_eq!(u64::from_le_bytes(out[0..8].try_into().unwrap()), 7); + + // A mask on a non-8-byte encoding is a template error, not a silent half-write. + let mut narrow = Property::field("small".to_string()); + narrow.offset = Some(0); + narrow.encoding = Some(RawEncoding::U32); + narrow.xor_mask = Some(0xdead_beef); + let err = layout + .materialize( + &[0u8; 16], + &[narrow], + &HashMap::from([("small".to_string(), json!(1))]), + 0, + ) + .expect_err("a mask on a 4-byte encoding must be refused"); + assert!(err.contains("8-byte"), "unexpected error: {err}"); + } + #[test] fn raw_layout_rejects_writes_past_the_end_of_the_account() { use super::{Property, RawEncoding, RawLayout}; From 79e1e9507b7ccdca24cb749215b3fe7e83b5706f Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Mon, 14 Sep 2026 15:02:57 +0300 Subject: [PATCH 2/5] fix(humidifi): preserve prepared state in scenario builders --- crates/cli/src/http/mod.rs | 2 +- .../scenarios/protocols/humidifi/README.md | 14 +++--- .../protocols/humidifi/v1/fair_value.rs | 8 ++-- .../protocols/humidifi/v1/overrides.yaml | 14 +++--- crates/core/src/tests/humidifi/mod.rs | 43 ++++++++++++++++--- crates/mcp/src/surfpool/mod.rs | 2 +- 6 files changed, 56 insertions(+), 27 deletions(-) diff --git a/crates/cli/src/http/mod.rs b/crates/cli/src/http/mod.rs index cbcec12fa..dcf773fd1 100644 --- a/crates/cli/src/http/mod.rs +++ b/crates/cli/src/http/mod.rs @@ -465,7 +465,7 @@ mod tests { let overrides = stored[0]["overrides"].as_array().unwrap(); assert_eq!(overrides[0]["templateId"], "humidifi-fair-value"); assert_eq!(overrides[0]["values"]["fair_value"], "58546795155816"); - assert_eq!(overrides[0]["fetchBeforeUse"], true); + assert_eq!(overrides[0]["fetchBeforeUse"], false); assert_eq!(overrides[1]["templateId"], "humidifi-freshness"); assert!(overrides[1]["values"]["last_update_slot"].is_null()); assert_eq!(overrides[1]["fetchBeforeUse"], false); diff --git a/crates/core/src/scenarios/protocols/humidifi/README.md b/crates/core/src/scenarios/protocols/humidifi/README.md index 64cf50d58..0acd53342 100644 --- a/crates/core/src/scenarios/protocols/humidifi/README.md +++ b/crates/core/src/scenarios/protocols/humidifi/README.md @@ -100,13 +100,10 @@ filters the tag at offset 8 and version 8 at offset 1720. Surfnet RPC, where local accounts take precedence and missing accounts fall back to the datasource. It stages the result through the shared scenario path. -The price override sets `fetchBeforeUse: true`: on Play the shared materializer fetches the -market from the Surfnet datasource before applying the requested price. It does not require -the market account read at creation to remain in local state. A successful fetch replaces earlier -local edits to that market; it does not reset its vaults or the rest of the fork. The shared fetch -path is best effort: on a remote failure, an existing local account may still be used. -A second override sets `fetchBeforeUse: false` and persists freshness with a `null` value, -so the encoder uses its zero lead at every materialization slot without fetching over the price. +Both overrides leave `fetchBeforeUse: false`: the creation reads already cached the market in +Surfnet, and Play applies the values to that local state. Refetching would replace earlier local +edits. The second override persists freshness with a `null` value, so the encoder uses its zero +lead at every materialization slot without fetching over the price. `build_humidifi_liquidity_scenario` scales the market's vault balances through the generic `spl-token-account-balance` template, one override per side that changes, from 0 to 10000 remaining @@ -115,6 +112,9 @@ from the market's masked words at offsets 448 (quote) and 480 (base); each vault account for the market's mint on that side, owned by that mint's token program, initialized and controlled by the market. `create_humidifi_liquidity_scenario` reads the market, both mints and both vaults through the Surfnet RPC and stages the result. +Its overrides also leave `fetchBeforeUse: false` to preserve the local state used to calculate +the amounts. When composing templates directly, set `fetchBeforeUse: true` on the first override +for each account that has not already been prepared. `list_humidifi_markets` returns addresses, labels, both mint identities and decimals, and `maxStalenessSlots`. Both creation tools require a non-empty `market` address from this list. diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs b/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs index 63adc409d..88ae4a4ae 100644 --- a/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs +++ b/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs @@ -181,7 +181,8 @@ pub fn build_humidifi_fair_value_scenario( let market_name = market.label(); let target = AccountAddress::Pubkey(market.address.to_string()); - let mut price_override = OverrideInstance::new( + // Refetching on Play would replace the local market used to prepare this scenario. + let price_override = OverrideInstance::new( fair_value_template.id.clone(), PREPARATION_SLOT, target.clone(), @@ -191,7 +192,6 @@ pub fn build_humidifi_fair_value_scenario( serde_json::json!(fair_value.to_string()), )])) .with_label(format!("HumidiFi {market_name} fair value")); - price_override.fetch_before_use = true; // 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 market's @@ -439,12 +439,12 @@ mod tests { } #[test] - fn price_fetches_the_market_before_use_and_freshness_preserves_the_price() { + fn price_and_freshness_preserve_the_prepared_market() { let preparation = build_humidifi_fair_value_scenario(&market(9, 6), "100.25").unwrap(); let [price, freshness] = &preparation.scenario.overrides[..] else { panic!("expected exactly a price and a freshness override"); }; - assert!(price.fetch_before_use); + assert!(!price.fetch_before_use); assert!(!price.persist); assert!(!freshness.fetch_before_use); assert!(freshness.persist); diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml b/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml index ee2cc0a6f..91b5e3d70 100644 --- a/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml @@ -51,10 +51,11 @@ templates: as a decimal STRING, since it can exceed what a JSON number holds exactly. Discover markets with list_humidifi_markets and set the override account to the chosen address. - A market address is required; there is no default. Set fetchBeforeUse: true, as the builder does, - so the market is fetched from the Surfnet datasource before applying the requested price. - This replaces earlier local edits to the market. The following humidifi-freshness override - uses fetchBeforeUse: false to preserve that price, with persist: true to keep it fresh. + A market address is required; there is no default. When composing this template directly, set + fetchBeforeUse: true so the live market is forked before the write. The fair-value builder + leaves it false because creation already read and cached the market through Surfnet. + The following humidifi-freshness override uses fetchBeforeUse: false to preserve that price, + with persist: true to keep it fresh. EXAMPLE - SOL/USDC at 208 quote tokens per base token (9 base decimals, 6 quote): fair_value: "58546795155816" @@ -95,9 +96,8 @@ templates: 1. Set last_update_slot to null (or 0). The venue resumes quoting the price it already holds. 2. Set persist: true, so every slot re-stamps itself and the quote stays live indefinitely. A fixed absolute slot would age by one slot per slot and go stale anyway. - 3. Set fetchBeforeUse: true so the live market is forked first. Use false only for a later - override that builds on state an earlier one prepared in the same scenario, which is why the - fair-value builder pairs this override at false after the price write. + 3. Set fetchBeforeUse: true so the live market is forked first. Use false when the market was + already prepared by a builder or an earlier override, so its local changes are preserved. EXAMPLE - keep the maker quoting for the whole run: last_update_slot: null, persist: true diff --git a/crates/core/src/tests/humidifi/mod.rs b/crates/core/src/tests/humidifi/mod.rs index 6bfbe4be4..27a027047 100644 --- a/crates/core/src/tests/humidifi/mod.rs +++ b/crates/core/src/tests/humidifi/mod.rs @@ -364,11 +364,11 @@ async fn humidifi_fair_value_scale_yields_a_sane_price() { ); } -/// The builder's full production path: read the live market and both mints, build the scenario, then +/// The builder's materialization path: prepare the live market locally, build the scenario, then /// register and materialize it through the real materializer. The fair value lands from the human -/// price and the persisted freshness re-stamps itself on the next slot. +/// price and the persisted freshness re-stamps itself on the next slot without remote replacement. #[tokio::test] -async fn humidifi_builder_scenario_fetches_market_and_keeps_quote_fresh() { +async fn humidifi_builder_scenario_preserves_local_market_and_keeps_quote_fresh() { let market_account = fetch(&[SOL_USDC_MARKET]).await.remove(0); let base_slot = decode_u64(&market_account.data, LAST_UPDATE_SLOT_OFFSET, STATE_KEY) + 100; let (base_mint, quote_mint) = HumidiFiMarket::mint_addresses(&market_account).expect("mints"); @@ -380,9 +380,19 @@ async fn humidifi_builder_scenario_fetches_market_and_keeps_quote_fresh() { let preparation = build_humidifi_fair_value_scenario(&market, "175.5").expect("build scenario"); let expected_fair_value = preparation.fair_value; assert!(preparation.scenario.name.contains(&market.label())); + let [price, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected price and freshness overrides"); + }; + assert!(!price.fetch_before_use); + assert!(!freshness.fetch_before_use); + assert!(freshness.persist); let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - assert!(svm.inner.get_account(&market_key).unwrap().is_none()); + let mut local_account = market_account.clone(); + local_account.lamports = local_account.lamports.checked_add(1).unwrap(); + svm.inner + .set_account(market_key, local_account.clone()) + .expect("seed prepared market"); let remote = Some((live::client(), CommitmentConfig::confirmed())); svm.register_scenario(preparation.scenario, Some(base_slot)) .expect("register scenario"); @@ -392,8 +402,9 @@ async fn humidifi_builder_scenario_fetches_market_and_keeps_quote_fresh() { .await .expect("materialize"); let applied_account = svm.inner.get_account(&market_key).unwrap().unwrap(); - assert_eq!(applied_account.owner, market_account.owner); - assert_eq!(applied_account.data.len(), market_account.data.len()); + assert_eq!(applied_account.owner, local_account.owner); + assert_eq!(applied_account.lamports, local_account.lamports); + assert_eq!(applied_account.data.len(), local_account.data.len()); let applied = &applied_account.data; assert_eq!( decode_u64(applied, FAIR_VALUE_OFFSET, FAIR_VALUE_KEY), @@ -405,7 +416,23 @@ async fn humidifi_builder_scenario_fetches_market_and_keeps_quote_fresh() { base_slot, "freshness must publish the base slot" ); + assert_only_within( + &diff_indices(applied, &local_account.data), + &[ + FAIR_VALUE_OFFSET..FAIR_VALUE_OFFSET + 8, + LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, + ], + "prepared local market", + ); // Next slot: the persisted freshness re-stamps offset 616 to the new slot, and nothing else. + let scheduled = svm + .scheduled_overrides + .get(&(base_slot + 1)) + .expect("read scheduled freshness") + .expect("persisted freshness"); + assert_eq!(scheduled.len(), 1); + assert!(scheduled[0].persist); + assert!(!scheduled[0].fetch_before_use); svm.materialize_overrides_for_slot(&remote, base_slot + 1) .await .expect("materialize next slot"); @@ -738,7 +765,9 @@ async fn humidifi_builder_price_matches_the_executed_exchange_rate() { let preparation = build_humidifi_fair_value_scenario(&market, &price.to_string()) .expect("build human-price scenario"); let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - assert!(svm.inner.get_account(&SOL_USDC_MARKET).unwrap().is_none()); + svm.inner + .set_account(SOL_USDC_MARKET, account(SOL_USDC_MARKET).clone()) + .expect("seed prepared market"); svm.register_scenario(preparation.scenario, Some(slot)) .expect("register price scenario"); svm.materialize_overrides_for_slot(&remote, slot) diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 074800f5a..c203c92a6 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -1086,7 +1086,7 @@ impl Surfpool { } #[tool( - description = "Creates one editable HumidiFi fair-value scenario for a live market. Reads the market and both mint accounts from the running surfnet to derive the 2^48-scaled quote-per-base ratio from their decimals. On Play, fetchBeforeUse refreshes the market from the Surfnet datasource before applying the requested price; a second override keeps the quote fresh without fetching it again. Prepares state; sends no swap. Resolve `market` through list_humidifi_markets." + description = "Creates one editable HumidiFi fair-value scenario for a live market. Reads the market and both mint accounts from the running surfnet to derive the 2^48-scaled quote-per-base ratio from their decimals. These reads cache the accounts in Surfnet. On Play, the scenario applies the requested price and keeps the quote fresh with fetchBeforeUse disabled to preserve local state. Prepares state; sends no swap. Resolve `market` through list_humidifi_markets." )] async fn create_humidifi_fair_value_scenario( &self, From f2a03393889f79106793f9d56dac3f0c7d8bf2ec Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Tue, 15 Sep 2026 07:57:59 +0300 Subject: [PATCH 3/5] fix(mcp): take camelCase tool arguments like the pump tool The Tessera, HumidiFi and GoonFi parameter structs deserialized their fields as snake_case, so a client sending surfnetPort the way the pump, get_template and search_constant_options tools expect it was ignored and the read fell back to port 8899. Scenario tools now share one convention. --- crates/core/src/scenarios/protocols/humidifi/README.md | 2 +- crates/mcp/src/surfpool/mod.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/core/src/scenarios/protocols/humidifi/README.md b/crates/core/src/scenarios/protocols/humidifi/README.md index 0acd53342..18da6e310 100644 --- a/crates/core/src/scenarios/protocols/humidifi/README.md +++ b/crates/core/src/scenarios/protocols/humidifi/README.md @@ -118,7 +118,7 @@ for each account that has not already been prepared. `list_humidifi_markets` returns addresses, labels, both mint identities and decimals, and `maxStalenessSlots`. Both creation tools require a non-empty `market` address from this list. -All tools accept an optional `surfnet_port`, defaulting to 8899. Studio's PMM +All tools accept an optional `surfnetPort`, defaulting to 8899, the same camelCase argument names as the pump tool. Studio's PMM fair-value preset uses the market list and fair-value tools; the stale-quote and liquidity chips request editable state scenarios. diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index c203c92a6..4df8e162c 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -135,12 +135,14 @@ pub struct CreatePumpGraduationScenarioParams { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct ListHumidiFiMarketsParams { #[schemars(description = "The target local Surfnet RPC port. Omit to use 8899.")] pub surfnet_port: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct CreateHumidiFiFairValueScenarioParams { #[schemars( description = "Required HumidiFi market account address. Select one returned by list_humidifi_markets; there is no default market." @@ -155,6 +157,7 @@ pub struct CreateHumidiFiFairValueScenarioParams { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct CreateHumidiFiLiquidityScenarioParams { #[schemars( description = "Required HumidiFi market account address. Select one returned by list_humidifi_markets; there is no default market." @@ -1570,8 +1573,8 @@ mod tests { serde_json::from_value::(fair_value).is_err() ); let liquidity = serde_json::json!({ - "base_remaining_bps": 500, - "quote_remaining_bps": 10000, + "baseRemainingBps": 500, + "quoteRemainingBps": 10000, }); assert!( serde_json::from_value::(liquidity).is_err() From 6773c1bbf0164e65e8be3e39c329d0b1d7a1a25b Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 16 Sep 2026 09:53:23 +0300 Subject: [PATCH 4/5] refactor(humidifi): trim duplicate tests and share the market readers No behavior change. The PR shrinks by about 350 lines: - fair_value and liquidity share one masked-pubkey-pair reader and one persisted freshness override; the second vault owner check was unreachable and is gone - HumidiFiMarket fields are pub(crate) with read-only getters for the MCP crate, so no caller outside core can build one by hand - MCP tools share the market-reading prefix and fail through one Result path; the two staging-order tests become one - unit tests use shared fixtures and tables; the live suite drops the price-band smoke test that the executed exchange-rate test subsumes and keeps the independent XOR key oracles for u64 fields and pubkeys - the cli HTTP test is removed: the etalon protocols add none there - README loses dated narratives and text that repeats the templates - the fair-value tool description says it is not for staleness scenarios, which use the humidifi-stale-quote template - imports follow the nightly rustfmt grouping the CI check enforces --- crates/cli/src/http/mod.rs | 55 --- .../scenarios/protocols/humidifi/README.md | 71 +-- .../protocols/humidifi/v1/fair_value.rs | 285 +++++++----- .../protocols/humidifi/v1/liquidity.rs | 404 +++++++----------- .../protocols/humidifi/v1/markets.rs | 9 +- .../protocols/humidifi/v1/overrides.yaml | 23 +- crates/core/src/scenarios/registry.rs | 7 +- crates/core/src/tests/humidifi/mod.rs | 224 +++------- crates/mcp/src/surfpool/mod.rs | 243 ++++------- crates/types/src/scenarios.rs | 129 ++---- 10 files changed, 548 insertions(+), 902 deletions(-) diff --git a/crates/cli/src/http/mod.rs b/crates/cli/src/http/mod.rs index dcf773fd1..f787883c7 100644 --- a/crates/cli/src/http/mod.rs +++ b/crates/cli/src/http/mod.rs @@ -417,61 +417,6 @@ mod tests { .set_json(body) } - #[actix_web::test] - async fn humidifi_builder_scenario_keeps_value_types_through_the_api() { - use surfpool_core::scenarios::protocols::humidifi::v1::{ - HumidiFiMarket, build_humidifi_fair_value_scenario, - }; - - let token_program = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" - .parse() - .unwrap(); - let market = HumidiFiMarket { - address: solana_pubkey::Pubkey::new_unique(), - base_mint: "So11111111111111111111111111111111111111112" - .parse() - .unwrap(), - quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - .parse() - .unwrap(), - base_token_program: token_program, - quote_token_program: token_program, - base_decimals: 9, - quote_decimals: 6, - max_staleness_slots: 2, - }; - let scenario = build_humidifi_fair_value_scenario(&market, "208") - .unwrap() - .scenario; - let expected = serde_json::to_value(&scenario).unwrap(); - let loaded_scenarios = Data::new(RwLock::new(LoadedScenarios::new())); - let app = test::init_service( - App::new() - .app_data(loaded_scenarios) - .configure(configure_api), - ) - .await; - - let created = test::call_service(&app, post_scenario(expected.clone()).to_request()).await; - assert_eq!(created.status(), 200); - let response = test::call_service( - &app, - test::TestRequest::get().uri("/v1/scenarios").to_request(), - ) - .await; - assert_eq!(response.status(), 200); - let stored: serde_json::Value = test::read_body_json(response).await; - assert_eq!(stored, serde_json::json!([expected])); - let overrides = stored[0]["overrides"].as_array().unwrap(); - assert_eq!(overrides[0]["templateId"], "humidifi-fair-value"); - assert_eq!(overrides[0]["values"]["fair_value"], "58546795155816"); - assert_eq!(overrides[0]["fetchBeforeUse"], false); - assert_eq!(overrides[1]["templateId"], "humidifi-freshness"); - assert!(overrides[1]["values"]["last_update_slot"].is_null()); - assert_eq!(overrides[1]["fetchBeforeUse"], false); - assert_eq!(overrides[1]["persist"], true); - } - #[actix_web::test] async fn creating_the_same_scenario_twice_is_a_no_op() { let loaded_scenarios = Data::new(RwLock::new(LoadedScenarios::new())); diff --git a/crates/core/src/scenarios/protocols/humidifi/README.md b/crates/core/src/scenarios/protocols/humidifi/README.md index 18da6e310..2a9e4527d 100644 --- a/crates/core/src/scenarios/protocols/humidifi/README.md +++ b/crates/core/src/scenarios/protocols/humidifi/README.md @@ -12,13 +12,7 @@ state through the raw layout in `v1/overrides.yaml`; it does not construct or su - ELF length: 339440 bytes - ELF SHA-256: `4c2b4c29bce4ee4d2a0dfde28f6d511e60627e86ac3cd417e6734ff999ea4550` -Mainnet RPC and PublicNode independently returned these values on 2026-09-13, and the running -Surfnet fork matched them. The focused live suite then passed all ten tests against mainnet, -including two-market layout and round-trip checks, materialization, fair-value and liquidity swap -replays, and the inclusive staleness boundary. A signed original-wallet DFlow route simulation also -succeeded against the same ELF; this is simulation evidence, not a committed transaction. These -results validate compatibility with the existing `v1` raw layout. A later redeploy voids the layout -evidence; the live suite pins ProgramData and fails when it moves. +A later redeploy voids the layout evidence; the live suite pins ProgramData and fails when it moves. ## The account is obfuscated @@ -52,17 +46,8 @@ contract. Schema versions other than 8 are unsupported. | `humidifi-freshness` | Materialization slot at offset 616, default lead 0 | | `humidifi-stale-quote` | Aged slot at offset 616, default lead -3 | -The price conversion is `floor(price * 2^48 * 10^(quote_decimals - base_decimals))`. -The builder reads both mint decimals and computes this with integer arithmetic. A raw template -takes the resulting ratio as a decimal string. For SOL/USDC, price `208` gives -`"58546795155816"`. - -Offset 608 holds the maximum accepted quote age in slots. A supplied `last_update_slot` value is a -signed lead relative to materialization, not an absolute slot; `null` selects the template's -default lead. Pass `-(maxStalenessSlots + 1)` to reach the first stale slot: age equal to the -limit still fills, while the next slot fails with `Custom(1027565)` (`0xfaded`). Passing `0` -makes a quote fresh, including on the stale template. Staleness is applied -once; freshness can persist to keep the quote current over subsequent slots. +Each template's `llm_context` in `v1/overrides.yaml` documents its value: the fair-value +conversion, the slot lead, and the staleness boundary. ## Live market discovery @@ -71,16 +56,11 @@ referenced mints in batches of at most 100. Addresses identify markets; labels u symbols with full mint addresses as a fallback. Discovery sorts by label and address. The templates contain no static market list or default address; every override must target an explicitly selected market. -On 2026-09-11, a mainnet scan found 93 accounts of size 1728: 36 with schema 8, 50 with schema 5, -two each with values 0, 2 and 4, and one with value 6. Schema membership is not proof of current -trading or liquidity. Discovery validates compatible accounts and mint metadata; it does not -promise that every market is quoting. The live test checks returned metadata without pinning a +Market-sized accounts on mainnet span several schema versions, and schema membership is not proof +of current trading or liquidity. Discovery validates compatible accounts and mint metadata; it does +not promise that every market is quoting. The live test checks returned metadata without pinning a market count. -On 2026-09-13, the focused mainnet run discovered and validated 36 compatible markets. PublicNode -independently confirmed the ProgramData identity but returned HTTP 403 for `getProgramAccounts`, so -that provider did not verify discovery. - To inspect all market-sized accounts, including unsupported schemas: ```bash @@ -100,10 +80,12 @@ filters the tag at offset 8 and version 8 at offset 1720. Surfnet RPC, where local accounts take precedence and missing accounts fall back to the datasource. It stages the result through the shared scenario path. -Both overrides leave `fetchBeforeUse: false`: the creation reads already cached the market in -Surfnet, and Play applies the values to that local state. Refetching would replace earlier local -edits. The second override persists freshness with a `null` value, so the encoder uses its zero -lead at every materialization slot without fetching over the price. +Both builders leave every override at `fetchBeforeUse: false`: the creation reads already cached +the accounts in Surfnet, and Play applies the values to that local state. Refetching would replace +earlier local edits and the state the amounts were calculated from. Freshness is persisted with a +`null` value, so the encoder uses its zero lead at every materialization slot without fetching over +the price. When composing templates directly, set `fetchBeforeUse: true` on the first override for +each account that has not already been prepared. `build_humidifi_liquidity_scenario` scales the market's vault balances through the generic `spl-token-account-balance` template, one override per side that changes, from 0 to 10000 remaining @@ -112,28 +94,18 @@ from the market's masked words at offsets 448 (quote) and 480 (base); each vault account for the market's mint on that side, owned by that mint's token program, initialized and controlled by the market. `create_humidifi_liquidity_scenario` reads the market, both mints and both vaults through the Surfnet RPC and stages the result. -Its overrides also leave `fetchBeforeUse: false` to preserve the local state used to calculate -the amounts. When composing templates directly, set `fetchBeforeUse: true` on the first override -for each account that has not already been prepared. `list_humidifi_markets` returns addresses, labels, both mint identities and decimals, and `maxStalenessSlots`. Both creation tools require a non-empty `market` address from this list. -All tools accept an optional `surfnetPort`, defaulting to 8899, the same camelCase argument names as the pump tool. Studio's PMM +All tools accept an optional `surfnetPort`, defaulting to 8899. Studio's PMM fair-value preset uses the market list and fair-value tools; the stale-quote and liquidity chips request editable state scenarios. ## Behavioral evidence The live suite checks byte-limited template writes on two markets, guarded layout rejection, -discovered metadata, and scenario materialization with persisted freshness. It loads the pinned -deployed ELF into LiteSVM for swap replay. A native DFlow wrapper re-emits the captured HumidiFi -CPI; its system-owned authority must remain a read-only signer, with signature verification off. -The captured instruction comes from transaction -[`3zevqw…g1Si`](https://explorer.solana.com/tx/3zevqwAa8u136UGE1bdBzP1o2dpFuc7X333dC1ut3T1uCgY6iidihfNJNoJ7tuyj3tHNin1i7HTFCUSFWqK7g1Si) -at slot 444225745: DFlow outer instruction 2, HumidiFi CPI, 2427890 USDC atoms of input. -The replay uses those instruction bytes and account identities, with live protocol-state contents -and ELF. Test user token accounts and the signer are synthesized; it does not replay the entire -DFlow transaction or load frozen protocol account snapshots. +discovered metadata, and scenario materialization with persisted freshness. The swap replay's +setup is described in `crates/core/src/tests/humidifi/mod.rs`. The fair-value replay checks unchanged output after re-encoding the current ratio, increased base output after halving the price, and decreased output or rejection after doubling it. @@ -142,23 +114,16 @@ materializes them, then compares actual swap output with the human price and min 1%. This expectation does not use the encoded fair-value word or the `2^48` conversion. The staleness replay uses an explicit clock: with the tested SOL/USDC market's limit of 2, age 3 fails with `Custom(1027565)` (`0xfaded`) and age 2 fills. Changing offset 608 to 10 moves those boundaries -to ages 11 and 10; the stale template's default age 3 then fills. The limit is inclusive. +to ages 11 and 10. The limit is inclusive. The liquidity replay materializes the builder's scenarios through the production path and swaps against the prepared accounts. A drained base vault fails the transfer with the token program's -insufficient-funds error. On the tested SOL/USDC market in the 2026-09-11 replay, a base vault cut +insufficient-funds error. On the tested SOL/USDC market, a base vault cut to 0.5% of its live balance still filled at less than a hundredth of baseline output. Draining the quote vault left quote-to-base fills unchanged. These observations are tested against live state; the tool scales balances without assuming a fixed inventory threshold or output multiplier. -```bash -SURFPOOL_TEST_RPC_URL= cargo test -p surfpool-core --features integration-tests \ - tests::humidifi -- --test-threads=1 --nocapture -``` - -Run serially. Public endpoints can shed requests after discovery, sometimes reporting HTTP 413. -`SURFPOOL_TEST_RPC_URL` defaults to the public mainnet endpoint; use a private endpoint when it -rate-limits. +The run command for the live suite is in [`../../README.md`](../../README.md#humidifi-integration-tests). ## Known boundaries diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs b/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs index 88ae4a4ae..78360ae58 100644 --- a/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs +++ b/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs @@ -36,7 +36,7 @@ const ACTIVE_SCHEMA_VERSION: u64 = 8; /// The four per-word XOR keys the program uses to obfuscate a 32-byte pubkey field. Global across /// the supported markets. Used only to READ the mints for their decimals, never to /// write, which is why they live here rather than as template masks. -pub(super) const PUBKEY_XOR_KEYS: [u64; 4] = [ +const PUBKEY_XOR_KEYS: [u64; 4] = [ 0xfb5c_e87a_ae44_3c38, 0x04a2_1784_51ba_c3c7, 0x04a1_1787_51b9_c3c6, @@ -67,27 +67,47 @@ pub(super) static MARKET_LAYOUT: LazyLock = LazyLock::new(|| { /// The parts of a HumidiFi market a price needs: which mints it quotes, and at what scale. #[derive(Clone, Debug, PartialEq)] pub struct HumidiFiMarket { - pub address: Pubkey, - pub base_mint: Pubkey, - pub quote_mint: Pubkey, - pub base_token_program: Pubkey, - pub quote_token_program: Pubkey, - pub base_decimals: u8, - pub quote_decimals: u8, - pub max_staleness_slots: u64, + pub(crate) address: Pubkey, + pub(crate) base_mint: Pubkey, + pub(crate) quote_mint: Pubkey, + pub(crate) base_token_program: Pubkey, + pub(crate) quote_token_program: Pubkey, + pub(crate) base_decimals: u8, + pub(crate) quote_decimals: u8, + pub(crate) max_staleness_slots: u64, } impl HumidiFiMarket { + pub fn address(&self) -> Pubkey { + self.address + } + + pub fn base_mint(&self) -> Pubkey { + self.base_mint + } + + pub fn quote_mint(&self) -> Pubkey { + self.quote_mint + } + + pub fn base_decimals(&self) -> u8 { + self.base_decimals + } + + pub fn quote_decimals(&self) -> u8 { + self.quote_decimals + } + + pub fn max_staleness_slots(&self) -> u64 { + self.max_staleness_slots + } + pub fn mint_addresses(market_account: &Account) -> SurfpoolResult<(Pubkey, Pubkey)> { - validate_humidifi_market_layout(market_account)?; - let base_mint = read_masked_pubkey(&market_account.data, BASE_MINT_OFFSET)?; - let quote_mint = read_masked_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; - if base_mint == Pubkey::default() - || quote_mint == Pubkey::default() - || base_mint == quote_mint - { - return Err(invalid("market has invalid mint identities")); - } + let [base_mint, quote_mint] = read_masked_pubkey_pair( + market_account, + [BASE_MINT_OFFSET, QUOTE_MINT_OFFSET], + "mint", + )?; Ok((base_mint, quote_mint)) } @@ -177,32 +197,20 @@ pub fn build_humidifi_fair_value_scenario( let registry = TemplateRegistry::new(); let fair_value_template = template(®istry, FAIR_VALUE_TEMPLATE)?; - let freshness = template(®istry, FRESHNESS_TEMPLATE)?; let market_name = market.label(); - let target = AccountAddress::Pubkey(market.address.to_string()); // Refetching on Play would replace the local market used to prepare this scenario. let price_override = OverrideInstance::new( fair_value_template.id.clone(), PREPARATION_SLOT, - target.clone(), + AccountAddress::Pubkey(market.address.to_string()), ) .with_values(HashMap::from([( "fair_value".to_string(), serde_json::json!(fair_value.to_string()), )])) .with_label(format!("HumidiFi {market_name} fair value")); - - // 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 market's - // freshness window however long the scenario is left running. - let freshness_override = OverrideInstance::new(freshness.id.clone(), PREPARATION_SLOT, target) - .with_values(HashMap::from([( - "last_update_slot".to_string(), - serde_json::Value::Null, - )])) - .with_label("Keep HumidiFi quote fresh".to_string()) - .with_persist(true); + let freshness = freshness_override(®istry, &market.address)?; let normalized_price = price.trim(); let mut scenario = Scenario::new( @@ -218,7 +226,7 @@ pub fn build_humidifi_fair_value_scenario( "price-dislocation".to_string(), ]; scenario.add_override(price_override); - scenario.add_override(freshness_override); + scenario.add_override(freshness); Ok(HumidiFiFairValuePreparation { scenario, @@ -227,8 +235,44 @@ pub fn build_humidifi_fair_value_scenario( }) } +/// 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 market's +/// freshness window however long the scenario is left running. +pub(super) fn freshness_override( + registry: &TemplateRegistry, + market: &Pubkey, +) -> SurfpoolResult { + let freshness = template(registry, FRESHNESS_TEMPLATE)?; + Ok(OverrideInstance::new( + freshness.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(market.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep HumidiFi quote fresh".to_string()) + .with_persist(true)) +} + +/// Validates the market, then unmasks a `[base, quote]` pubkey pair that must be set and distinct. +pub(super) fn read_masked_pubkey_pair( + market_account: &Account, + [base_offset, quote_offset]: [usize; 2], + field: &str, +) -> SurfpoolResult<[Pubkey; 2]> { + validate_humidifi_market_layout(market_account)?; + let base = read_masked_pubkey(&market_account.data, base_offset)?; + let quote = read_masked_pubkey(&market_account.data, quote_offset)?; + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(invalid(format!("market has invalid {field} identities"))); + } + Ok([base, quote]) +} + /// Unmasks a 32-byte pubkey stored as four XOR-obfuscated words. -pub(super) fn read_masked_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { +fn read_masked_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { let end = offset .checked_add(32) .ok_or_else(|| invalid("market mint offset overflow"))?; @@ -326,49 +370,65 @@ pub(super) fn invalid(message: impl Into) -> SurfpoolError { } #[cfg(test)] -mod tests { +pub(super) fn mint_account(decimals: u8, token_program: Pubkey) -> Account { use solana_program_pack::Pack; - use super::*; - - fn mint_account(decimals: u8) -> Account { - let mut data = vec![0; spl_token_interface::state::Mint::LEN]; - spl_token_interface::state::Mint { + let mut data = vec![0; spl_token_interface::state::Mint::LEN]; + if token_program == spl_token_2022_interface::id() { + spl_token_2022_interface::state::Mint { decimals, is_initialized: true, ..Default::default() } .pack_into_slice(&mut data); - Account { - data, - owner: spl_token_interface::id(), - ..Account::default() + } else { + spl_token_interface::state::Mint { + decimals, + is_initialized: true, + ..Default::default() } + .pack_into_slice(&mut data); + } + Account { + data, + owner: token_program, + ..Account::default() } +} - fn write_masked_pubkey(data: &mut [u8], offset: usize, pubkey: &Pubkey) { - let bytes = pubkey.to_bytes(); - for (i, key) in PUBKEY_XOR_KEYS.iter().enumerate() { - let word = u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().unwrap()); - data[offset + i * 8..offset + i * 8 + 8].copy_from_slice(&(word ^ key).to_le_bytes()); - } +#[cfg(test)] +pub(super) fn write_masked_pubkey(data: &mut [u8], offset: usize, pubkey: &Pubkey) { + let bytes = pubkey.to_bytes(); + for (i, key) in PUBKEY_XOR_KEYS.iter().enumerate() { + let word = u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().unwrap()); + data[offset + i * 8..offset + i * 8 + 8].copy_from_slice(&(word ^ key).to_le_bytes()); } +} - fn market_account(base_mint: &Pubkey, quote_mint: &Pubkey) -> Account { - let mut data = vec![0; MARKET_LAYOUT.account_size]; - let magic = MARKET_LAYOUT.magic.as_ref().unwrap(); - data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); - data[SCHEMA_VERSION_OFFSET..SCHEMA_VERSION_OFFSET + 8] - .copy_from_slice(&schema_version_bytes()); - data[MAX_STALENESS_OFFSET..MAX_STALENESS_OFFSET + 8] - .copy_from_slice(&(6u64 ^ STATE_XOR_KEY).to_le_bytes()); - write_masked_pubkey(&mut data, BASE_MINT_OFFSET, base_mint); - write_masked_pubkey(&mut data, QUOTE_MINT_OFFSET, quote_mint); - Account { - data, - owner: HUMIDIFI_PROGRAM_ID, - ..Account::default() - } +/// A version-8 market with a staleness limit of 6 slots and the given mints. +#[cfg(test)] +pub(super) fn market_account(base_mint: &Pubkey, quote_mint: &Pubkey) -> Account { + let mut data = vec![0; MARKET_LAYOUT.account_size]; + let magic = MARKET_LAYOUT.magic.as_ref().unwrap(); + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[SCHEMA_VERSION_OFFSET..SCHEMA_VERSION_OFFSET + 8].copy_from_slice(&schema_version_bytes()); + data[MAX_STALENESS_OFFSET..MAX_STALENESS_OFFSET + 8] + .copy_from_slice(&(6u64 ^ STATE_XOR_KEY).to_le_bytes()); + write_masked_pubkey(&mut data, BASE_MINT_OFFSET, base_mint); + write_masked_pubkey(&mut data, QUOTE_MINT_OFFSET, quote_mint); + Account { + data, + owner: HUMIDIFI_PROGRAM_ID, + ..Account::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spl_mint(decimals: u8) -> Account { + mint_account(decimals, spl_token_interface::id()) } fn market(base_decimals: u8, quote_decimals: u8) -> HumidiFiMarket { @@ -377,8 +437,8 @@ mod tests { HumidiFiMarket::validate( Pubkey::new_unique(), &market_account(&base_mint, "e_mint), - &mint_account(base_decimals), - &mint_account(quote_decimals), + &spl_mint(base_decimals), + &spl_mint(quote_decimals), ) .expect("valid HumidiFi market") } @@ -394,8 +454,7 @@ mod tests { ); let address = Pubkey::new_unique(); let market = - HumidiFiMarket::validate(address, &account, &mint_account(9), &mint_account(6)) - .unwrap(); + HumidiFiMarket::validate(address, &account, &spl_mint(9), &spl_mint(6)).unwrap(); assert_eq!(market.address, address); assert_eq!(market.base_mint, base_mint); assert_eq!(market.quote_mint, quote_mint); @@ -419,51 +478,45 @@ mod tests { } #[test] - fn builds_fair_value_for_sol_usdc_decimals() { - let market = market(9, 6); - let preparation = build_humidifi_fair_value_scenario(&market, "208").unwrap(); - // 208 * 2^48 * 10^(6-9), floored. - assert_eq!(preparation.fair_value, 58_546_795_155_816); - assert_eq!(preparation.scenario.overrides.len(), 2); - - let [price, _] = &preparation.scenario.overrides[..] else { - panic!("expected exactly a price and a freshness override"); - }; - let stored: u64 = price - .values - .get("fair_value") - .and_then(|v| v.as_str()) - .and_then(|s| s.parse().ok()) - .unwrap(); - assert_eq!(stored, 58_546_795_155_816); - } - - #[test] - fn price_and_freshness_preserve_the_prepared_market() { - let preparation = build_humidifi_fair_value_scenario(&market(9, 6), "100.25").unwrap(); - let [price, freshness] = &preparation.scenario.overrides[..] else { - panic!("expected exactly a price and a freshness override"); - }; - assert!(!price.fetch_before_use); - assert!(!price.persist); - assert!(!freshness.fetch_before_use); - assert!(freshness.persist); - assert_eq!( - freshness.values.get("last_update_slot"), - Some(&serde_json::Value::Null) - ); - } - - #[test] - fn derives_fair_value_from_market_mint_decimals() { - // A d6/d6 pair: the decimals cancel, so raw is price * 2^48 directly. - let market = market(6, 6); - let preparation = - build_humidifi_fair_value_scenario(&market, "0.4433").expect("build JUP/USDC price"); - assert_eq!( - preparation.fair_value, - (4433u128 * FAIR_VALUE_SCALE / 10_000) as u64 - ); + fn builds_fair_value_from_the_market_decimals_and_keeps_the_quote_fresh() { + for (base_decimals, quote_decimals, price, expected) in [ + // 208 * 2^48 * 10^(6-9), floored. + (9, 6, "208", 58_546_795_155_816), + ( + 9, + 6, + "100.25", + (10_025u128 * FAIR_VALUE_SCALE / 100_000) as u64, + ), + // A d6/d6 pair: the decimals cancel, so raw is price * 2^48 directly. + ( + 6, + 6, + "0.4433", + (4433u128 * FAIR_VALUE_SCALE / 10_000) as u64, + ), + ] { + let market = market(base_decimals, quote_decimals); + let preparation = build_humidifi_fair_value_scenario(&market, price).unwrap(); + assert_eq!(preparation.fair_value, expected, "{price}"); + assert!(preparation.scenario.name.contains(&market.label())); + + let [price_override, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected exactly a price and a freshness override"); + }; + assert_eq!( + price_override.values.get("fair_value"), + Some(&serde_json::json!(expected.to_string())) + ); + assert!(!price_override.fetch_before_use); + assert!(!price_override.persist); + assert!(!freshness.fetch_before_use); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } } #[test] @@ -473,8 +526,8 @@ mod tests { assert!(build_humidifi_fair_value_scenario(&market, price).is_err()); } - let base_mint = mint_account(9); - let quote_mint = mint_account(6); + let base_mint = spl_mint(9); + let quote_mint = spl_mint(6); let wrong_owner = Account { owner: Pubkey::new_unique(), ..market_account(&Pubkey::new_unique(), &Pubkey::new_unique()) diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/humidifi/v1/liquidity.rs index 69d5bf5a4..872e62fd5 100644 --- a/crates/core/src/scenarios/protocols/humidifi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/humidifi/v1/liquidity.rs @@ -9,12 +9,11 @@ use solana_account::Account; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; -use crate::{error::SurfpoolResult, scenarios::TemplateRegistry, types::TokenAccount}; - use super::fair_value::{ - FRESHNESS_TEMPLATE, HumidiFiMarket, PREPARATION_SLOT, invalid, read_masked_pubkey, template, - validate_humidifi_market_layout, + HumidiFiMarket, PREPARATION_SLOT, freshness_override, invalid, read_masked_pubkey_pair, + template, }; +use crate::{error::SurfpoolResult, scenarios::TemplateRegistry, types::TokenAccount}; /// The vault addresses are read, never written: their balances ride the generic token template. const QUOTE_VAULT_OFFSET: usize = 448; @@ -25,13 +24,11 @@ const BPS: u128 = 10_000; /// The market's `[base, quote]` vault addresses, unmasked. pub fn humidifi_vault_addresses(market_account: &Account) -> SurfpoolResult<[Pubkey; 2]> { - validate_humidifi_market_layout(market_account)?; - let base = read_masked_pubkey(&market_account.data, BASE_VAULT_OFFSET)?; - let quote = read_masked_pubkey(&market_account.data, QUOTE_VAULT_OFFSET)?; - if base == Pubkey::default() || quote == Pubkey::default() || base == quote { - return Err(invalid("market has invalid vault identities")); - } - Ok([base, quote]) + read_masked_pubkey_pair( + market_account, + [BASE_VAULT_OFFSET, QUOTE_VAULT_OFFSET], + "vault", + ) } pub fn build_humidifi_liquidity_scenario( @@ -77,7 +74,6 @@ pub fn build_humidifi_liquidity_scenario( let registry = TemplateRegistry::new(); let balance_template = template(®istry, TOKEN_BALANCE_TEMPLATE)?; - let freshness = template(®istry, FRESHNESS_TEMPLATE)?; let label = market.label(); let mut scenario = Scenario::new( @@ -123,19 +119,7 @@ pub fn build_humidifi_liquidity_scenario( ); } - scenario.add_override( - OverrideInstance::new( - freshness.id.clone(), - PREPARATION_SLOT, - AccountAddress::Pubkey(market.address.to_string()), - ) - .with_values(HashMap::from([( - "last_update_slot".to_string(), - serde_json::Value::Null, - )])) - .with_label("Keep HumidiFi quote fresh".to_string()) - .with_persist(true), - ); + scenario.add_override(freshness_override(®istry, &market.address)?); Ok(scenario) } @@ -151,11 +135,6 @@ fn vault_balance( "{side} vault token program does not match the market's {side} mint" ))); } - if vault.owner != spl_token_interface::id() && vault.owner != spl_token_2022_interface::id() { - return Err(invalid(format!( - "{side} vault is not owned by a supported token program" - ))); - } let token = TokenAccount::unpack(&vault.data) .map_err(|_| invalid(format!("{side} vault is not an initialized token account")))?; if !token_account_is_initialized(&token) { @@ -191,15 +170,14 @@ fn percent(bps: u16) -> String { #[cfg(test)] mod tests { - use solana_program_pack::Pack; - use super::{ - super::fair_value::{HUMIDIFI_PROGRAM_ID, PUBKEY_XOR_KEYS, schema_version_bytes}, + super::fair_value::{ + FRESHNESS_TEMPLATE, SCHEMA_VERSION_OFFSET, market_account, mint_account, + write_masked_pubkey, + }, *, }; - const STATE_KEY: u64 = 0x6e9d_e2b3_0b19_f1ea; - struct Fixture { market: HumidiFiMarket, market_account: Account, @@ -209,35 +187,22 @@ mod tests { quote_vault: Account, } - fn write_masked_pubkey(data: &mut [u8], offset: usize, pubkey: &Pubkey) { - let bytes = pubkey.to_bytes(); - for (i, key) in PUBKEY_XOR_KEYS.iter().enumerate() { - let word = u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().unwrap()); - data[offset + i * 8..offset + i * 8 + 8].copy_from_slice(&(word ^ key).to_le_bytes()); - } - } - - fn mint_account(decimals: u8, token_program: Pubkey) -> Account { - let mut data = vec![0; spl_token_interface::state::Mint::LEN]; - if token_program == spl_token_2022_interface::id() { - spl_token_2022_interface::state::Mint { - decimals, - is_initialized: true, - ..Default::default() - } - .pack_into_slice(&mut data); - } else { - spl_token_interface::state::Mint { - decimals, - is_initialized: true, - ..Default::default() - } - .pack_into_slice(&mut data); - } - Account { - data, - owner: token_program, - ..Account::default() + impl Fixture { + fn build( + &self, + base_vault: &Account, + quote_vault: &Account, + base_remaining_bps: u16, + quote_remaining_bps: u16, + ) -> SurfpoolResult { + build_humidifi_liquidity_scenario( + &self.market, + &self.market_account, + base_vault, + quote_vault, + base_remaining_bps, + quote_remaining_bps, + ) } } @@ -280,25 +245,17 @@ mod tests { let base_vault_address = Pubkey::new_unique(); let quote_vault_address = Pubkey::new_unique(); - let registry = TemplateRegistry::new(); - let layout = registry - .get("humidifi-fair-value") - .and_then(|template| template.raw_layout.clone()) - .unwrap(); - let magic = layout.magic.unwrap(); - let mut data = vec![0; layout.account_size]; - data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); - data[1720..1728].copy_from_slice(&schema_version_bytes()); - data[608..616].copy_from_slice(&(2u64 ^ STATE_KEY).to_le_bytes()); - write_masked_pubkey(&mut data, 416, &base_mint); - write_masked_pubkey(&mut data, 384, "e_mint); - write_masked_pubkey(&mut data, BASE_VAULT_OFFSET, &base_vault_address); - write_masked_pubkey(&mut data, QUOTE_VAULT_OFFSET, "e_vault_address); - let market_account = Account { - data, - owner: HUMIDIFI_PROGRAM_ID, - ..Account::default() - }; + let mut market_account = market_account(&base_mint, "e_mint); + write_masked_pubkey( + &mut market_account.data, + BASE_VAULT_OFFSET, + &base_vault_address, + ); + write_masked_pubkey( + &mut market_account.data, + QUOTE_VAULT_OFFSET, + "e_vault_address, + ); let market = HumidiFiMarket::validate( address, &market_account, @@ -349,15 +306,9 @@ mod tests { #[test] fn scales_only_the_selected_side_and_keeps_the_quote_fresh() { let fixture = fixture(1_000_000, 2_000_000); - let scenario = build_humidifi_liquidity_scenario( - &fixture.market, - &fixture.market_account, - &fixture.base_vault, - &fixture.quote_vault, - 50, - 10_000, - ) - .unwrap(); + let scenario = fixture + .build(&fixture.base_vault, &fixture.quote_vault, 50, 10_000) + .unwrap(); let [base, freshness] = &scenario.overrides[..] else { panic!("expected one vault override and the freshness override"); @@ -388,21 +339,15 @@ mod tests { #[test] fn drains_a_side_floors_the_remainder_and_rejects_noops() { let fixture = fixture(1_000_001, 2_000_000); - let build = |base: u16, quote: u16| { - build_humidifi_liquidity_scenario( - &fixture.market, - &fixture.market_account, - &fixture.base_vault, - &fixture.quote_vault, - base, - quote, - ) - }; - let drained = build(0, 10_000).unwrap(); + let drained = fixture + .build(&fixture.base_vault, &fixture.quote_vault, 0, 10_000) + .unwrap(); assert_eq!(amount_of(&drained.overrides[0]), 0); - let both = build(3_333, 2_500).unwrap(); + let both = fixture + .build(&fixture.base_vault, &fixture.quote_vault, 3_333, 2_500) + .unwrap(); assert_eq!(both.overrides.len(), 3); assert_eq!(amount_of(&both.overrides[0]), 333_300); assert_eq!( @@ -411,139 +356,111 @@ mod tests { ); assert_eq!(amount_of(&both.overrides[1]), 500_000); - assert!(build(10_000, 10_000).is_err()); - assert!(build(10_001, 10_000).is_err()); - } - - #[test] - fn rejects_vaults_that_do_not_belong_to_the_market() { - let fixture = fixture(1_000_000, 2_000_000); - let build = |base_vault: &Account, quote_vault: &Account| { - build_humidifi_liquidity_scenario( - &fixture.market, - &fixture.market_account, - base_vault, - quote_vault, - 500, - 10_000, - ) - }; - - let wrong_mint = token_account( - Pubkey::new_unique(), - fixture.market.address, - 1, - spl_token_interface::id(), - "initialized", - ); - assert!(build(&wrong_mint, &fixture.quote_vault).is_err()); - - let wrong_authority = token_account( - fixture.market.base_mint, - Pubkey::new_unique(), - 1, - spl_token_interface::id(), - "initialized", + assert!( + fixture + .build(&fixture.base_vault, &fixture.quote_vault, 10_000, 10_000) + .is_err() ); - assert!(build(&wrong_authority, &fixture.quote_vault).is_err()); - - let foreign_program = Account { - owner: Pubkey::new_unique(), - ..fixture.base_vault.clone() - }; - assert!(build(&foreign_program, &fixture.quote_vault).is_err()); - - let not_a_token_account = Account { - data: vec![0; 10], - ..fixture.base_vault.clone() - }; - assert!(build(¬_a_token_account, &fixture.quote_vault).is_err()); - - let mut version_5 = fixture.market_account.clone(); - version_5.data[1720..1728].copy_from_slice(&5u64.to_le_bytes()); - assert!(humidifi_vault_addresses(&version_5).is_err()); - } - - #[test] - fn rejects_vault_token_program_mismatches_on_both_sides() { - let fixture = fixture(1_000_000, 2_000_000); - let mut base_mismatch = fixture.base_vault.clone(); - base_mismatch.owner = spl_token_2022_interface::id(); - let base_error = build_humidifi_liquidity_scenario( - &fixture.market, - &fixture.market_account, - &base_mismatch, - &fixture.quote_vault, - 500, - 10_000, - ) - .unwrap_err(); - assert!(base_error.to_string().contains("base vault token program")); - - let mut quote_mismatch = fixture.quote_vault.clone(); - quote_mismatch.owner = spl_token_2022_interface::id(); - let quote_error = build_humidifi_liquidity_scenario( - &fixture.market, - &fixture.market_account, - &fixture.base_vault, - "e_mismatch, - 500, - 10_000, - ) - .unwrap_err(); assert!( - quote_error - .to_string() - .contains("quote vault token program") + fixture + .build(&fixture.base_vault, &fixture.quote_vault, 10_001, 10_000) + .is_err() ); } #[test] - fn rejects_frozen_and_uninitialized_vaults() { + fn rejects_vaults_that_do_not_belong_to_the_market() { let fixture = fixture(1_000_000, 2_000_000); - let frozen = token_account( - fixture.market.base_mint, - fixture.market.address, - 1_000_000, - spl_token_interface::id(), - "frozen", - ); - let frozen_error = build_humidifi_liquidity_scenario( - &fixture.market, - &fixture.market_account, - &frozen, - &fixture.quote_vault, - 500, - 10_000, - ) - .unwrap_err(); - assert!( - frozen_error - .to_string() - .contains("base vault is not initialized") - ); + let market = &fixture.market; + let token = spl_token_interface::id(); + let token_2022 = spl_token_2022_interface::id(); + for (side, vault, expected) in [ + ( + "base", + token_account( + Pubkey::new_unique(), + market.address, + 1, + token, + "initialized", + ), + "base vault does not hold the market's base mint", + ), + ( + "base", + token_account( + market.base_mint, + Pubkey::new_unique(), + 1, + token, + "initialized", + ), + "base vault is not controlled by the market", + ), + ( + "base", + Account { + owner: Pubkey::new_unique(), + ..fixture.base_vault.clone() + }, + "base vault token program", + ), + ( + "base", + Account { + data: vec![0; 10], + ..fixture.base_vault.clone() + }, + "base vault is not an initialized token account", + ), + ( + "base", + Account { + owner: token_2022, + ..fixture.base_vault.clone() + }, + "base vault token program", + ), + ( + "quote", + Account { + owner: token_2022, + ..fixture.quote_vault.clone() + }, + "quote vault token program", + ), + ( + "base", + token_account(market.base_mint, market.address, 1_000_000, token, "frozen"), + "base vault is not initialized", + ), + ( + "quote", + token_account( + market.quote_mint, + market.address, + 2_000_000, + token, + "uninitialized", + ), + "quote vault is not an initialized token account", + ), + ] { + let (base_vault, quote_vault) = if side == "base" { + (&vault, &fixture.quote_vault) + } else { + (&fixture.base_vault, &vault) + }; + let error = fixture + .build(base_vault, quote_vault, 500, 10_000) + .unwrap_err(); + assert!(error.to_string().contains(expected), "{side}: {error}"); + } - let uninitialized = token_account( - fixture.market.quote_mint, - fixture.market.address, - 2_000_000, - spl_token_interface::id(), - "uninitialized", - ); - let uninitialized_error = build_humidifi_liquidity_scenario( - &fixture.market, - &fixture.market_account, - &fixture.base_vault, - &uninitialized, - 500, - 10_000, - ) - .unwrap_err(); - assert!( - uninitialized_error - .to_string() - .contains("quote vault is not an initialized token account") - ); + let mut version_5 = fixture.market_account.clone(); + version_5.data[SCHEMA_VERSION_OFFSET..SCHEMA_VERSION_OFFSET + 8] + .copy_from_slice(&5u64.to_le_bytes()); + assert!(humidifi_vault_addresses(&version_5).is_err()); } #[test] @@ -554,32 +471,19 @@ mod tests { spl_token_2022_interface::id(), spl_token_2022_interface::id(), ); - let scenario = build_humidifi_liquidity_scenario( - &fixture.market, - &fixture.market_account, - &fixture.base_vault, - &fixture.quote_vault, - 500, - 10_000, - ) - .unwrap(); + let scenario = fixture + .build(&fixture.base_vault, &fixture.quote_vault, 500, 10_000) + .unwrap(); assert_eq!(amount_of(&scenario.overrides[0]), 50_000); } #[test] fn rejects_market_metadata_from_a_different_account_graph() { - let fixture = fixture(1_000_000, 2_000_000); - let mut mismatched_market = fixture.market.clone(); - mismatched_market.base_mint = Pubkey::new_unique(); - let error = build_humidifi_liquidity_scenario( - &mismatched_market, - &fixture.market_account, - &fixture.base_vault, - &fixture.quote_vault, - 500, - 10_000, - ) - .unwrap_err(); + let mut fixture = fixture(1_000_000, 2_000_000); + fixture.market.base_mint = Pubkey::new_unique(); + let error = fixture + .build(&fixture.base_vault, &fixture.quote_vault, 500, 10_000) + .unwrap_err(); assert!(error.to_string().contains("mint identities do not match")); } } diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/markets.rs b/crates/core/src/scenarios/protocols/humidifi/v1/markets.rs index 6c59bdabc..9aaf9cfe4 100644 --- a/crates/core/src/scenarios/protocols/humidifi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/humidifi/v1/markets.rs @@ -8,15 +8,14 @@ use solana_client::{ }; use solana_commitment_config::CommitmentConfig; -use crate::{ - error::{SurfpoolError, SurfpoolResult}, - surfnet::remote::SurfnetRemoteClient, -}; - use super::{ HUMIDIFI_PROGRAM_ID, HumidiFiMarket, fair_value::{MARKET_LAYOUT, SCHEMA_VERSION_OFFSET, schema_version_bytes}, }; +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + surfnet::remote::SurfnetRemoteClient, +}; pub async fn discover_humidifi_markets( client: &SurfnetRemoteClient, diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml b/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml index 91b5e3d70..0843b119f 100644 --- a/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/humidifi/v1/overrides.yaml @@ -79,15 +79,12 @@ templates: slot itself, which keeps the quote live. llm_context: | This field is the venue's liveness signal, compared against the chain slot. HumidiFi accepts a - quote through the market's staleness limit. Discover markets and their maxStalenessSlots with - list_humidifi_markets and set the override account to the chosen address; there is no default. - At age maxStalenessSlots + 1 the swap fails with Custom(1027565), or 0xfaded; - at age maxStalenessSlots it still fills. + quote through the market's staleness limit (maxStalenessSlots in list_humidifi_markets) and + rejects it after. Set the override account to a market address from that list; there is no default. - A FORKED MARKET GOES STALE BY ITSELF. Surfpool caches fetched accounts and does not - continuously republish the quote. Once past the - limit the market stops quoting and the fair-value override is silently ineffective - the swap - fails on staleness. That makes this template the precondition for humidifi-fair-value. + A FORKED MARKET GOES STALE BY ITSELF. Surfpool caches fetched accounts and does not republish + the quote, so once past the limit the swap fails on staleness and any humidifi-fair-value + override is silently ineffective. This template is that override's precondition. THE VALUE IS A LEAD, NOT A SLOT NUMBER. It is resolved against the slot the override materializes at, so 0 means "published this slot". Pass null to take the lead of zero. @@ -122,11 +119,11 @@ templates: The value you pass IS the lead: Surfpool writes the materialization slot plus it, clamped at zero. Pass null to take the -3 default. This is one template for every market, not one per limit. - Rejection is at age strictly greater than the market's own limit. Discover markets with - list_humidifi_markets and set the override account to the chosen address; there is no default. - Read maxStalenessSlots for that market (the market's masked - word at offset 608), and pass -(maxStalenessSlots + 1) to reach the first rejection slot. - At that age the swap fails with Custom(1027565), or 0xfaded; one slot younger it fills. + Rejection is at age strictly greater than the market's own limit. Set the override account to + a market address from list_humidifi_markets; there is no default. Read its maxStalenessSlots + (the market's masked word at offset 608) and pass -(maxStalenessSlots + 1) to reach the first + rejection slot. At that age the swap fails with Custom(1027565), or 0xfaded; one slot younger + it fills. Do not persist this override: the quote should stay stale. Set fetchBeforeUse: true so the live market is forked first. Keep override labels short ("SOL/USDC stale quote"). diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index d21219503..64f8ba15a 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -294,9 +294,12 @@ impl TemplateRegistry { #[cfg(test)] mod tests { - use anchor_lang_idl::types::IdlType; - use std::{collections::BTreeSet, collections::HashMap, str::FromStr}; + use std::{ + collections::{BTreeSet, HashMap}, + str::FromStr, + }; + use anchor_lang_idl::types::IdlType; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, PdaSeed}; diff --git a/crates/core/src/tests/humidifi/mod.rs b/crates/core/src/tests/humidifi/mod.rs index 27a027047..70c31c412 100644 --- a/crates/core/src/tests/humidifi/mod.rs +++ b/crates/core/src/tests/humidifi/mod.rs @@ -7,7 +7,7 @@ //! HumidiFi market fields are XOR-obfuscated: an 8-byte word is stored as `plaintext XOR key`. These //! tests fetch live markets and prove the shipped templates write exactly their target fields, that //! the masked write round-trips to the plaintext the caller asked for, that the guard rejects a -//! foreign account, and that discovery matches the chain. They pin the deployed ProgramData +//! tampered account, and that discovery matches the chain. They pin the deployed ProgramData //! so a redeploy that could move the keys or offsets fails loudly rather than writing garbage. //! //! Swap replays execute the deployed HumidiFi program through a native DFlow shim to prove the @@ -35,7 +35,7 @@ use crate::{ TemplateRegistry, protocols::humidifi::v1::{ HumidiFiMarket, build_humidifi_fair_value_scenario, build_humidifi_liquidity_scenario, - discover_humidifi_markets, humidifi_vault_addresses, validate_humidifi_market_layout, + discover_humidifi_markets, humidifi_vault_addresses, }, }, surfnet::svm::SurfnetSvm, @@ -106,16 +106,6 @@ fn assert_only_within(diffs: &[usize], ranges: &[std::ops::Range], contex } } -/// The single-field case of [`assert_only_within`]. -fn assert_within(diffs: &[usize], range: std::ops::Range, context: &str) { - for index in diffs { - assert!( - range.contains(index), - "{context}: byte {index} changed outside the target field {range:?}" - ); - } -} - fn template_raw_apply( template_id: &str, values: HashMap, @@ -167,71 +157,59 @@ async fn humidifi_templates_write_only_their_fields_and_round_trip() { ); let data = market.data; - // Fair value: a chosen raw ratio lands at offset 576, masked, and nothing else moves. let chosen: u64 = 12_345_678_901_234; - let out = template_raw_apply( - "humidifi-fair-value", - HashMap::from([( - "fair_value".to_string(), - serde_json::json!(chosen.to_string()), - )]), - 0, - &data, - ); - assert_eq!( - decode_u64(&out, FAIR_VALUE_OFFSET, FAIR_VALUE_KEY), - chosen, - "{address}: the masked fair value must unmask to the chosen ratio" - ); - assert_within( - &diff_indices(&out, &data), - FAIR_VALUE_OFFSET..FAIR_VALUE_OFFSET + 8, - &format!("{address} fair value"), - ); - - // Freshness: the materialization slot lands at offset 616, masked. let slot = 500_000_000u64; - let out = template_raw_apply( - "humidifi-freshness", - HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), - slot, - &data, - ); - assert_eq!( - decode_u64(&out, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), - slot, - "{address}: freshness must publish the materialization slot" - ); - assert_within( - &diff_indices(&out, &data), - LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, - &format!("{address} freshness"), - ); - - // Stale: the default lead of -3 ages the quote past the tested SOL/USDC market's inclusive limit. - let out = template_raw_apply( - "humidifi-stale-quote", - HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), - slot, - &data, - ); - assert_eq!( - decode_u64(&out, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), - slot - 3, - "{address}: the stale template must age the quote by its lead" - ); - assert_within( - &diff_indices(&out, &data), - LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, - &format!("{address} stale quote"), - ); + // The stale template's default lead of -3 ages the quote past the tested SOL/USDC market's + // inclusive limit. + for (template, property, value, offset, key, expected) in [ + ( + "humidifi-fair-value", + "fair_value", + serde_json::json!(chosen.to_string()), + FAIR_VALUE_OFFSET, + FAIR_VALUE_KEY, + chosen, + ), + ( + "humidifi-freshness", + "last_update_slot", + serde_json::Value::Null, + LAST_UPDATE_SLOT_OFFSET, + STATE_KEY, + slot, + ), + ( + "humidifi-stale-quote", + "last_update_slot", + serde_json::Value::Null, + LAST_UPDATE_SLOT_OFFSET, + STATE_KEY, + slot - 3, + ), + ] { + let out = template_raw_apply( + template, + HashMap::from([(property.to_string(), value)]), + slot, + &data, + ); + assert_eq!( + decode_u64(&out, offset, key), + expected, + "{address} {template}: the masked word must unmask to the requested plaintext" + ); + assert_only_within( + &diff_indices(&out, &data), + std::slice::from_ref(&(offset..offset + 8)), + &format!("{address} {template}"), + ); + } } } -/// The guard admits a real market and rejects everything else, and the owner check the builder adds -/// catches a foreign account the raw guard cannot see. +/// The raw guard rejects a market of the wrong size or with a tampered magic word. #[tokio::test] -async fn humidifi_guard_admits_markets_and_rejects_others() { +async fn humidifi_guard_rejects_wrong_size_and_magic() { let market = fetch(&[SOL_USDC_MARKET]).await.remove(0); let registry = TemplateRegistry::new(); let layout = registry @@ -239,12 +217,6 @@ async fn humidifi_guard_admits_markets_and_rejects_others() { .and_then(|t| t.raw_layout.clone()) .expect("HumidiFi raw layout"); - assert!( - layout.guard(&market.data).is_ok(), - "the real market must pass" - ); - assert!(validate_humidifi_market_layout(&market).is_ok()); - // Wrong size. let mut short = market.data.clone(); short.truncate(MARKET_SIZE - 8); @@ -256,19 +228,6 @@ async fn humidifi_guard_admits_markets_and_rejects_others() { tampered[MAGIC_OFFSET] ^= 0xff; let err = layout.guard(&tampered).unwrap_err(); assert!(err.contains("magic"), "unexpected error: {err}"); - - // Right bytes, wrong owner: the raw guard passes, the owner check does not. - let foreign = Account { - owner: Pubkey::new_unique(), - ..market.clone() - }; - assert!(layout.guard(&foreign.data).is_ok()); - assert!(validate_humidifi_market_layout(&foreign).is_err()); - - let mut version_5 = market.clone(); - encode_masked_u64(&mut version_5.data, SCHEMA_VERSION_OFFSET, 0, 5); - assert!(layout.guard(&version_5.data).is_ok()); - assert!(validate_humidifi_market_layout(&version_5).is_err()); } #[tokio::test] @@ -325,43 +284,6 @@ async fn humidifi_discovers_live_markets() { assert_eq!(decode_u64(&account.data, SCHEMA_VERSION_OFFSET, 0), 8); assert!(!market.label().is_empty()); } - let registry = TemplateRegistry::new(); - assert!( - !registry - .get("humidifi-fair-value") - .unwrap() - .constants - .contains_key("market") - ); - eprintln!( - "Discovered {} HumidiFi markets from program accounts", - markets.len() - ); -} - -/// The fair-value scale, checked against reality. Offset 576 is the quote-per-base ratio times -/// 2^48; decoding the live SOL/USDC market and converting through the mints' decimals must land in a -/// sane price band. A layout drift or a wrong scale (the earlier 2^47 guess was 2x off) blows past -/// this. The 2^48 scale itself was proven behaviorally: a live SOL/USDC swap executed at a rate that -/// equals the decoded fair value divided by 2^48, and the deployed program shifts by 48 (not 47). -#[tokio::test] -async fn humidifi_fair_value_scale_yields_a_sane_price() { - let market_account = fetch(&[SOL_USDC_MARKET]).await.remove(0); - let (base_mint, quote_mint) = HumidiFiMarket::mint_addresses(&market_account).expect("mints"); - let mints = fetch(&[base_mint, quote_mint]).await; - let market = HumidiFiMarket::validate(SOL_USDC_MARKET, &market_account, &mints[0], &mints[1]) - .expect("valid market"); - - let raw = decode_u64(&market_account.data, FAIR_VALUE_OFFSET, FAIR_VALUE_KEY); - // human = raw / 2^48 * 10^(base_decimals - quote_decimals) - let ratio = raw as f64 / (1u64 << 48) as f64; - let human = - ratio * 10f64.powi(i32::from(market.base_decimals) - i32::from(market.quote_decimals)); - eprintln!("HumidiFi {SOL_USDC_MARKET} decoded price ~= {human:.2} quote per base"); - assert!( - (1.0..100_000.0).contains(&human), - "SOL/USDC decoded to {human}, outside a sane band; the scale or the layout drifted" - ); } /// The builder's materialization path: prepare the live market locally, build the scenario, then @@ -379,13 +301,6 @@ async fn humidifi_builder_scenario_preserves_local_market_and_keeps_quote_fresh( let preparation = build_humidifi_fair_value_scenario(&market, "175.5").expect("build scenario"); let expected_fair_value = preparation.fair_value; - assert!(preparation.scenario.name.contains(&market.label())); - let [price, freshness] = &preparation.scenario.overrides[..] else { - panic!("expected price and freshness overrides"); - }; - assert!(!price.fetch_before_use); - assert!(!freshness.fetch_before_use); - assert!(freshness.persist); let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); let mut local_account = market_account.clone(); @@ -451,9 +366,9 @@ async fn humidifi_builder_scenario_preserves_local_market_and_keeps_quote_fresh( base_slot + 1, "the persisted freshness must track the slot" ); - assert_within( + assert_only_within( &diff_indices(&next, applied), - LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, + std::slice::from_ref(&(LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8)), "persisted freshness next slot", ); } @@ -583,18 +498,15 @@ fn token_account(mint: Pubkey, owner: Pubkey, amount: u64) -> Account { lamports: 2_039_280, data, owner: TOKEN, - executable: false, - rent_epoch: 0, + ..Account::default() } } fn system_account(lamports: u64) -> Account { Account { lamports, - data: vec![], owner: solana_pubkey::Pubkey::from_str_const("11111111111111111111111111111111"), - executable: false, - rent_epoch: 0, + ..Account::default() } } @@ -880,28 +792,6 @@ async fn humidifi_stale_quote_lands_the_rejection_boundary() { ); } } - - if configured_limit == 10 { - let default_age = template_raw_apply( - "humidifi-stale-quote", - std::collections::HashMap::from([( - "last_update_slot".to_string(), - serde_json::Value::Null, - )]), - clock, - &configured, - ); - assert_eq!( - decode_u64(&default_age, LAST_UPDATE_SLOT_OFFSET, STATE_KEY), - clock - 3, - ); - let output = run_swap(&programdata, &live, Some(clock), |data| *data = default_age) - .expect( - "the template's default age three must fill when the configured limit is ten", - ); - assert!(output > 0); - eprintln!("HumidiFi staleness limit=10 default age=3: WSOL output={output}"); - } } } @@ -1007,19 +897,19 @@ async fn humidifi_liquidity_builder_exhausts_only_the_selected_side() { let drained = prepared_liquidity(&live, &market, 0, 10_000).await; let base_vault = account(&drained, BASE_VAULT); assert_eq!(token_amount(&base_vault), 0); - assert_within( + assert_only_within( &live::diff_indices(&account(&live, BASE_VAULT).data, &base_vault.data), - 64..72, + std::slice::from_ref(&(64..72)), "drained base vault", ); assert_eq!(base_vault.lamports, account(&live, BASE_VAULT).lamports); assert_eq!(account(&drained, QUOTE_VAULT), account(&live, QUOTE_VAULT)); - assert_within( + assert_only_within( &live::diff_indices( &account(&live, SOL_USDC_MARKET).data, &account(&drained, SOL_USDC_MARKET).data, ), - LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8, + std::slice::from_ref(&(LAST_UPDATE_SLOT_OFFSET..LAST_UPDATE_SLOT_OFFSET + 8)), "liquidity scenario market", ); let error = run_swap(&programdata, &drained, None, |_| {}) diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 4df8e162c..f1134639b 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -19,12 +19,15 @@ use start_surfnet::StartSurfnetResponse; use surfpool_core::{ scenarios::{ TemplateRegistry, - protocols::humidifi::v1::{ - HumidiFiMarket, build_humidifi_fair_value_scenario, build_humidifi_liquidity_scenario, - discover_humidifi_markets, humidifi_vault_addresses, - }, - protocols::pump::v1::graduation_builder::{ - build_pump_graduation_scenario, pump_graduation_addresses, + protocols::{ + humidifi::v1::{ + HumidiFiMarket, build_humidifi_fair_value_scenario, + build_humidifi_liquidity_scenario, discover_humidifi_markets, + humidifi_vault_addresses, + }, + pump::v1::graduation_builder::{ + build_pump_graduation_scenario, pump_graduation_addresses, + }, }, }, solana_account::Account, @@ -1073,13 +1076,13 @@ impl Surfpool { .iter() .map(|market| { serde_json::json!({ - "address": market.address.to_string(), + "address": market.address().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, - "maxStalenessSlots": market.max_staleness_slots, + "baseMint": market.base_mint().to_string(), + "quoteMint": market.quote_mint().to_string(), + "baseDecimals": market.base_decimals(), + "quoteDecimals": market.quote_decimals(), + "maxStalenessSlots": market.max_staleness_slots(), }) }) .collect::>(); @@ -1089,63 +1092,58 @@ impl Surfpool { } #[tool( - description = "Creates one editable HumidiFi fair-value scenario for a live market. Reads the market and both mint accounts from the running surfnet to derive the 2^48-scaled quote-per-base ratio from their decimals. These reads cache the accounts in Surfnet. On Play, the scenario applies the requested price and keeps the quote fresh with fetchBeforeUse disabled to preserve local state. Prepares state; sends no swap. Resolve `market` through list_humidifi_markets." + description = "Creates one editable HumidiFi fair-value scenario for a live market. Reads the market and both mint accounts from the running surfnet to derive the 2^48-scaled quote-per-base ratio from their decimals. These reads cache the accounts in Surfnet. On Play, the scenario applies the requested price and keeps the quote fresh with fetchBeforeUse disabled to preserve local state. Prepares state; sends no swap. Resolve `market` through list_humidifi_markets. Not for staleness scenarios: those use the humidifi-stale-quote template through create_scenario." )] async fn create_humidifi_fair_value_scenario( &self, Parameters(params): Parameters, ) -> Result { - let market_address = match humidifi_market_address(¶ms.market) { - Ok(market) => market, - Err(error) => return Ok(scenario_tool_error(error)), - }; - - let accounts = match self - .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) - .await - { - Ok(accounts) => accounts, - Err(error) => return Ok(scenario_tool_error(error)), - }; - let Some(market_account) = accounts[0].as_ref() else { - return Ok(scenario_tool_error(format!( - "HumidiFi market account {market_address} was not found on the surfnet" - ))); - }; - let (base_mint, quote_mint) = match HumidiFiMarket::mint_addresses(market_account) { - Ok(mints) => mints, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; + match self.humidifi_fair_value_scenario(¶ms).await { + Ok(scenario) => self.stage_scenario(scenario).await, + Err(error) => Ok(scenario_tool_error(error)), + } + } - let mints = match self + async fn humidifi_fair_value_scenario( + &self, + params: &CreateHumidiFiFairValueScenarioParams, + ) -> Result { + let (market_address, market_account, (base_mint, quote_mint)) = self + .humidifi_market(¶ms.market, params.surfnet_port) + .await?; + let mints = self .fetch_surfnet_accounts(params.surfnet_port, &[base_mint, quote_mint]) - .await - { - Ok(mints) => mints, - Err(error) => return Ok(scenario_tool_error(error)), - }; - let (Some(base_account), Some(quote_account)) = (mints[0].as_ref(), mints[1].as_ref()) - else { - return Ok(scenario_tool_error(format!( + .await?; + let [Some(base_account), Some(quote_account)] = &mints[..] else { + return Err(format!( "HumidiFi market {market_address} references a mint that was not found on the surfnet" - ))); - }; - - let market = match HumidiFiMarket::validate( - market_address, - market_account, - base_account, - quote_account, - ) { - Ok(market) => market, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; - let preparation = match build_humidifi_fair_value_scenario(&market, ¶ms.price) { - Ok(preparation) => preparation, - Err(error) => return Ok(scenario_tool_error(error.to_string())), + )); }; + let market = + HumidiFiMarket::validate(market_address, &market_account, base_account, quote_account) + .map_err(|error| error.to_string())?; + let preparation = build_humidifi_fair_value_scenario(&market, ¶ms.price) + .map_err(|error| error.to_string())?; + Ok(preparation.scenario) + } - self.stage_scenario(preparation.scenario).await + /// Resolves and reads the selected market, returning its address, account and `(base, quote)` + /// mint addresses. + async fn humidifi_market( + &self, + market: &str, + surfnet_port: Option, + ) -> Result<(Pubkey, Account, (Pubkey, Pubkey)), String> { + let market_address = humidifi_market_address(market)?; + let mut accounts = self + .fetch_surfnet_accounts(surfnet_port, &[market_address]) + .await?; + let market_account = accounts.remove(0).ok_or_else(|| { + format!("HumidiFi market account {market_address} was not found on the surfnet") + })?; + let mints = + HumidiFiMarket::mint_addresses(&market_account).map_err(|error| error.to_string())?; + Ok((market_address, market_account, mints)) } #[tool( @@ -1155,81 +1153,54 @@ impl Surfpool { &self, Parameters(params): Parameters, ) -> Result { - let market_address = match humidifi_market_address(¶ms.market) { - Ok(market) => market, - Err(error) => return Ok(scenario_tool_error(error)), - }; - - let accounts = match self - .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) - .await - { - Ok(accounts) => accounts, - Err(error) => return Ok(scenario_tool_error(error)), - }; - let Some(market_account) = accounts[0].as_ref() else { - return Ok(scenario_tool_error(format!( - "HumidiFi market account {market_address} was not found on the surfnet" - ))); - }; - let (base_mint, quote_mint) = match HumidiFiMarket::mint_addresses(market_account) { - Ok(mints) => mints, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; - let [base_vault, quote_vault] = match humidifi_vault_addresses(market_account) { - Ok(vaults) => vaults, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; + match self.humidifi_liquidity_scenario(¶ms).await { + Ok(scenario) => self.stage_scenario(scenario).await, + Err(error) => Ok(scenario_tool_error(error)), + } + } - let graph = match self + async fn humidifi_liquidity_scenario( + &self, + params: &CreateHumidiFiLiquidityScenarioParams, + ) -> Result { + let (market_address, market_account, (base_mint, quote_mint)) = self + .humidifi_market(¶ms.market, params.surfnet_port) + .await?; + let [base_vault, quote_vault] = + humidifi_vault_addresses(&market_account).map_err(|error| error.to_string())?; + let graph = self .fetch_surfnet_accounts( params.surfnet_port, &[base_mint, quote_mint, base_vault, quote_vault], ) - .await - { - Ok(graph) => graph, - Err(error) => return Ok(scenario_tool_error(error)), - }; - let ( + .await?; + let [ Some(base_mint_account), Some(quote_mint_account), Some(base_vault_account), Some(quote_vault_account), - ) = ( - graph[0].as_ref(), - graph[1].as_ref(), - graph[2].as_ref(), - graph[3].as_ref(), - ) + ] = &graph[..] else { - return Ok(scenario_tool_error(format!( + return Err(format!( "HumidiFi market {market_address} references a mint or vault that was not found on the surfnet" - ))); + )); }; - - let market = match HumidiFiMarket::validate( + let market = HumidiFiMarket::validate( market_address, - market_account, + &market_account, base_mint_account, quote_mint_account, - ) { - Ok(market) => market, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; - let scenario = match build_humidifi_liquidity_scenario( + ) + .map_err(|error| error.to_string())?; + build_humidifi_liquidity_scenario( &market, - market_account, + &market_account, base_vault_account, quote_vault_account, params.base_remaining_bps, params.quote_remaining_bps, - ) { - Ok(scenario) => scenario, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; - - self.stage_scenario(scenario).await + ) + .map_err(|error| error.to_string()) } #[tool( @@ -1568,31 +1539,6 @@ mod tests { #[test] fn humidifi_tools_require_an_explicit_market() { - let fair_value = serde_json::json!({ "price": "100" }); - assert!( - serde_json::from_value::(fair_value).is_err() - ); - let liquidity = serde_json::json!({ - "baseRemainingBps": 500, - "quoteRemainingBps": 10000, - }); - assert!( - serde_json::from_value::(liquidity).is_err() - ); - - for schema in [ - schemars::schema_for!(CreateHumidiFiFairValueScenarioParams), - schemars::schema_for!(CreateHumidiFiLiquidityScenarioParams), - ] { - let schema = serde_json::to_value(schema).unwrap(); - assert!( - schema["required"] - .as_array() - .unwrap() - .contains(&serde_json::json!("market")) - ); - } - for market in ["", " "] { assert!( humidifi_market_address(market) @@ -1607,25 +1553,6 @@ mod tests { ); } - #[tokio::test] - async fn humidifi_liquidity_rejects_a_bad_market_before_any_rpc() { - let result = Surfpool::new() - .create_humidifi_liquidity_scenario(Parameters(CreateHumidiFiLiquidityScenarioParams { - market: "not-a-pubkey".to_string(), - base_remaining_bps: 500, - quote_remaining_bps: 10_000, - surfnet_port: None, - })) - .await - .expect("tool result"); - assert!( - json_of(&result)["error"] - .as_str() - .expect("error payload") - .contains("Invalid HumidiFi market pubkey") - ); - } - #[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 85820cfad..bb705c978 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1703,85 +1703,62 @@ mod tests { // A program that keeps every word as `plaintext XOR key`. The template value stays // plaintext; the engine masks it on write, so the account holds value ^ key. - let key: u64 = 0xb957_ed15_dc87_7426; - let layout = RawLayout { - account_size: 16, - magic: None, - }; - let mut property = Property::field("fair_value".to_string()); - property.offset = Some(8); - property.encoding = Some(RawEncoding::U64); - property.xor_mask = Some(key); - - let plaintext: u64 = 29_278_243_997_902; - let out = layout - .materialize( - &[0u8; 16], - &[property], - &HashMap::from([("fair_value".to_string(), json!(plaintext.to_string()))]), - 0, - ) - .expect("masked write"); - - let stored = u64::from_le_bytes(out[8..16].try_into().unwrap()); - assert_eq!( - stored, - plaintext ^ key, - "the account must hold the masked word" - ); - assert_eq!( - stored ^ key, - plaintext, - "unmasking reproduces the plaintext" - ); - } - - #[test] - fn xor_mask_masks_a_materialization_slot() { - use super::{Property, RawEncoding, RawLayout}; + fn masked_property(encoding: RawEncoding, key: u64) -> Property { + let mut property = Property::field("word".to_string()); + property.offset = Some(0); + property.encoding = Some(encoding); + property.xor_mask = Some(key); + property + } - let key: u64 = 0x6e9d_e2b3_0b19_f1ea; let layout = RawLayout { account_size: 8, magic: None, }; - let mut property = Property::field("last_update_slot".to_string()); - property.offset = Some(0); - property.encoding = Some(RawEncoding::Slot { lead: 0 }); - property.xor_mask = Some(key); - - let out = layout - .materialize( - &[0u8; 8], - &[property.clone()], - &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + let fair_value_key: u64 = 0xb957_ed15_dc87_7426; + let state_key: u64 = 0x6e9d_e2b3_0b19_f1ea; + for (encoding, key, value, slot, plaintext) in [ + ( + RawEncoding::U64, + fair_value_key, + json!("29278243997902"), + 0, + 29_278_243_997_902u64, + ), + ( + RawEncoding::Slot { lead: 0 }, + state_key, + serde_json::Value::Null, 444_223_940, - ) - .expect("masked slot write"); - assert_eq!( - u64::from_le_bytes(out[0..8].try_into().unwrap()) ^ key, - 444_223_940, - "the slot must round-trip through the mask" - ); - - // A negative lead ages the quote, still masked. - property.encoding = Some(RawEncoding::Slot { lead: -3 }); - let out = layout - .materialize( - &[0u8; 8], - &[property], - &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), 444_223_940, - ) - .expect("masked stale slot"); - assert_eq!( - u64::from_le_bytes(out[0..8].try_into().unwrap()) ^ key, - 444_223_937 - ); + ), + // A negative lead ages the quote, still masked. + ( + RawEncoding::Slot { lead: -3 }, + state_key, + serde_json::Value::Null, + 444_223_940, + 444_223_937, + ), + ] { + let out = layout + .materialize( + &[0u8; 8], + &[masked_property(encoding, key)], + &HashMap::from([("word".to_string(), value)]), + slot, + ) + .expect("masked write"); + assert_eq!( + u64::from_le_bytes(out[0..8].try_into().unwrap()), + plaintext ^ key, + "the account must hold the masked word for {plaintext}" + ); + } } #[test] - fn xor_mask_leaves_unmasked_properties_untouched_and_rejects_narrow_encodings() { + fn xor_mask_rejects_narrow_encodings() { use super::{Property, RawEncoding, RawLayout}; let layout = RawLayout { @@ -1789,20 +1766,6 @@ mod tests { magic: None, }; - // No mask: bytes are written verbatim, exactly as before this feature existed. - let mut plain = Property::field("value".to_string()); - plain.offset = Some(0); - plain.encoding = Some(RawEncoding::U64); - let out = layout - .materialize( - &[0u8; 16], - &[plain], - &HashMap::from([("value".to_string(), json!(7u64.to_string()))]), - 0, - ) - .expect("plain write"); - assert_eq!(u64::from_le_bytes(out[0..8].try_into().unwrap()), 7); - // A mask on a non-8-byte encoding is a template error, not a silent half-write. let mut narrow = Property::field("small".to_string()); narrow.offset = Some(0); From bf0758a1567cb1bf677cfb2e4895f3067cd9d162 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 18 Sep 2026 08:45:07 +0300 Subject: [PATCH 5/5] docs(humidifi): say that the freshness override is written once --- crates/core/src/scenarios/protocols/humidifi/README.md | 8 ++++---- .../src/scenarios/protocols/humidifi/v1/fair_value.rs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/core/src/scenarios/protocols/humidifi/README.md b/crates/core/src/scenarios/protocols/humidifi/README.md index 6d339a623..001120dbf 100644 --- a/crates/core/src/scenarios/protocols/humidifi/README.md +++ b/crates/core/src/scenarios/protocols/humidifi/README.md @@ -83,10 +83,10 @@ datasource. It stages the result through the shared scenario path. Both builders leave every override at `fetchBeforeUse: false`: the creation reads already cached the accounts in Surfnet, and Play applies the values to that local state. Refetching would replace earlier local edits and the state the amounts were calculated from. Freshness passes a `null` -value, so the encoder uses its zero lead and writes the preparation slot itself; it is applied once, -and a scenario that runs past the market's staleness window refreshes it again at a later slot. When composing -templates directly, set `fetchBeforeUse: true` on the first override for each account that has not -already been prepared. +value, so the encoder uses its zero lead and writes the preparation slot itself. The builder applies +it once, and a scenario extended past the market's staleness window needs another freshness override +of its own. When composing templates directly, set `fetchBeforeUse: true` on the first override for +each account that has not already been prepared. `build_humidifi_liquidity_scenario` scales the market's vault balances through the generic `spl-token-account-balance` template, one override per side that changes, from 0 to 10000 remaining diff --git a/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs b/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs index 58c78a2ca..aaeeba991 100644 --- a/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs +++ b/crates/core/src/scenarios/protocols/humidifi/v1/fair_value.rs @@ -236,8 +236,8 @@ pub fn build_humidifi_fair_value_scenario( } /// Applied once, at the scenario's own slot: nothing on a fork republishes the quote, and nothing -/// overwrites it either. A scenario that spans enough slots to age past the market's window -/// refreshes it again at a later slot. +/// overwrites it either. Play pauses the clock there, so a swap in that slot reads a fresh quote. +/// Running past the market's window takes a second freshness override; the builder adds only one. pub(super) fn freshness_override( registry: &TemplateRegistry, market: &Pubkey,