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
108 changes: 103 additions & 5 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, HashMap},
sync::{Arc, RwLock},
};

Expand All @@ -16,10 +16,10 @@ use solana_system_interface::program as system_program;
use solana_transaction::versioned::VersionedTransaction;
use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id;
use surfpool_types::{
AccountSnapshot, CheatcodeControlConfig, CheatcodeFilter, ClockCommand, ExportSnapshotConfig,
GetStreamedAccountsResponse, GetSurfnetInfoResponse, Idl, OfflineAccountConfig,
ResetAccountConfig, RpcProfileResultConfig, Scenario, SimnetCommand, StreamAccountConfig,
StreamAccountsEntry, UiKeyedProfileResult,
AccountAddress, AccountSnapshot, CheatcodeControlConfig, CheatcodeFilter, ClockCommand,
ExportSnapshotConfig, GetStreamedAccountsResponse, GetSurfnetInfoResponse, Idl,
OfflineAccountConfig, ResetAccountConfig, RpcProfileResultConfig, Scenario, SimnetCommand,
StreamAccountConfig, StreamAccountsEntry, UiKeyedProfileResult,
types::{
AccountUpdate, ConfidentialBalanceKeys, DeriveConfidentialKeysResponse,
GetConfidentialBalanceResponse, SetSomeAccount, SupplyUpdate, TokenAccountUpdate,
Expand Down Expand Up @@ -1516,6 +1516,21 @@ pub trait SurfnetCheatcodes {
scenario: Scenario,
slot: Option<Slot>,
) -> BoxFuture<Result<RpcResponse<()>>>;

/// Stops scheduler-generated persisted copies of one override. This does not materialize the
/// override again and leaves all independently authored timeline entries intact. `values`
/// supplies property-reference PDA seeds; callers may instead pass the resolved pubkey as
/// `account`. Omitting both forms of concrete identity is rejected rather than cancelling
/// every continuation that happens to share a PDA recipe.
#[rpc(meta, name = "surfnet_stopPersistingOverride")]
fn stop_persisting_override(
&self,
meta: Self::Metadata,
id: String,
account: AccountAddress,
template_id: String,
values: Option<HashMap<String, serde_json::Value>>,
) -> Result<RpcResponse<usize>>;
}

#[derive(Clone)]
Expand Down Expand Up @@ -2580,6 +2595,28 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc {
})
})
}

fn stop_persisting_override(
&self,
meta: Self::Metadata,
id: String,
account: AccountAddress,
template_id: String,
values: Option<HashMap<String, serde_json::Value>>,
) -> Result<RpcResponse<usize>> {
let svm_locker = meta.get_svm_locker()?;
let removed = svm_locker
.stop_persisting_override(id, account, template_id, values)
.map_err(|e| jsonrpc_core::Error {
code: jsonrpc_core::ErrorCode::InternalError,
message: format!("Failed to stop persisted override: {}", e),
data: None,
})?;
Ok(RpcResponse {
context: RpcResponseContext::new(svm_locker.get_latest_absolute_slot()),
value: removed,
})
}
}

#[cfg(test)]
Expand Down Expand Up @@ -2629,6 +2666,67 @@ mod tests {
assert_eq!(registered, manifest);
}

#[tokio::test]
async fn stop_persisting_wire_accepts_optional_pda_values() {
let mut io: jsonrpc_core::MetaIoHandler<Option<RunloopContext>> =
jsonrpc_core::MetaIoHandler::default();
io.extend_with(SurfnetCheatcodesRpc::empty().to_delegate());
let account = serde_json::json!({ "pubkey": Pubkey::new_unique().to_string() });

for params in [
serde_json::json!(["override", account.clone(), "template"]),
serde_json::json!(["override", account.clone(), "template", { "market": "SOL" }]),
] {
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "surfnet_stopPersistingOverride",
"params": params,
})
.to_string();
let response = io
.handle_request(&request, None)
.await
.expect("request produces a response");
let response: serde_json::Value =
serde_json::from_str(&response).expect("valid JSON-RPC response");
assert_ne!(
response
.pointer("/error/code")
.and_then(|code| code.as_i64()),
Some(-32602),
"both the legacy three-argument call and the PDA-aware four-argument call must deserialize"
);
}
}

#[test]
fn stop_persisting_endpoint_metadata_matches_the_wire_contract() {
let metadata: serde_json::Value =
serde_json::from_str(include_str!("../../../types/src/rpc_endpoints.json"))
.expect("RPC endpoint metadata is valid JSON");
let endpoint = metadata["categories"]
.as_array()
.expect("categories array")
.iter()
.flat_map(|category| category["endpoints"].as_array().into_iter().flatten())
.find(|endpoint| endpoint["method"] == "surfnet_stopPersistingOverride")
.expect("stop-persistence endpoint is discoverable");
let parameter_names: Vec<&str> = endpoint["params"]
.as_array()
.expect("params array")
.iter()
.map(|parameter| parameter["name"].as_str().expect("parameter name"))
.collect();
assert_eq!(parameter_names, ["id", "account", "template_id", "values"]);
assert_eq!(
endpoint["returns"].as_str(),
Some(
"A `RpcResponse<usize>` containing the number of scheduler-generated continuations removed."
)
);
}

