Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 72 additions & 6 deletions crates/cli/src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -266,3 +274,61 @@ async fn dist(path: web::Path<String>) -> 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"
);
}
Comment on lines +290 to +333

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Test only covers GET; POST/DELETE method mismatches on unknown paths are untested

The catch-all uses web::route() (no method filter), so all HTTP methods on unknown /v1/* paths correctly return a JSON 404. The test only exercises GET requests, which means the case where a client sends POST or DELETE to a mistyped URL is not covered. Adding at least one non-GET assertion would solidify the guarantee that the scope's default_service is truly method-agnostic.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/cli/src/http/mod.rs
Line: 290-319

Comment:
**Test only covers GET; POST/DELETE method mismatches on unknown paths are untested**

The catch-all uses `web::route()` (no method filter), so all HTTP methods on unknown `/v1/*` paths correctly return a JSON 404. The test only exercises GET requests, which means the case where a client sends POST or DELETE to a mistyped URL is not covered. Adding at least one non-GET assertion would solidify the guarantee that the scope's `default_service` is truly method-agnostic.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid — added a POST assertion in c2c4e65.

}
7 changes: 5 additions & 2 deletions crates/core/src/rpc/surfnet_cheatcodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,12 +735,15 @@ pub trait SurfnetCheatcodes {
limit: Option<u64>,
) -> BoxFuture<Result<RpcResponse<Vec<RpcLogsResponse>>>>;

/// 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).
///
Expand Down
55 changes: 52 additions & 3 deletions crates/core/src/scenarios/protocols/pyth/v2/overrides.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
157 changes: 145 additions & 12 deletions crates/core/src/surfnet/locker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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};
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<String, serde_json::Value> = 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};
Expand Down
3 changes: 3 additions & 0 deletions crates/mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ tracing = { workspace = true }

surfpool-core = { workspace = true }
surfpool-types = { workspace = true }

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
Loading