Skip to content

Commit b6975f0

Browse files
Akagi201Copilot
andcommitted
feat: implement runtime extension registry and account source
- Add `RuntimeExtensionRegistry` to manage custom syscalls and precompiles. - Create `StaticAccountSource` for testing account retrieval in `HPSVM`. - Introduce tests for account source functionality and environment configuration. - Implement `Inspector` trait for tracking instruction execution in `HPSVM`. - Add tests to validate inspector behavior and execution outcomes. - Document the new architecture and API changes in the core architecture plan. Co-authored-by: Copilot <copilot@github.com>
1 parent dc084c5 commit b6975f0

25 files changed

Lines changed: 2516 additions & 324 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- Add explicit `ExecutionOutcome` flow through `HPSVM::transact` and `HPSVM::commit_transaction`.
8+
- Add `AccountSource` integration plus the `hpsvm-fork-rpc::RpcForkSource` companion crate for cached RPC-backed reads.
9+
- Add extracted execution environment surfaces, including `HPSVM::block_env` and the public `Inspector` hook via `HPSVM::with_inspector`.
10+
11+
### Changed
12+
13+
- Share one commit-delta path across single-transaction and batch execution.
14+
- Move internal block/config/runtime state behind `BlockEnv`, `SvmCfg`, and `RuntimeEnv`.
15+
- Centralize custom syscalls and standard precompile materialization behind an internal `RuntimeExtensionRegistry`.
16+
517
## [0.11.0] - 2026-03-30
618

719
### Added

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ indexmap = "2.14.0"
2929
itertools = "0.14.0"
3030
libsecp256k1 = "0.7.2"
3131
log = "0.4.29"
32+
parking_lot = "0.12.5"
3233
qualifier_attr = "0.2.2"
3334
serde = "1.0.228"
3435
sha2 = "0.11.0"
@@ -66,6 +67,8 @@ solana-program-option = "3.1.0"
6667
solana-program-pack = "3.1.0"
6768
solana-program-runtime = "3.1.14"
6869
solana-rent = "3.1.0"
70+
solana-rpc-client = "3.1.14"
71+
solana-rpc-client-api = "3.1.14"
6972
solana-sdk-ids = "3.1.0"
7073
solana-secp256k1-program = "3.0.1"
7174
solana-sha256-hasher = "3.1.0"

