diff --git a/Cargo.lock b/Cargo.lock index 20260545..1f55ae1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12090,6 +12090,7 @@ dependencies = [ "spl-token-interface 2.0.0", "surfpool-core", "surfpool-types", + "tokio", "tracing", ] diff --git a/crates/cli/src/http/mod.rs b/crates/cli/src/http/mod.rs index 5673fab5..f010b661 100644 --- a/crates/cli/src/http/mod.rs +++ b/crates/cli/src/http/mod.rs @@ -41,6 +41,19 @@ use crate::cli::Context; #[folder = "../../../explorer/.next/server/app"] pub struct Asset; +/// Registers the studio API routes. Shared between the server and its tests +fn configure_api(cfg: &mut web::ServiceConfig) { + cfg.service(get_config) + .service(get_scenario_templates) + .service(post_scenarios) + .service(get_scenarios) + .service(delete_scenario) + .service(patch_scenario) + // Unknown /v1/* paths must fail loudly here: otherwise the studio + // SPA fallback answers them with index.html and a misleading 200 + .service(web::scope("/v1").default_service(web::route().to(api_not_found))); +} + pub async fn start_studio_and_scenario_server( network_binding: String, config: SanitizedConfig, @@ -78,12 +91,7 @@ pub async fn start_studio_and_scenario_server( ) .wrap(middleware::Compress::default()) .wrap(middleware::Logger::default()) - .service(get_config) - .service(get_scenario_templates) - .service(post_scenarios) - .service(get_scenarios) - .service(delete_scenario) - .service(patch_scenario) + .configure(configure_api) .service(web::scope("/mcp").service(mcp_service.clone().scope())); if enable_studio { @@ -266,3 +274,61 @@ async fn dist(path: web::Path) -> impl Responder { }; handle_embedded_file(path_str) } + +async fn api_not_found() -> HttpResponse { + HttpResponse::NotFound() + .content_type("application/json") + .body(r#"{"error":"not found"}"#) +} + +#[cfg(test)] +mod tests { + use actix_web::{App, test}; + + use super::*; + + #[actix_web::test] + async fn unknown_v1_paths_return_json_404_instead_of_spa_fallback() { + let loaded_scenarios = Data::new(RwLock::new(LoadedScenarios::new())); + let app = test::init_service( + App::new() + .app_data(loaded_scenarios) + .configure(configure_api) + .service(surfpool_studio_ui::serve_studio_static_files), + ) + .await; + + for path in ["/v1/scenarios/some-id", "/v1/nonexistent"] { + let request = test::TestRequest::get().uri(path).to_request(); + let response = test::call_service(&app, request).await; + assert_eq!(response.status(), 404, "expected 404 for {path}"); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/json", + "expected JSON body for {path}" + ); + } + + let request = test::TestRequest::post() + .uri("/v1/nonexistent") + .to_request(); + let response = test::call_service(&app, request).await; + assert_eq!( + response.status(), + 404, + "the guard must catch non-GET methods too" + ); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/json", + ); + + let request = test::TestRequest::get().uri("/v1/scenarios").to_request(); + let response = test::call_service(&app, request).await; + assert_eq!( + response.status(), + 200, + "registered endpoints must keep working" + ); + } +} diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index 1c2b0a96..32b718c3 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -735,12 +735,15 @@ pub trait SurfnetCheatcodes { limit: Option, ) -> BoxFuture>>>; - /// A cheat code to jump forward or backward in time on the local network. + /// A cheat code to jump forward in time on the local network. The clock only moves + /// forward: a target in the past is rejected. /// Useful for testing epoch-based or time-sensitive logic. /// /// ## Parameters /// - `config` (optional): A `TimeTravelConfig` specifying how to modify the clock: - /// - `absoluteTimestamp(u64)`: Moves time to the specified UNIX timestamp. + /// - `absoluteTimestamp(u64)`: Moves time to the specified UNIX timestamp in + /// **milliseconds** (like JavaScript `Date.now()`). Note that the `Clock` sysvar + /// programs read is in seconds, so a value taken from it must be multiplied by 1000. /// - `absoluteSlot(u64)`: Moves to the specified absolute slot. /// - `absoluteEpoch(u64)`: Advances time to the specified epoch (each epoch = 432,000 slots). /// diff --git a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml index 3650d32a..31b5ba13 100644 --- a/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml +++ b/crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml @@ -196,13 +196,34 @@ templates: description: Override any Pyth price feed with custom price data idl_account_name: PriceUpdateV2 properties: - - path: price_message.price - label: Price - description: The price value - path: feed_id type: constant_ref label: Price Feed constant: price_feed + - path: price_message.price + label: Price + description: Price as a scaled integer - multiply the USD value by 10^decimals + - path: price_message.conf + label: Confidence + description: Uncertainty band around the price, in the same scale as price + - path: price_message.exponent + label: Exponent + description: Decimal exponent, normally -8 and -10 on some feeds. Rescales every price field + - path: price_message.publish_time + label: Publish Time + description: Unix seconds of this update, interpreted against the Surfnet clock + - path: price_message.prev_publish_time + label: Previous Publish Time + description: Unix seconds of the preceding update + - path: price_message.ema_price + label: EMA Price + description: Exponential moving average price, in the same scale as price + - path: price_message.ema_conf + label: EMA Confidence + description: Exponential moving average confidence, in the same scale as conf + - path: posted_slot + label: Posted Slot + description: Slot the update was posted at, for consumers that measure freshness in slots llm_context: | CRITICAL: Always set fetchBeforeUse: true for Pyth price feeds! This fetches the current account data from mainnet before applying your price override. @@ -224,6 +245,34 @@ templates: EXAMPLE SCENARIO - "SOL crashes from $145 to $85": Override 1 (slot 0): feed_id=SOL/USD, price_message.price=14500000000 Override 2 (slot 1): feed_id=SOL/USD, price_message.price=8500000000 + + FIELDS OTHER THAN PRICE: + - price_message.conf is the uncertainty band, in the SAME scale as price. Consumers reject a + quote when confidence is too wide relative to the price. On an 8 decimal feed, $2 of + uncertainty is 200000000. + - price_message.exponent rescales price, conf, ema_price and ema_conf together. Leave it alone + unless the scenario is specifically about an exponent error. If you do change it, restate + every scaled field to match. + - price_message.ema_price and price_message.ema_conf use the same scales as price and conf. + - posted_slot is a slot number, not a timestamp. Some consumers measure freshness in slots + rather than in seconds. + + TIME FIELDS - READ BEFORE SETTING publish_time: + publish_time and prev_publish_time are unix seconds interpreted against the SURFNET clock, not + the real-world clock. The two can differ by an arbitrary amount, so a timestamp taken from the + real current time will look far in the future inside the simulation and will NOT produce a + stale price. + + Do not try to backdate the timestamp either - the Surfnet clock only moves forward. To build a + stale-price scenario, make the price fresh and then advance time past the consumer's limit: + 1. surfnet_pauseClock so the elapsed time is exact rather than dependent on slot production + 2. read the current Surfnet clock and set publish_time to it, which is a fresh price + 3. surfnet_timeTravel forward by more than the consumer's staleness limit + 4. send the transaction - the consumer now sees an aged price and should reject it + + UNITS WARNING for step 3: surfnet_timeTravel's absoluteTimestamp is in MILLISECONDS, while + publish_time and the Clock sysvar are in SECONDS. Multiply by 1000 when passing a value read + from the clock, or the call fails with a "past timestamp" error. address: type: pda program_id: pythWSnswVUd12oZpeFP8e9CVaEqJg25g1Vtc2biRsT diff --git a/crates/core/src/surfnet/locker.rs b/crates/core/src/surfnet/locker.rs index 4b619900..1052b447 100644 --- a/crates/core/src/surfnet/locker.rs +++ b/crates/core/src/surfnet/locker.rs @@ -4071,6 +4071,23 @@ mod tests { surfnet::{BlockHeader, SurfnetSvm, svm::apply_override_to_decoded_account}, }; + /// A real `PriceUpdateV2` account. Its `VerificationLevel` is the one-byte `Full` variant and + /// it ends in a padding byte, which is what fixes the offsets the tests below assert on. + fn pyth_price_update_v2_fixture() -> Vec { + vec![ + 0x22, 0xf1, 0x23, 0x63, 0x9d, 0x7e, 0xf4, 0xcd, // Discriminator + 0x35, 0xa7, 0x0c, 0x11, 0x16, 0x2f, 0xbf, 0x5a, 0x0e, 0x7f, 0x7d, 0x2f, 0x96, 0xe1, + 0x9f, 0x97, 0xb0, 0x22, 0x46, 0xa1, 0x56, 0x87, 0xee, 0x67, 0x27, 0x94, 0x89, 0x74, + 0x48, 0xe6, 0x58, 0xde, 0x01, 0xe6, 0x2d, 0xf6, 0xc8, 0xb4, 0xa8, 0x5f, 0xe1, 0xa6, + 0x7d, 0xb4, 0x4d, 0xc1, 0x2d, 0xe5, 0xdb, 0x33, 0x0f, 0x7a, 0xc6, 0x6b, 0x72, 0xdc, + 0x65, 0x8a, 0xfe, 0xdf, 0x0f, 0x4a, 0x41, 0x5b, 0x43, 0xd7, 0x1f, 0x18, 0x64, 0x5f, + 0x0a, 0x00, 0x00, 0x96, 0x67, 0xea, 0xc5, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, + 0xff, 0x5f, 0x2b, 0x00, 0x69, 0x00, 0x00, 0x00, 0x00, 0x5e, 0x2b, 0x00, 0x69, 0x00, + 0x00, 0x00, 0x00, 0xa0, 0x7c, 0x1a, 0x38, 0x63, 0x0a, 0x00, 0x00, 0x94, 0xa6, 0xb9, + 0xb5, 0x00, 0x00, 0x00, 0x00, 0x8c, 0x5e, 0x6d, 0x16, 0x00, 0x00, 0x00, 0x00, 0x00, + ] + } + #[test] fn test_get_forged_account_data_with_pyth_fixture() { use borsh::{BorshDeserialize, BorshSerialize}; @@ -4103,18 +4120,7 @@ mod tests { } // Pyth price feed account data fixture - let account_data_hex = vec![ - 0x22, 0xf1, 0x23, 0x63, 0x9d, 0x7e, 0xf4, 0xcd, // Discriminator - 0x35, 0xa7, 0x0c, 0x11, 0x16, 0x2f, 0xbf, 0x5a, 0x0e, 0x7f, 0x7d, 0x2f, 0x96, 0xe1, - 0x9f, 0x97, 0xb0, 0x22, 0x46, 0xa1, 0x56, 0x87, 0xee, 0x67, 0x27, 0x94, 0x89, 0x74, - 0x48, 0xe6, 0x58, 0xde, 0x01, 0xe6, 0x2d, 0xf6, 0xc8, 0xb4, 0xa8, 0x5f, 0xe1, 0xa6, - 0x7d, 0xb4, 0x4d, 0xc1, 0x2d, 0xe5, 0xdb, 0x33, 0x0f, 0x7a, 0xc6, 0x6b, 0x72, 0xdc, - 0x65, 0x8a, 0xfe, 0xdf, 0x0f, 0x4a, 0x41, 0x5b, 0x43, 0xd7, 0x1f, 0x18, 0x64, 0x5f, - 0x0a, 0x00, 0x00, 0x96, 0x67, 0xea, 0xc5, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, - 0xff, 0x5f, 0x2b, 0x00, 0x69, 0x00, 0x00, 0x00, 0x00, 0x5e, 0x2b, 0x00, 0x69, 0x00, - 0x00, 0x00, 0x00, 0xa0, 0x7c, 0x1a, 0x38, 0x63, 0x0a, 0x00, 0x00, 0x94, 0xa6, 0xb9, - 0xb5, 0x00, 0x00, 0x00, 0x00, 0x8c, 0x5e, 0x6d, 0x16, 0x00, 0x00, 0x00, 0x00, 0x00, - ]; + let account_data_hex = pyth_price_update_v2_fixture(); // Create a minimal Pyth IDL for testing let idl: Idl = serde_json::from_str(PYTH_V2_IDL_CONTENT).expect("Failed to load IDL"); @@ -4552,6 +4558,133 @@ mod tests { } } + /// Mirrors the `pyth-price-feed-v2` template: a property added there needs a case here. + #[test] + fn test_get_forged_account_data_overrides_all_pyth_price_feed_fields() { + use solana_account_decoder::UiAccountData; + + let account_data = pyth_price_update_v2_fixture(); + let idl: Idl = serde_json::from_str(PYTH_V2_IDL_CONTENT).expect("Failed to load IDL"); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let svm_locker = SurfnetSvmLocker::new(surfnet_svm); + let account_pubkey = Pubkey::from_str_const("rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"); + svm_locker.register_idl(idl.clone(), None).unwrap(); + + // feed_id is omitted because it is a PDA seed reference, which the override path strips + // before writing. Every value differs from the fixture's so a no-op write cannot pass. + let new_price = 9_100_000_000_000i64; + let new_conf = 500_000_000u64; + let new_exponent = -10i32; + let new_publish_time = 1_800_000_000i64; + let new_prev_publish_time = 1_799_999_940i64; + let new_ema_price = 9_050_000_000_000i64; + let new_ema_conf = 450_000_000u64; + let new_posted_slot = 500_000_000u64; + + let mut overrides: HashMap = HashMap::new(); + overrides.insert("price_message.price".into(), json!(new_price)); + overrides.insert("price_message.conf".into(), json!(new_conf)); + overrides.insert("price_message.exponent".into(), json!(new_exponent)); + overrides.insert("price_message.publish_time".into(), json!(new_publish_time)); + overrides.insert( + "price_message.prev_publish_time".into(), + json!(new_prev_publish_time), + ); + overrides.insert("price_message.ema_price".into(), json!(new_ema_price)); + overrides.insert("price_message.ema_conf".into(), json!(new_ema_conf)); + overrides.insert("posted_slot".into(), json!(new_posted_slot)); + + let forged = svm_locker + .get_forged_account_data(&account_pubkey, &account_data, &idl, &overrides) + .expect("forging should succeed for every field the template exposes"); + + // price starts at byte 73, so nothing before it is named by the overrides. + assert_eq!( + forged.len(), + account_data.len(), + "forged account should keep its original length" + ); + assert_eq!( + &forged[..73], + &account_data[..73], + "bytes ahead of the price field should be untouched" + ); + + let forged_account = Account { + lamports: 1_000_000, + data: forged, + owner: account_pubkey, + executable: false, + rent_epoch: 0, + }; + let ui_account = svm_locker.encode_ui_account( + &account_pubkey, + &forged_account, + UiAccountEncoding::JsonParsed, + None, + None, + ); + + match &ui_account.data { + UiAccountData::Json(parsed_account) => { + let parsed = &parsed_account.parsed; + let price_message = parsed + .get("price_message") + .expect("Should have price_message field") + .as_object() + .expect("price_message should be an object"); + + let field = |name: &str| -> i64 { + price_message + .get(name) + .unwrap_or_else(|| panic!("Should have {name} field")) + .as_i64() + .unwrap_or_else(|| panic!("{name} should be a number")) + }; + + assert_eq!(field("price"), new_price, "price should be overridden"); + assert_eq!(field("conf"), new_conf as i64, "conf should be overridden"); + assert_eq!( + field("exponent"), + new_exponent as i64, + "exponent should be overridden" + ); + assert_eq!( + field("publish_time"), + new_publish_time, + "publish_time should be overridden" + ); + assert_eq!( + field("prev_publish_time"), + new_prev_publish_time, + "prev_publish_time should be overridden" + ); + assert_eq!( + field("ema_price"), + new_ema_price, + "ema_price should be overridden" + ); + assert_eq!( + field("ema_conf"), + new_ema_conf as i64, + "ema_conf should be overridden" + ); + + let posted_slot = parsed + .get("posted_slot") + .expect("Should have posted_slot field") + .as_u64() + .expect("posted_slot should be a number"); + assert_eq!( + posted_slot, new_posted_slot, + "posted_slot should be overridden" + ); + } + _ => panic!("Expected JSON parsed account data"), + } + } + #[test] fn test_apply_override_to_decoded_account() { use txtx_addon_kit::{indexmap::IndexMap, types::types::Value}; diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 4aa08b49..0ef4ebe2 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -30,3 +30,6 @@ tracing = { workspace = true } surfpool-core = { workspace = true } surfpool-types = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 1f836447..deacdddf 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -42,6 +42,67 @@ pub struct SetTokenAccountsParams { pub token_params_with_owner: Vec, } +/// Page size of search_constant_options results. The exact value is a +/// judgment call: big enough that an ambiguous query ("USD") still returns a +/// useful choice, small enough that a response stays cheap in LLM context. +/// `totalMatches`/`truncated` tell the model to refine the query when more +/// options exist. +const MAX_SEARCH_RESULTS: usize = 20; + +/// Compact template JSON shared by the get_override_templates tool and the +/// str:///override_templates resource. Constant options are summarized to a +/// count: inlined option lists run to hundreds of entries and are duplicated +/// across templates, pushing LLM clients past context and rate limits. Models +/// resolve concrete values through search_constant_options instead. +fn compact_template_json(template: &surfpool_types::OverrideTemplate) -> serde_json::Value { + let constants: serde_json::Map = template + .constants + .iter() + .map(|(name, def)| { + ( + name.clone(), + serde_json::json!({ + "label": def.label, + "description": def.description, + "optionsCount": def.options.len(), + }), + ) + }) + .collect(); + + let mut obj = serde_json::json!({ + "id": template.id, + "name": template.name, + "description": template.description, + "protocol": template.protocol, + "accountType": template.account_type, + "properties": template.properties, + "address": template.address, + "constants": constants, + "tags": template.tags + }); + if let Some(ref ctx) = template.llm_context { + obj["llmContext"] = serde_json::Value::String(ctx.clone()); + } + obj +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SearchConstantOptionsParams { + #[schemars( + description = "Template id from get_override_templates (e.g., \"pyth-price-feed-v2\")." + )] + pub template_id: String, + #[schemars( + description = "Name of the constant to search in (e.g., \"price_feed\", \"openbook_market\"). Omit to search every constant on the template." + )] + pub constant: Option, + #[schemars( + description = "Case-insensitive text matched against option id, label, description and value (e.g., \"SOL/USD\"). An empty string returns the first options." + )] + pub query: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct StartSurfnetWithTokenAccountsParams { #[schemars( @@ -546,7 +607,7 @@ impl Surfpool { 1. `templateId` MUST exactly match a template's `id` field (e.g., "raydium-clmm-custom", NOT "raydium_clmm_v1") 2. `values` keys MUST be from the template's `properties` array 3. For PDA addresses, DO NOT provide `account` - it will be generated from template + values - 4. For constant_ref properties (like feed_id), the value MUST be from the template's constants options + 4. For constant_ref properties (like feed_id), the value MUST come from search_constant_options results CORRECT JSON STRUCTURE FOR PYTH PRICE FEED: { @@ -571,7 +632,7 @@ impl Surfpool { } NOTE: The `account` field will be auto-generated from the template's PDA configuration. - For Pyth feeds, use feed_id values from get_override_templates constants. + For Pyth feeds, resolve the feed_id value via search_constant_options (e.g., query "SOL/USD"). "#)] async fn create_scenario( &self, @@ -798,7 +859,7 @@ impl Surfpool { } #[tool( - description = "Fetches ALL available override templates. MUST be called before create_scenario to get valid templateId values, property names, and account addresses." + 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." )] async fn get_override_templates(&self) -> Result { let registry = self.template_registry.read().map_err(|_| { @@ -810,34 +871,100 @@ impl Surfpool { } })?; - // Return compact version without full IDL to avoid token limits let templates: Vec = registry .all() .iter() - .map(|t| { - let mut obj = serde_json::json!({ - "id": t.id, - "name": t.name, - "description": t.description, - "protocol": t.protocol, - "accountType": t.account_type, - "properties": t.properties, - "address": t.address, - "constants": t.constants, - "tags": t.tags - }); - // Include llm_context if present - if let Some(ref ctx) = t.llm_context { - obj["llmContext"] = serde_json::Value::String(ctx.clone()); - } - obj - }) + .map(|t| compact_template_json(t)) .collect(); let json_str = serde_json::to_string(&templates).unwrap_or_default(); Ok(CallToolResult::success(vec![Content::text(json_str)])) } + #[tool( + description = "Searches the options of a template's constants (price feeds, markets, token mints). Use after get_override_templates to resolve a constant_ref value: pass the templateId, optionally the constant name, and a query like \"SOL/USD\". Returns matching options whose `value` field is what create_scenario expects." + )] + async fn search_constant_options( + &self, + Parameters(params): Parameters, + ) -> Result { + let registry = self.template_registry.read().map_err(|_| { + use std::borrow::Cow; + McpError { + code: ErrorCode(-32603), + message: Cow::from("Failed to read template registry"), + data: None, + } + })?; + + let all_templates = registry.all(); + let Some(template) = all_templates.iter().find(|t| t.id == params.template_id) else { + let valid_ids: Vec<&String> = all_templates.iter().map(|t| &t.id).collect(); + return Ok(CallToolResult::error(vec![Content::text(format!( + "Unknown templateId {:?}. Valid IDs are: {:?}", + params.template_id, valid_ids + ))])); + }; + + if let Some(ref wanted) = params.constant { + if !template.constants.contains_key(wanted) { + let available: Vec<&String> = template.constants.keys().collect(); + return Ok(CallToolResult::error(vec![Content::text(format!( + "Template {:?} has no constant {:?}. Available constants: {:?}", + params.template_id, wanted, available + ))])); + } + } + + let query = params.query.to_lowercase(); + let mut results = Vec::new(); + let mut total_matches = 0usize; + + // HashMap iteration order is random per process; sort so paged results + // are stable across calls for templates with several constants + let mut constants: Vec<_> = template.constants.iter().collect(); + constants.sort_by(|a, b| a.0.cmp(b.0)); + for (constant_name, def) in constants { + if let Some(ref wanted) = params.constant { + if constant_name != wanted { + continue; + } + } + for opt in &def.options { + let matches = query.is_empty() + || opt.id.to_lowercase().contains(&query) + || opt.label.to_lowercase().contains(&query) + || opt.value.to_lowercase().contains(&query) + || opt + .description + .as_deref() + .is_some_and(|d| d.to_lowercase().contains(&query)); + if matches { + total_matches += 1; + if results.len() < MAX_SEARCH_RESULTS { + results.push(serde_json::json!({ + "constant": constant_name, + "id": opt.id, + "label": opt.label, + "description": opt.description, + "value": opt.value, + "metadata": opt.metadata, + })); + } + } + } + } + + let response = serde_json::json!({ + "templateId": params.template_id, + "results": results, + "totalMatches": total_matches, + "truncated": total_matches > results.len(), + }); + let json_str = serde_json::to_string(&response).unwrap_or_default(); + Ok(CallToolResult::success(vec![Content::text(json_str)])) + } + #[tool( description = "Translates a token symbol (e.g., 'USDC', 'SOL', 'JUP') into its mint address. Uses the verified tokens list." )] @@ -935,28 +1062,10 @@ impl ServerHandler for Surfpool { } })?; - // Return compact version without full IDL to avoid token limits let templates: Vec = registry .all() .iter() - .map(|t| { - let mut obj = serde_json::json!({ - "id": t.id, - "name": t.name, - "description": t.description, - "protocol": t.protocol, - "accountType": t.account_type, - "properties": t.properties, - "address": t.address, - "constants": t.constants, - "tags": t.tags - }); - // Include llm_context if present - if let Some(ref ctx) = t.llm_context { - obj["llmContext"] = serde_json::Value::String(ctx.clone()); - } - obj - }) + .map(|t| compact_template_json(t)) .collect(); let templates_json = serde_json::to_string(&templates).map_err(|_| { @@ -997,3 +1106,129 @@ impl ServerHandler for Surfpool { Ok(self.get_info()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn json_of(result: &CallToolResult) -> serde_json::Value { + let text = &result.content[0].as_text().expect("text content").text; + serde_json::from_str(text).expect("valid JSON payload") + } + + fn search( + template_id: &str, + constant: Option<&str>, + query: &str, + ) -> Parameters { + Parameters(SearchConstantOptionsParams { + template_id: template_id.to_string(), + constant: constant.map(str::to_string), + query: query.to_string(), + }) + } + + #[tokio::test] + async fn get_override_templates_summarizes_constants_instead_of_inlining_options() { + let surfpool = Surfpool::new(); + let result = surfpool.get_override_templates().await.unwrap(); + assert_ne!(result.is_error, Some(true)); + + let templates = json_of(&result); + let pyth = templates + .as_array() + .unwrap() + .iter() + .find(|t| t["id"] == "pyth-price-feed-v2") + .expect("pyth template present"); + + let price_feed = &pyth["constants"]["price_feed"]; + assert!( + price_feed["optionsCount"].as_u64().unwrap() > 0, + "summary must report how many options exist" + ); + assert!( + price_feed.get("options").is_none(), + "options must not be inlined; they blow past LLM token limits" + ); + } + + #[tokio::test] + async fn search_finds_a_feed_case_insensitively_and_returns_usable_values() { + let surfpool = Surfpool::new(); + let result = surfpool + .search_constant_options(search("pyth-price-feed-v2", None, "sOl/UsD")) + .await + .unwrap(); + assert_ne!(result.is_error, Some(true)); + + let payload = json_of(&result); + let results = payload["results"].as_array().unwrap(); + assert!( + results + .iter() + .any(|r| r["label"].as_str().unwrap().eq_ignore_ascii_case("sol/usd")), + "SOL/USD must match a case-insensitive query" + ); + assert!( + results + .iter() + .all(|r| !r["value"].as_str().unwrap().is_empty()), + "every result must carry the value create_scenario expects" + ); + } + + #[tokio::test] + async fn search_truncates_at_max_results_and_reports_the_full_total() { + let surfpool = Surfpool::new(); + let result = surfpool + .search_constant_options(search("pyth-price-feed-v2", Some("price_feed"), "")) + .await + .unwrap(); + + let payload = json_of(&result); + let returned = payload["results"].as_array().unwrap().len(); + let total = payload["totalMatches"].as_u64().unwrap() as usize; + assert!(returned <= MAX_SEARCH_RESULTS); + assert_eq!(payload["truncated"].as_bool().unwrap(), total > returned); + assert!( + total > MAX_SEARCH_RESULTS, + "the pyth feed list is expected to exceed one page; if this fails the \ + truncation branch is no longer covered" + ); + assert_eq!(returned, MAX_SEARCH_RESULTS); + } + + #[test] + fn compact_template_json_never_inlines_constant_options() { + let registry = TemplateRegistry::new(); + for template in registry.all() { + let json = compact_template_json(template); + for (name, constant) in json["constants"].as_object().unwrap() { + assert!( + constant.get("options").is_none(), + "constant {name} of template {} leaks inlined options", + template.id + ); + assert!(constant.get("optionsCount").is_some()); + } + } + } + + #[tokio::test] + async fn search_rejects_unknown_template_and_unknown_constant() { + let surfpool = Surfpool::new(); + + let unknown_template = surfpool + .search_constant_options(search("no-such-template", None, "x")) + .await + .unwrap(); + assert_eq!(unknown_template.is_error, Some(true)); + + let unknown_constant = surfpool + .search_constant_options(search("pyth-price-feed-v2", Some("no-such-constant"), "x")) + .await + .unwrap(); + assert_eq!(unknown_constant.is_error, Some(true)); + } +}