Skip to main content

replay_core/
session.rs

1//! ForkedSession — a replayable, mutable sandbox around a single transaction.
2//!
3//! Mutations are stored as a log and re-applied on every `execute()` call, so
4//! you can always reset to baseline by clearing the mutation list. This keeps
5//! the semantics simple and makes the diff computation trivial.
6
7use crate::error::ReplayError;
8use crate::idl::{AccountDecoder, IdlCache};
9use crate::rpc::HeliusClient;
10use crate::svm::replay_from_scratch;
11use crate::trace::build_trace;
12use crate::types::{AccountMutation, ReconstructedState, Trace, TraceDiff, TxContext};
13use solana_sdk::{account::Account, pubkey::Pubkey};
14use std::collections::HashMap;
15
16pub struct ForkedSession {
17    pub id: String,
18    pub ctx: TxContext,
19    pub state: ReconstructedState,
20    pub baseline_trace: Trace,
21    pub mutations: Vec<(Pubkey, AccountMutation)>,
22    pub latest_trace: Option<Trace>,
23    pub created_at: std::time::Instant,
24}
25
26impl ForkedSession {
27    /// Create a new session. Runs the baseline replay to capture the
28    /// un-mutated trace — this is the reference point for future diffs.
29    pub async fn new(
30        ctx: TxContext,
31        state: ReconstructedState,
32    ) -> Result<Self, ReplayError> {
33        let id = ulid::Ulid::new().to_string();
34
35        // Run the baseline replay.
36        let execution = replay_from_scratch(&state, &ctx, &[])?;
37        let idl_cache = IdlCache::default();
38        let decoder = AccountDecoder::new(&idl_cache);
39        let baseline_trace = build_trace(&ctx, &execution, &decoder).await;
40
41        Ok(Self {
42            id,
43            ctx,
44            state,
45            baseline_trace,
46            mutations: Vec::new(),
47            latest_trace: None,
48            created_at: std::time::Instant::now(),
49        })
50    }
51
52    /// Append a mutation to the session's mutation log. Does NOT re-execute.
53    pub fn mutate(
54        &mut self,
55        pubkey: Pubkey,
56        mutation: AccountMutation,
57    ) -> Result<(), ReplayError> {
58        // For v1 we don't pre-validate mutations against the IDL here; the
59        // apply-logic during execute() handles errors. A future version
60        // should return preview info (byte-level diff) synchronously.
61        self.mutations.push((pubkey, mutation));
62        Ok(())
63    }
64
65    /// Clear all applied mutations. Next execute() will match baseline.
66    pub fn reset(&mut self) {
67        self.mutations.clear();
68        self.latest_trace = None;
69    }
70
71    /// Execute the transaction with the current mutations applied on top of
72    /// reconstructed state. Caches the result in `latest_trace`.
73    pub async fn execute<C: HeliusClient>(
74        &mut self,
75        client: &C,
76    ) -> Result<Trace, ReplayError> {
77        let resolved = self.resolve_mutations(client).await?;
78        let execution = replay_from_scratch(&self.state, &self.ctx, &resolved)?;
79
80        let idl_cache = IdlCache::default();
81        let decoder = AccountDecoder::new(&idl_cache);
82        let trace = build_trace(&self.ctx, &execution, &decoder).await;
83
84        self.latest_trace = Some(trace.clone());
85        Ok(trace)
86    }
87
88    /// Produce a diff between baseline and the most recent execution.
89    pub fn diff(&self) -> Option<TraceDiff> {
90        let latest = self.latest_trace.clone()?;
91        let result_changed = self.baseline_trace.replay_result != latest.replay_result;
92
93        let mut changed_accounts: Vec<String> = Vec::new();
94        let baseline_deltas: HashMap<_, _> = self
95            .baseline_trace
96            .account_deltas
97            .iter()
98            .map(|d| (d.pubkey.clone(), d))
99            .collect();
100        let latest_deltas: HashMap<_, _> = latest
101            .account_deltas
102            .iter()
103            .map(|d| (d.pubkey.clone(), d))
104            .collect();
105        for key in latest_deltas.keys() {
106            match baseline_deltas.get(key) {
107                None => changed_accounts.push(key.clone()),
108                Some(b) => {
109                    if b.data_after_hex != latest_deltas[key].data_after_hex
110                        || b.lamports_after != latest_deltas[key].lamports_after
111                    {
112                        changed_accounts.push(key.clone());
113                    }
114                }
115            }
116        }
117
118        Some(TraceDiff {
119            baseline: self.baseline_trace.clone(),
120            total_cu_delta: latest.total_cu as i64 - self.baseline_trace.total_cu as i64,
121            result_changed,
122            changed_accounts,
123            latest,
124        })
125    }
126
127    /// Resolve the mutation log into concrete Account overrides.
128    async fn resolve_mutations<C: HeliusClient>(
129        &self,
130        _client: &C,
131    ) -> Result<Vec<(Pubkey, Account)>, ReplayError> {
132        let idl_cache = IdlCache::default();
133        let mut out: HashMap<Pubkey, Account> = HashMap::new();
134
135        for (pk, mutation) in &self.mutations {
136            let base = out
137                .get(pk)
138                .cloned()
139                .or_else(|| self.state.accounts.get(pk).cloned())
140                .ok_or_else(|| ReplayError::StateReconstruction {
141                    step: "mutation_target_missing".into(),
142                    detail: format!("account {pk} not in reconstructed state"),
143                })?;
144
145            let mutated = apply_mutation(base, mutation, &idl_cache)?;
146            out.insert(*pk, mutated);
147        }
148
149        Ok(out.into_iter().collect())
150    }
151}
152
153fn apply_mutation(
154    mut account: Account,
155    mutation: &AccountMutation,
156    idl_cache: &IdlCache,
157) -> Result<Account, ReplayError> {
158    match mutation {
159        AccountMutation::Lamports { new_value } => {
160            account.lamports = *new_value;
161        }
162        AccountMutation::Owner { new_value } => {
163            account.owner = new_value
164                .parse()
165                .map_err(|e| ReplayError::Decoder(format!("bad owner pubkey: {e:?}")))?;
166        }
167        AccountMutation::RawBytes { offset, bytes, extend } => {
168            let required = *offset + bytes.len();
169            if required > account.data.len() {
170                if *extend {
171                    account.data.resize(required, 0);
172                } else {
173                    return Err(ReplayError::Decoder(format!(
174                        "raw_bytes mutation exceeds account data length ({}+{} > {})",
175                        offset,
176                        bytes.len(),
177                        account.data.len()
178                    )));
179                }
180            }
181            account.data[*offset..*offset + bytes.len()].copy_from_slice(bytes);
182        }
183        AccountMutation::Field { path, new_value } => {
184            let idl = idl_cache
185                .get_local(&account.owner)
186                .ok_or_else(|| ReplayError::InvalidMutationPath {
187                    path: path.clone(),
188                    type_name: format!("no IDL for owner {}", account.owner),
189                })?;
190            account.data =
191                crate::idl::apply_field_mutation(&idl, &account.data, path, new_value)?;
192        }
193    }
194    Ok(account)
195}