Skip to content
Open
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
2,010 changes: 1,151 additions & 859 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ ratatui = { version = "0.29.0", features = [
"crossterm",
], default-features = false }
reqwest = { version = "0.12.23", default-features = false }
rmcp = "0.9.0"
rmcp = "=0.9.0"
rmcp-actix-web = "=0.8.17"
rust-embed = "8.2.0"
schemars = { version = "1.0.4" }
schemars_derive = { version = "1.0.4" }
Expand Down
2 changes: 2 additions & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ url = { workspace = true }
surfpool-core = { workspace = true }
surfpool-gql = { workspace = true }
surfpool-mcp = { workspace = true }
rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] }
rmcp-actix-web = { workspace = true }
surfpool-studio-ui = { workspace = true }
surfpool-types = { workspace = true }

Expand Down
18 changes: 18 additions & 0 deletions crates/cli/src/cli/simnet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ pub async fn handle_start_local_surfnet_command(
let feature_config = cmd.feature_config();
surfnet_svm.apply_feature_config(&feature_config);

// Restore from snapshots if specified
for snapshot_path in &cmd.snapshot {
let resolved_path = super::resolve_path(snapshot_path);
let path_str = resolved_path.to_string_lossy();
info!("Restoring state from snapshot: {}", path_str);
match surfnet_svm.restore_from_snapshot(&path_str) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We have a very similar code path to surfnet_svm.restore_from_snapshot at https://github.com/txtx/surfpool/blob/9cb2398b2cf5caf1969aa9715bcb5a45e7c4c49e/crates/core/src/surfnet/locker.rs#L481

It is initialized a few lines below this (line 89 of this file) where the snapshot file contents are processed and stored on the simnet config.

That one does a few extra things - allowing useres to provide <pubkey>: null in their snapshot to fetch from remote.

Is this current loop causing a double import? Do we want to go with just one of these approaches?

Ok(count) => {
info!("Successfully restored {} accounts from snapshot", count);
}
Err(e) => {
return Err(format!(
"Failed to restore from snapshot '{}': {}",
path_str, e
));
}
}
}

