Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f5ee344
Extend persist flag with a way to specify number of slots to be valid…
bakasura980 Aug 24, 2026
ac54377
Extend persist flag with a way to specify number of slots to be valid…
bakasura980 Aug 24, 2026
f3a42fe
Fix templates to include perists better
bakasura980 Aug 24, 2026
c7dbff6
Fixed broken storage clear
bakasura980 Aug 25, 2026
717aed7
fix: keep bounded persist alive across clock jumps and cancellation
bakasura980 Aug 26, 2026
57c0a0d
Merge with bisonfi branch
bakasura980 Aug 26, 2026
870d5ac
fix: harden override scheduling against duplicate application
bakasura980 Aug 26, 2026
c1fe344
fix: make scenario registration atomic and timeline-safe
bakasura980 Aug 26, 2026
351b736
fix: tell re-armed copies apart from scheduled timeline steps
bakasura980 Aug 26, 2026
d93e409
fix: keep re-arming and caller input off scheduled entries
bakasura980 Aug 26, 2026
47a8967
fix: suppress a continuation the same batch already supersedes
bakasura980 Aug 26, 2026
f4100ac
feat(scenarios): add cancellation-only RPC for persisted overrides
bakasura980 Sep 3, 2026
feed639
Fix conflicts and merge with bisonfi
bakasura980 Sep 3, 2026
1766e87
fix(scenarios): distinguish PDA override targets by resolved account
bakasura980 Sep 3, 2026
f2598cd
fix(scenarios): resolve PDA targets when stopping persistent overrides
bakasura980 Sep 3, 2026
a6bd2f9
fix(scenarios): prevent disabled overrides from stopping persistence
bakasura980 Sep 4, 2026
3660c16
fix(scenarios): make persistence queue updates atomic
bakasura980 Sep 4, 2026
4084d4b
fix(scenarios): atomically claim overdue override buckets
bakasura980 Sep 4, 2026
36d7660
fix(scenarios): re-arm persistent overrides only after application
bakasura980 Sep 4, 2026
5380557
fix(scenarios): preserve bounded persistence across failed writes
bakasura980 Sep 4, 2026
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
20 changes: 17 additions & 3 deletions crates/core/src/scenarios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,23 @@ 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` re-applies indefinitely and `{"slots": 10}` applies in ten slots in total,
counting the first. Prefer a bounded window - an indefinite one outlives the scenario that created
it. `{"slots": 0}` is refused rather than granted as one application: an override always applies on
the slot it is scheduled for, so zero applications is not something it can honour.

Registering a scenario replaces any override it has already queued, matched on id, account and
template together. That is how an override is updated, and how a persisted one is stopped: register
it again with `"persist": false`. A persisted override has usually already armed a copy one slot
ahead by then, so the cancelling registration also drops matching copies queued for later slots -
otherwise the armed one would keep re-arming out of a bucket the registration never touched.

Persistence survives a clock jump. Each slot claims every override queued at or before it, not only
an exact match, so a `timeTravel` forward past an armed copy carries it to the slot actually reached
instead of stranding it in a key nothing materializes again.
Re-applying is what you want 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