/// Pins the wire shape of `TimeTravelConfig`, which is hand-mirrored in
/// the TypeScript bindings
/// (`crates/sdk-node/surfpool-sdk/kit/types/api.ts`). Update that mirror
Expand Down
14 changes: 11 additions & 3 deletions crates/core/src/scenarios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,17 @@ whole struct or array) also works, but it must be **complete** - every field of
padding included - because the account is re-encoded with Borsh. An out-of-range index or a
non-numeric segment on an array is a hard error, never a silent write elsewhere.

By default an override applies to exactly one slot. Set `"persist": true` and it is re-applied on
every following slot, which is needed when something else writes the account in between - a
transaction, or another override fetching it fresh. Persist inputs nothing in the scenario writes
By default an override applies to exactly one slot. `"persist"` controls how long it keeps
re-applying: `true` continues indefinitely and `{"slots": 10}` applies successfully in ten slots in
total, counting the first. A failed or skipped write is retried without consuming a bounded slot.
`{"slots": 0}` is rejected because an override cannot request zero applications.

Use `surfnet_stopPersistingOverride` to cancel an already armed persistent override without
re-applying it. Persistence also survives a forward clock jump: overdue work is claimed at the slot
actually reached instead of being stranded in an earlier bucket.

Re-applying is useful when something else writes the account in between - a transaction, or another
override fetching it fresh. Persist inputs nothing in the scenario writes
(an oracle price, a disabled switch, a risk parameter), never state the transactions under test
mutate: re-applying reverts their writes at the start of the next slot, so a pool would refill
itself after every swap. Only one entry is queued per override, so it is never applied twice to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ templates:
[210, 3] means price = prices[210] * prices[3]
4. Set price.value = usd_price * 10^exp, keeping exp as you found it
5. Set last_updated_slot and unix_timestamp to now, or Kamino rejects the price as stale
6. Set persist: true if the scenario runs past one slot, so a transaction that writes
this account cannot restore the real price. Safe here: nothing in a fork cranks Scope
6. Set persist to a window covering your scenario if it runs past one slot, so a transaction
that writes this account cannot restore the real price - persist: { slots: N } where N is
how many slots the scenario spans. Prefer that to persist: true, which never expires: a
pinned price would then leak into every later scenario in the same surfnet run, and the
only symptom is numbers that are inexplicably wrong. Safe here: nothing in a fork cranks
Scope

SCOPE INDICES (verified 2026-08-06, do not guess these):
- 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH (Main Market):
Expand Down
12 changes: 8 additions & 4 deletions crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,10 @@ templates:
EXAMPLE - "liquidate SOL collateral above 50% LTV":
config.liquidation_threshold_pct: 50

persist: true is safe for the config.* fields only. liquidity.* and last_update.* are
rewritten by refresh_reserve, so pinning them fights every transaction that touches the reserve.
Persisting is safe for the config.* fields only. liquidity.* and last_update.* are rewritten by
refresh_reserve, so pinning them fights every transaction that touches the reserve. Bound the
window to the scenario - persist: { slots: N } - rather than persist: true, which never expires
and leaves the parameter pinned for every later scenario in the same surfnet run.
- id: kamino-reserve-main-usdc
name: Override USDC Reserve (Main Market)
description: Override the USDC reserve of Kamino's Main Market
Expand Down Expand Up @@ -422,8 +424,10 @@ templates:
EXAMPLE - "USDC depegs to $0.90":
use kamino-scope-price with prices.13.price.value: 90000000 and prices.13.price.exp: 8

persist: true is safe for the config.* fields only. liquidity.* and last_update.* are
rewritten by refresh_reserve, so pinning them fights every transaction that touches the reserve.
Persisting is safe for the config.* fields only. liquidity.* and last_update.* are rewritten by
refresh_reserve, so pinning them fights every transaction that touches the reserve. Bound the
window to the scenario - persist: { slots: N } - rather than persist: true, which never expires
and leaves the parameter pinned for every later scenario in the same surfnet run.
# ==========================================
# Obligation
# ==========================================
Expand Down
13 changes: 13 additions & 0 deletions crates/core/src/surfnet/locker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2804,6 +2804,19 @@ impl SurfnetSvmLocker {
self.with_svm_writer(move |svm_writer| svm_writer.register_scenario(scenario, slot))
}

/// Stops persisted copies of an override without materializing another account write.
pub fn stop_persisting_override(
&self,
id: String,
account: surfpool_types::AccountAddress,
template_id: String,
values: Option<HashMap<String, serde_json::Value>>,
) -> SurfpoolResult<usize> {
self.with_svm_writer(move |svm_writer| {
svm_writer.stop_persisting_override(&id, &account, &template_id, values.as_ref())
})
}

/// Materializes overrides for a specific slot (not necessarily the current slot)
pub async fn materialize_overrides_for_slot(
&self,
Expand Down
Loading
Loading