let (simnet_commands_tx, simnet_commands_rx) = crossbeam::channel::unbounded();
let (subgraph_commands_tx, subgraph_commands_rx) = crossbeam::channel::unbounded();
let (subgraph_events_tx, subgraph_events_rx) = crossbeam::channel::unbounded();
Expand Down
15 changes: 14 additions & 1 deletion crates/cli/src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ use crossbeam::channel::{Receiver, Select, Sender};
use juniper_actix::{graphiql_handler, graphql_handler, subscriptions};
use juniper_graphql_ws::ConnectionConfig;
use log::{debug, error, info, trace, warn};
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp_actix_web::transport::StreamableHttpService;
#[cfg(feature = "explorer")]
use rust_embed::RustEmbed;
use serde::{Deserialize, Serialize};
Expand All @@ -31,6 +33,7 @@ use surfpool_gql::{
query::{CollectionsMetadataLookup, Dataloader, DataloaderContext, SqlStore},
types::{CollectionEntry, CollectionEntryData, collections::CollectionMetadata, sql},
};
use surfpool_mcp::Surfpool;
use surfpool_studio_ui::serve_studio_static_files;
use surfpool_types::{
DataIndexingCommand, OverrideTemplate, SanitizedConfig, Scenario, SubgraphCommand,
Expand Down Expand Up @@ -74,6 +77,14 @@ pub async fn start_subgraph_and_explorer_server(
let template_registry_wrapped = Data::new(RwLock::new(TemplateRegistry::new()));
let loaded_scenarios = Data::new(RwLock::new(LoadedScenarios::new()));

// Initialize MCP service
let mcp_service = StreamableHttpService::builder()
.service_factory(Arc::new(|| Ok(Surfpool::new())))
.session_manager(Arc::new(LocalSessionManager::default()))
.stateful_mode(true)
.sse_keep_alive(Duration::from_secs(30))
.build();

let subgraph_handle = start_subgraph_runloop(
subgraph_events_tx,
subgraph_commands_rx,
Expand All @@ -97,6 +108,7 @@ pub async fn start_subgraph_and_explorer_server(
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.expose_headers(vec!["Mcp-Session-Id", "mcp-session-id"])
.supports_credentials()
.max_age(3600),
)
Expand All @@ -115,7 +127,8 @@ pub async fn start_subgraph_and_explorer_server(
.route("/v1/graphql?<request..>", web::get().to(get_graphql))
.route("/v1/graphql", web::post().to(post_graphql))
.route("/v1/subscriptions", web::get().to(subscriptions)),
);
)
.service(web::scope("/mcp").service(mcp_service.clone().scope()));

if enable_studio {
app = app.app_data(Arc::new(RwLock::new(LoadedScenarios::new())));
Expand Down
2 changes: 1 addition & 1 deletion crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ txtx-addon-network-svm = { workspace = true }


[dev-dependencies]
tempfile = { workspace = true }
test-case = { workspace = true }
env_logger = "*"
tempfile = { workspace = true }
spl-token-metadata-interface = { workspace = true }

[features]
Expand Down
45 changes: 38 additions & 7 deletions crates/core/src/rpc/surfnet_cheatcodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,7 @@ pub trait SurfnetCheatcodes {
meta: Self::Metadata,
scenario: Scenario,
slot: Option<Slot>,
) -> Result<RpcResponse<()>>;
) -> BoxFuture<Result<RpcResponse<()>>>;
}

#[derive(Clone)]
Expand Down Expand Up @@ -1840,12 +1840,43 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc {
meta: Self::Metadata,
scenario: Scenario,
slot: Option<Slot>,
) -> Result<RpcResponse<()>> {
let svm_locker = meta.get_svm_locker()?;
svm_locker.register_scenario(scenario, slot)?;
Ok(RpcResponse {
context: RpcResponseContext::new(svm_locker.get_latest_absolute_slot()),
value: (),
) -> BoxFuture<Result<RpcResponse<()>>> {
let SurfnetRpcContext {
svm_locker,
remote_ctx,
} = match meta.get_rpc_context(CommitmentConfig::confirmed()) {
Ok(res) => res,
Err(e) => return e.into(),
};

Box::pin(async move {
// Get the base slot for registration (either provided or current)
let base_slot = slot.unwrap_or_else(|| svm_locker.get_latest_absolute_slot());

// Register the scenario with explicit base slot
svm_locker
.register_scenario(scenario, Some(base_slot))
.map_err(|e| jsonrpc_core::Error {
code: jsonrpc_core::ErrorCode::InternalError,
message: format!("Failed to register scenario: {}", e),
data: None,
})?;

// Immediately materialize overrides for the BASE slot (not current slot)
// This ensures slot 0's override is applied right away
svm_locker
.materialize_overrides_for_slot(&remote_ctx, base_slot)
.await
.map_err(|e| jsonrpc_core::Error {
code: jsonrpc_core::ErrorCode::InternalError,
message: format!("Failed to materialize initial overrides: {}", e),
data: None,
})?;

Ok(RpcResponse {
context: RpcResponseContext::new(svm_locker.get_latest_absolute_slot()),
value: (),
})
})
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"id": "kamino-liquidation-arb-example",
"name": "Kamino Liquidation Arbitrage - POPCAT/SOL",
"description": "A scenario replicating the liquidation arbitrage from tx 5xDtqZcY4CzDHjdT61VsGuF1YL7fADUhPz6hCdA2RVMFMhUjuSh5rqkrLKFXfh4gXevMN1L2NjnCaRCAZYxVmqpz. This scenario sets up a Kamino obligation to be liquidatable, and manipulates Whirlpool and Raydium AMM pool states to create a profitable arbitrage opportunity.",
"tags": ["liquidation", "arbitrage", "kamino", "whirlpool", "raydium"],
"overrides": [
{
"id": "obligation-unhealthy",
"templateId": "kamino-obligation-health",
"label": "Make Obligation Unhealthy",
"scenarioRelativeSlot": 0,
"enabled": true,
"fetchBeforeUse": true,
"account": {
"pubkey": "3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS"
},
"values": {
"borrowed_value_sf": 1000000000000000000,
"unhealthy_borrow_value_sf": 500000000000000000,
"deposited_value_sf": 800000000000000000,
"allowed_borrow_value_sf": 600000000000000000
}
},
{
"id": "whirlpool-popcat-sol-price",
"templateId": "whirlpool-popcat-sol",
"label": "Set Whirlpool POPCAT/SOL Price",
"scenarioRelativeSlot": 0,
"enabled": true,
"fetchBeforeUse": true,
"account": {
"pubkey": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE"
},
"values": {
"liquidity": "5000000000000000",
"sqrt_price": "1844674407370955161",
"tick_current_index": 0
}
},
{
"id": "raydium-amm-popcat-sol-state",
"templateId": "raydium-amm-popcat-sol",
"label": "Set Raydium AMM POPCAT/SOL State",
"scenarioRelativeSlot": 0,
"enabled": true,
"fetchBeforeUse": true,
"account": {
"pubkey": "FRhB8L7Y9Qq41qZXYLtC2nw8An1RJfLLxRF2x9RwLLMo"
},
"values": {
"status": 1,
"state": 1,
"lp_amount": 10000000000000,
"fees.swap_fee_numerator": 25,
"fees.swap_fee_denominator": 10000
}
}
]
}
7 changes: 6 additions & 1 deletion crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,22 @@ templates:

- id: kamino-obligation-health
name: Override Obligation Health
description: Override Kamino Obligation health metrics for testing liquidation scenarios
description: Override Kamino Obligation health metrics for testing liquidation scenarios. An obligation becomes unhealthy (liquidatable) when borrowed_value_sf exceeds unhealthy_borrow_value_sf. Use deposits/borrows arrays to set actual positions.
idl_account_name: Obligation
properties:
[
"last_update_slot",
"lending_market",
"owner",
"deposits",
"borrows",
"deposited_value_sf",
"borrowed_value_sf",
"allowed_borrow_value_sf",
"unhealthy_borrow_value_sf",
"borrowing_disabled",
"highest_borrow_factor_pct",
"num_of_obsolete_reserves",
]
address:
type: pubkey
Loading