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
37 changes: 36 additions & 1 deletion crates/core/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::{fmt::Display, future::Future, pin::Pin};

use crossbeam_channel::TrySendError;
use jsonrpc_core::{Error, Result};
use jsonrpc_core::{Error, ErrorCode, Result};
use litesvm::error::LiteSVMError;
use serde::Serialize;
use serde_json::json;
Expand All @@ -10,6 +10,7 @@ use solana_clock::Slot;
use solana_pubkey::Pubkey;
use solana_transaction::TransactionError;
use solana_transaction_status::EncodeError;
use surfpool_types::SimnetCommandError;

use crate::storage::StorageError;

Expand Down Expand Up @@ -84,6 +85,14 @@ impl From<solana_client::client_error::ClientError> for SurfpoolError {
}

impl SurfpoolError {
pub fn into_simnet_command_error(self) -> SimnetCommandError {
let message = self.to_string();
match self.0.code {
ErrorCode::InvalidParams => SimnetCommandError::InvalidParams(message),
_ => SimnetCommandError::Internal(message),
}
}

pub fn from_try_send_error<T>(e: TrySendError<T>) -> Self {
let mut error = Error::internal_error();
error.data = Some(json!(format!(
Expand Down Expand Up @@ -416,6 +425,15 @@ impl SurfpoolError {
Self(error)
}

pub fn internal_message<M>(message: M) -> Self
where
M: Into<String>,
{
let mut error = Error::internal_error();
error.message = message.into();
Self(error)
}

pub fn sig_verify_replace_recent_blockhash_collision() -> Self {
Self(Error::invalid_params(
"sigVerify may not be used with replaceRecentBlockhash",
Expand Down Expand Up @@ -564,3 +582,20 @@ impl Display for AirdropError {
}

impl std::error::Error for AirdropError {}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn simnet_command_errors_preserve_rpc_classification() {
assert!(matches!(
SurfpoolError::invalid_params("bad slot").into_simnet_command_error(),
SimnetCommandError::InvalidParams(message) if message.contains("bad slot")
));
assert!(matches!(
SurfpoolError::from(StorageError::LockError).into_simnet_command_error(),
SimnetCommandError::Internal(message) if message.contains("Storage error")
));
}
}
98 changes: 68 additions & 30 deletions crates/core/src/rpc/surfnet_cheatcodes.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, HashSet},
sync::{Arc, RwLock},
};

Expand All @@ -18,8 +18,8 @@ use spl_associated_token_account_interface::address::get_associated_token_addres
use surfpool_types::{
AccountSnapshot, CheatcodeControlConfig, CheatcodeFilter, ClockCommand, ExportSnapshotConfig,
GetStreamedAccountsResponse, GetSurfnetInfoResponse, Idl, OfflineAccountConfig,
ResetAccountConfig, RpcProfileResultConfig, Scenario, SimnetCommand, StreamAccountConfig,
StreamAccountsEntry, UiKeyedProfileResult,
OverrideOutcome, ResetAccountConfig, RpcProfileResultConfig, Scenario, SimnetCommand,
StreamAccountConfig, StreamAccountsEntry, UiKeyedProfileResult,
types::{AccountUpdate, SetSomeAccount, SupplyUpdate, TokenAccountUpdate, UuidOrSignature},
};

Expand Down Expand Up @@ -781,6 +781,13 @@ pub trait SurfnetCheatcodes {
config: Option<TimeTravelConfig>,
) -> Result<EpochInfo>;

#[rpc(meta, name = "surfnet_timeTravelWithOverrideOutcomes")]
fn time_travel_with_override_outcomes(
&self,
meta: Self::Metadata,
config: Option<TimeTravelConfig>,
) -> Result<RpcResponse<Vec<OverrideOutcome>>>;

/// A cheat code to freeze the Surfnet clock on the local network.
/// All time progression halts until resumed.
///
Expand Down Expand Up @@ -1278,13 +1285,13 @@ pub trait SurfnetCheatcodes {
/// - `scenarioRelativeSlot`: The relative slot offset (from base slot) when this override should be applied
/// - `label`: Optional label for this override
/// - `enabled`: Whether this override is active
/// - `fetchBeforeUse`: If true, fetch fresh account data just before transaction execution (useful for price feeds, oracle updates, and dynamic balances)
/// - `fetchBeforeUse`: If true, require fresh remote account data before applying the override. The override is skipped when no remote client is configured or the fetch fails.
/// - `account`: Account address (either `{ "pubkey": "..." }` or `{ "pda": { "programId": "...", "seeds": [...] } }`)
/// - `tags`: Array of tags for categorization
/// - `slot` (optional): The base slot from which relative slot offsets are calculated. If omitted, uses the current slot.
///
/// ## Returns
/// A `RpcResponse<()>` indicating whether the Scenario registration was successful.
/// A `RpcResponse<Vec<OverrideOutcome>>` for overrides at the base slot.
///
/// ## Example Request (with slot)
/// ```json
Expand Down Expand Up @@ -1362,7 +1369,13 @@ pub trait SurfnetCheatcodes {
/// "slot": 355684457,
/// "apiVersion": "2.2.2"
/// },
/// "value": null,
/// "value": [
/// {
/// "overrideId": "override-1",
/// "label": "Set BTC price",
/// "applied": true
/// }
/// ],
/// "id": 1
/// }
/// ```
Expand All @@ -1372,7 +1385,14 @@ pub trait SurfnetCheatcodes {
meta: Self::Metadata,
scenario: Scenario,
slot: Option<Slot>,
) -> BoxFuture<Result<RpcResponse<()>>>;
) -> BoxFuture<Result<RpcResponse<Vec<OverrideOutcome>>>>;

#[rpc(meta, name = "surfnet_cancelScenarioOverrides")]
fn cancel_scenario_overrides(
&self,
meta: Self::Metadata,
override_ids: Vec<String>,
) -> Result<RpcResponse<u64>>;
}

#[derive(Clone)]
Expand Down Expand Up @@ -2089,6 +2109,26 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc {
Ok(epoch_info)
}

fn time_travel_with_override_outcomes(
&self,
meta: Self::Metadata,
config: Option<TimeTravelConfig>,
) -> Result<RpcResponse<Vec<OverrideOutcome>>> {
let key = meta.as_ref().map(|ctx| ctx.id.clone()).unwrap_or_default();
let simnet_command_tx = meta.get_surfnet_command_tx()?;
let svm_locker = meta.get_svm_locker()?;
let (epoch_info, outcomes) = svm_locker.time_travel_with_override_outcomes(
key,
simnet_command_tx,
config.unwrap_or_default(),
)?;

Ok(RpcResponse {
context: RpcResponseContext::new(epoch_info.absolute_slot),
value: outcomes,
})
}

fn reset_account(
&self,
meta: Self::Metadata,
Expand Down Expand Up @@ -2318,7 +2358,7 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc {
meta: Self::Metadata,
scenario: Scenario,
slot: Option<Slot>,
) -> BoxFuture<Result<RpcResponse<()>>> {
) -> BoxFuture<Result<RpcResponse<Vec<OverrideOutcome>>>> {
let SurfnetRpcContext {
svm_locker,
remote_ctx,
Expand All @@ -2328,35 +2368,33 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc {
};

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)
let outcomes = svm_locker
.register_scenario_and_materialize(&remote_ctx, scenario, slot)
.await
.map_err(|e| jsonrpc_core::Error {
code: jsonrpc_core::ErrorCode::InternalError,
message: format!("Failed to materialize initial overrides: {}", e),
data: None,
})?;
.map_err(jsonrpc_core::Error::from)?;

Ok(RpcResponse {
context: RpcResponseContext::new(svm_locker.get_latest_absolute_slot()),
value: (),
value: outcomes,
})
})
}

fn cancel_scenario_overrides(
&self,
meta: Self::Metadata,
override_ids: Vec<String>,
) -> Result<RpcResponse<u64>> {
let svm_locker = meta.get_svm_locker()?;
let removed = svm_locker
.cancel_scheduled_overrides(override_ids.into_iter().collect::<HashSet<_>>())
.map_err(jsonrpc_core::Error::from)?;

Ok(RpcResponse {
context: RpcResponseContext::new(svm_locker.get_latest_absolute_slot()),
value: removed,
})
}
}

#[cfg(test)]
Expand Down
29 changes: 8 additions & 21 deletions crates/core/src/runloops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,27 +477,14 @@ pub async fn start_block_production_runloop(
});
}
SimnetCommand::UpdateInternalClockWithConfirmation(_, clock, response_tx) => {
// Confirm the current block to materialize any scheduled overrides for this slot
if let Err(e) = svm_locker.confirm_current_block(&remote_client_with_commitment).await {
svm_locker.simnet_events_tx().error(format!(
"Failed to confirm block after time travel: {}", e
));
}

let epoch_info = svm_locker.with_svm_writer(|svm_writer| {
svm_writer.inner.set_sysvar(&clock);
svm_writer.updated_at = clock.unix_timestamp as u64 * 1_000;
svm_writer.latest_epoch_info.absolute_slot = clock.slot;
svm_writer.latest_epoch_info.epoch = clock.epoch;
svm_writer.latest_epoch_info.slot_index = clock.slot;
svm_writer.latest_epoch_info.epoch = clock.epoch;
svm_writer.latest_epoch_info.absolute_slot = clock.slot + clock.epoch * svm_writer.latest_epoch_info.slots_in_epoch;
svm_writer.simnet_events_tx.system_clock_updated(clock);
svm_writer.latest_epoch_info.clone()
});

// Send confirmation back
let _ = response_tx.send(epoch_info);
let result = {
let mut svm_writer = svm_locker.0.write().await;
svm_writer
.time_travel_to_clock(&remote_client_with_commitment, clock)
.await
.map_err(crate::error::SurfpoolError::into_simnet_command_error)
};
let _ = response_tx.send(result);
}
SimnetCommand::UpdateBlockProductionMode(update) => {
block_production_mode = update;
Expand Down
17 changes: 17 additions & 0 deletions crates/core/src/storage/fifo_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ where
Ok(())
}

fn apply_batch(
&mut self,
operations: Vec<super::StorageOperation<K, V>>,
) -> super::StorageResult<()> {
for operation in operations {
match operation {
super::StorageOperation::Store(key, value) => {
self.insert(key, value);
}
super::StorageOperation::Remove(key) => {
self.remove(&key);
}
}
}
Ok(())
}

fn clear(&mut self) -> super::StorageResult<()> {
self.clear();
Ok(())
Expand Down
17 changes: 17 additions & 0 deletions crates/core/src/storage/hash_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ where
Ok(())
}

fn apply_batch(
&mut self,
operations: Vec<super::StorageOperation<K, V>>,
) -> super::StorageResult<()> {
for operation in operations {
match operation {
super::StorageOperation::Store(key, value) => {
self.insert(key, value);
}
super::StorageOperation::Remove(key) => {
self.remove(&key);
}
}
}
Ok(())
}

fn clear(&mut self) -> super::StorageResult<()> {
self.clear();
Ok(())
Expand Down
33 changes: 33 additions & 0 deletions crates/core/src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ pub enum StorageError {
DeserializeValueError(String, serde_json::Error),
#[error("Failed to acquire lock for database")]
LockError,
#[error("Atomic cross-table batches are not supported by {backend} ({configuration})")]
AtomicCrossTableBatchUnsupported {
backend: String,
configuration: String,
},
#[error("Query failed for table '{0}' in '{1}' database: {2}")]
QueryError(String, String, #[source] QueryExecuteError),
}
Expand Down Expand Up @@ -223,6 +228,16 @@ pub enum QueryExecuteError {

pub type StorageResult<T> = Result<T, StorageError>;

pub enum StorageOperation<K, V> {
Store(K, V),
Remove(K),
}

pub struct CrossTableRemove {
pub(crate) table_name: &'static str,
pub(crate) serialized_key: String,
}

impl From<StorageError> for jsonrpc_core::Error {
fn from(err: StorageError) -> Self {
SurfpoolError::from(err).into()
Expand All @@ -231,6 +246,24 @@ impl From<StorageError> for jsonrpc_core::Error {

pub trait Storage<K, V>: Send + Sync {
fn store(&mut self, key: K, value: V) -> StorageResult<()>;
fn apply_batch(&mut self, operations: Vec<StorageOperation<K, V>>) -> StorageResult<()>;
fn ensure_atomic_cross_table_batch_supported(&self) -> StorageResult<()> {
Err(StorageError::AtomicCrossTableBatchUnsupported {
backend: std::any::type_name::<Self>().to_string(),
configuration: "default storage implementation".to_string(),
})
}
fn apply_batch_with_cross_table_remove(
&mut self,
_operations: Vec<StorageOperation<K, V>>,
_cross_table_remove: CrossTableRemove,
) -> StorageResult<()> {
self.ensure_atomic_cross_table_batch_supported()?;
Err(StorageError::AtomicCrossTableBatchUnsupported {
backend: std::any::type_name::<Self>().to_string(),
configuration: "atomic operation is not implemented".to_string(),
})
}
fn clear(&mut self) -> StorageResult<()>;
fn get(&self, key: &K) -> StorageResult<Option<V>>;
fn take(&mut self, key: &K) -> StorageResult<Option<V>>;
Expand Down
Loading
Loading