README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ Add `hpsvm` as a development dependency to your Solana program project:
3939
cargo add --dev hpsvm
4040
```
4141

42+
To read through live RPC state while keeping execution local, add the companion crate as well:
43+
44+
```sh
45+
cargo add --dev hpsvm-fork-rpc
46+
```
47+
4248
### 🤖 Quick Example
4349

4450
Here's a minimal example that demonstrates creating a test environment, airdropping SOL, and executing a transfer transaction:
@@ -87,6 +93,92 @@ assert_eq!(to_account.lamports, 64);
8793

8894
For more advanced usage, including custom configurations, program deployment, and complex transaction scenarios, see the [full documentation](https://docs.rs/hpsvm).
8995

96+
### Architecture Highlights
97+
98+
`hpsvm` keeps the `HPSVM` facade stable while exposing a few sharper seams for advanced test harnesses:
99+
100+
- `transact` computes an `ExecutionOutcome` without mutating the VM, and `commit_transaction` applies it explicitly when you want to persist the result.
101+
- `with_account_source` lets the VM read missing accounts from an external source while keeping local writes in the in-memory overlay.
102+
- `block_env` exposes the current blockhash and slot snapshot, and `with_inspector` installs lightweight top-level execution observers.
103+
104+
```rust
105+
use hpsvm::HPSVM;
106+
use solana_address::Address;
107+
use solana_keypair::Keypair;
108+
use solana_message::Message;
109+
use solana_signer::Signer;
110+
use solana_system_interface::instruction::transfer;
111+
use solana_transaction::Transaction;
112+
113+
let mut svm = HPSVM::new();
114+
let payer = Keypair::new();
115+
let recipient = Address::new_unique();
116+
117+
svm.airdrop(&payer.pubkey(), 10_000).unwrap();
118+
119+
let tx = Transaction::new(
120+
&[&payer],
121+
Message::new(&[transfer(&payer.pubkey(), &recipient, 64)], Some(&payer.pubkey())),
122+
svm.latest_blockhash(),
123+
);
124+
125+
let outcome = svm.transact(tx);
126+
assert!(outcome.status().is_ok());
127+
assert_eq!(svm.get_balance(&recipient), None);
128+
129+
let commit = svm.commit_transaction(outcome);
130+
assert!(commit.is_ok());
131+
assert_eq!(svm.get_balance(&recipient), Some(64));
132+
assert_eq!(svm.block_env().latest_blockhash, svm.latest_blockhash());
133+
```
134+
135+
### Forking RPC State
136+
137+
`hpsvm` can read missing accounts through a configured account source. The `hpsvm-fork-rpc` companion crate provides an RPC-backed source with a local cache:
138+
139+
```rust
140+
use hpsvm::HPSVM;
141+
use hpsvm_fork_rpc::RpcForkSource;
142+
143+
let source = RpcForkSource::builder()
144+
.with_rpc_url("http://127.0.0.1:8899")
145+
.with_slot(1)
146+
.build();
147+
148+
let svm = HPSVM::default().with_account_source(source);
149+
```
150+
151+
### Top-Level Instruction Inspection
152+
153+
Use `with_inspector` when you need lightweight transaction observation without reaching into the lower-level invocation callback APIs:
154+
155+
```rust
156+
use std::sync::{
157+
Arc,
158+
atomic::{AtomicUsize, Ordering},
159+
};
160+
161+
use hpsvm::{HPSVM, Inspector};
162+
use solana_address::Address;
163+
164+
#[derive(Default)]
165+
struct CountingInspector {
166+
seen: Arc<AtomicUsize>,
167+
}
168+
169+
impl Inspector for CountingInspector {
170+
fn on_instruction(&self, _svm: &HPSVM, _index: usize, _program_id: &Address) {
171+
self.seen.fetch_add(1, Ordering::SeqCst);
172+
}
173+
}
174+
175+
let inspector = CountingInspector::default();
176+
let observed = Arc::clone(&inspector.seen);
177+
let _svm = HPSVM::new().with_inspector(inspector);
178+
179+
assert_eq!(observed.load(Ordering::SeqCst), 0);
180+
```
181+
90182
## 🛠️ Developing hpsvm
91183

92184
### Building Test Programs

crates/fork-rpc/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "hpsvm-fork-rpc"
3+
description = "RPC-backed account source for hpsvm"
4+
version.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
9+
[dependencies]
10+
hpsvm.workspace = true
11+
parking_lot.workspace = true
12+
solana-account.workspace = true
13+
solana-address.workspace = true
14+
solana-rpc-client.workspace = true
15+
solana-rpc-client-api.workspace = true
16+
17+
[lints]
18+
workspace = true

crates/fork-rpc/src/lib.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
//! RPC-backed account source support for `hpsvm`.
2+
3+
use std::{
4+
collections::HashMap,
5+
sync::{
6+
Arc,
7+
atomic::{AtomicUsize, Ordering},
8+
},
9+
};
10+
11+
use hpsvm::{AccountSource, AccountSourceError};
12+
use parking_lot::Mutex;
13+
use solana_account::AccountSharedData;
14+
use solana_address::Address;
15+
use solana_rpc_client::rpc_client::RpcClient;
16+
use solana_rpc_client_api::config::RpcAccountInfoConfig;
17+
18+
/// Read-through account source backed by a Solana RPC endpoint and a local cache.
19+
#[derive(Clone)]
20+
pub struct RpcForkSource {
21+
client: Arc<RpcClient>,
22+
slot: u64,
23+
cache: Arc<Mutex<HashMap<Address, AccountSharedData>>>,
24+
cache_hits: Arc<AtomicUsize>,
25+
cache_misses: Arc<AtomicUsize>,
26+
}
27+
28+
impl std::fmt::Debug for RpcForkSource {
29+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30+
f.debug_struct("RpcForkSource")
31+
.field("client", &"RpcClient")
32+
.field("slot", &self.slot)
33+
.field("cache_len", &self.cache.lock().len())
34+
.field("cache_hits", &self.cache_hits())
35+
.field("cache_misses", &self.cache_misses())
36+
.finish()
37+
}
38+
}
39+
40+
impl RpcForkSource {
41+
/// Creates a builder for an RPC-backed account source.
42+
pub fn builder() -> RpcForkSourceBuilder {
43+
RpcForkSourceBuilder::default()
44+
}
45+
46+
/// Returns the number of reads served directly from the local cache.
47+
pub fn cache_hits(&self) -> usize {
48+
self.cache_hits.load(Ordering::Relaxed)
49+
}
50+
51+
/// Returns the number of cache misses that triggered an RPC read.
52+
pub fn cache_misses(&self) -> usize {
53+
self.cache_misses.load(Ordering::Relaxed)
54+
}
55+
56+
/// Returns the minimum RPC context slot used for fetches.
57+
pub const fn slot(&self) -> u64 {
58+
self.slot
59+
}
60+
61+
fn fetch_account(
62+
&self,
63+
pubkey: &Address,
64+
) -> Result<Option<AccountSharedData>, AccountSourceError> {
65+
#[expect(deprecated)]
66+
self.client
67+
.get_account_with_config(
68+
pubkey,
69+
RpcAccountInfoConfig {
70+
min_context_slot: Some(self.slot),
71+
..RpcAccountInfoConfig::default()
72+
},
73+
)
74+
.map(|response| response.value.map(Into::into))
75+
.map_err(|error| AccountSourceError::new(error.to_string()))
76+
}
77+
}
78+
79+
impl AccountSource for RpcForkSource {
80+
fn get_account(
81+
&self,
82+
pubkey: &Address,
83+
) -> Result<Option<AccountSharedData>, AccountSourceError> {
84+
if let Some(account) = self.cache.lock().get(pubkey).cloned() {
85+
self.cache_hits.fetch_add(1, Ordering::Relaxed);
86+
return Ok(Some(account));
87+
}
88+
89+
self.cache_misses.fetch_add(1, Ordering::Relaxed);
90+
let account = self.fetch_account(pubkey)?;
91+
if let Some(account) = &account {
92+
self.cache.lock().insert(*pubkey, account.clone());
93+
}
94+
Ok(account)
95+
}
96+
}
97+
98+
/// Builder for [`RpcForkSource`].
99+
#[derive(Default)]
100+
pub struct RpcForkSourceBuilder {
101+
rpc_url: Option<String>,
102+
client: Option<Arc<RpcClient>>,
103+
slot: Option<u64>,
104+
}
105+
106+
impl std::fmt::Debug for RpcForkSourceBuilder {
107+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108+
f.debug_struct("RpcForkSourceBuilder")
109+
.field("rpc_url", &self.rpc_url)
110+
.field("client", &self.client.as_ref().map(|_| "RpcClient"))
111+
.field("slot", &self.slot)
112+
.finish()
113+
}
114+
}
115+
116+
impl RpcForkSourceBuilder {
117+
/// Configures the RPC URL used when no explicit client is provided.
118+
pub fn with_rpc_url(mut self, rpc_url: impl Into<String>) -> Self {
119+
self.rpc_url = Some(rpc_url.into());
120+
self
121+
}
122+
123+
/// Reuses an existing RPC client, including mock clients in tests.
124+
pub fn with_client(mut self, client: RpcClient) -> Self {
125+
self.client = Some(Arc::new(client));
126+
self
127+
}
128+
129+
/// Sets the minimum context slot used for remote account reads.
130+
pub fn with_slot(mut self, slot: u64) -> Self {
131+
self.slot = Some(slot);
132+
self
133+
}
134+
135+
/// Builds the configured RPC-backed account source.
136+
pub fn build(self) -> RpcForkSource {
137+
let client = self.client.unwrap_or_else(|| {
138+
Arc::new(RpcClient::new(
139+
self.rpc_url.unwrap_or_else(|| "http://127.0.0.1:8899".to_owned()),
140+
))
141+
});
142+
143+
RpcForkSource {
144+
client,
145+
slot: self.slot.unwrap_or_default(),
146+
cache: Arc::new(Mutex::new(HashMap::new())),
147+
cache_hits: Arc::new(AtomicUsize::new(0)),
148+
cache_misses: Arc::new(AtomicUsize::new(0)),
149+
}
150+
}
151+
}

crates/fork-rpc/tests/rpc_fork.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//! Integration tests for the RPC fork account source.
2+
3+
use std::str::FromStr;
4+
5+
use hpsvm::AccountSource;
6+
use hpsvm_fork_rpc::RpcForkSource;
7+
use solana_address::Address;
8+
use solana_rpc_client::{
9+
mock_sender::PUBKEY,
10+
rpc_client::{RpcClient, create_rpc_client_mocks},
11+
};
12+
13+
#[test]
14+
/// Repeated reads of the same remote account should be served from cache.
15+
fn rpc_fork_source_serves_cached_accounts_without_refetching() {
16+
let client = RpcClient::new_mock_with_mocks("succeeds".to_owned(), create_rpc_client_mocks());
17+
let source = RpcForkSource::builder().with_client(client).with_slot(1).build();
18+
let key = Address::from_str(PUBKEY).unwrap();
19+
20+
let first = source.get_account(&key).unwrap();
21+
let second = source.get_account(&key).unwrap();
22+
23+
assert!(first.is_some());
24+
assert_eq!(first, second);
25+
assert_eq!(source.cache_hits() + source.cache_misses(), 2);
26+
assert_eq!(source.cache_misses(), 1);
27+
}

crates/hpsvm/src/account_source.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
use solana_account::AccountSharedData;
2+
use solana_address::Address;
3+
4+
#[derive(Debug, thiserror::Error)]
5+
#[error("{message}")]
6+
pub struct AccountSourceError {
7+
message: String,
8+
}
9+
10+
impl AccountSourceError {
11+
pub fn new(message: impl Into<String>) -> Self {
12+
Self { message: message.into() }
13+
}
14+
}
15+
16+
pub trait AccountSource: Send + Sync {
17+
fn get_account(
18+
&self,
19+
pubkey: &Address,
20+
) -> Result<Option<AccountSharedData>, AccountSourceError>;
21+
}
22+
23+
#[derive(Clone, Default)]
24+
pub(crate) struct EmptyAccountSource;
25+
26+
impl AccountSource for EmptyAccountSource {
27+
fn get_account(
28+
&self,
29+
_pubkey: &Address,
30+
) -> Result<Option<AccountSharedData>, AccountSourceError> {
31+
Ok(None)
32+
}
33+
}

0 commit comments

Comments
 (0)