Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c3c2429
save place to check on first iteration of diffs
dgrkotsonis Mar 16, 2026
0b91215
fix license, remove comments from prep
dgrkotsonis Mar 16, 2026
027a403
remove comments, add type for serialized mappings to get rid of clipp…
dgrkotsonis Mar 16, 2026
2820dfc
add more to readme
dgrkotsonis Mar 17, 2026
c96eba5
Add method to get slipstream plugin manager
dgrkotsonis Mar 17, 2026
9cd55f8
add diagnostic log
dgrkotsonis Mar 20, 2026
4cccb46
add new feature flag, debug logs
dgrkotsonis Mar 20, 2026
2a2888e
Adding log to staking notifs
dgrkotsonis Mar 23, 2026
68b234c
add additional logs
dgrkotsonis Mar 24, 2026
f4af5c6
add more logging
dgrkotsonis Mar 25, 2026
a7af4ce
propagate tracing to interface
dgrkotsonis Mar 25, 2026
ca43ff7
More logs
dgrkotsonis Mar 25, 2026
5666186
fix esc
dgrkotsonis Mar 25, 2026
cf0bed8
wrap plugin manager in arc
dgrkotsonis Mar 25, 2026
32dfed1
remove diagnostic logs
dgrkotsonis Mar 25, 2026
0c9105d
resolve PR review comments (first batch)
dgrkotsonis Mar 25, 2026
802c996
fix imports
dgrkotsonis Mar 25, 2026
17e347c
fix another feature gate
dgrkotsonis Mar 25, 2026
3cf0ffa
Resolve more PR comments
dgrkotsonis Mar 26, 2026
a7b7cac
attempt to resolve error with loading duplicate plugin
dgrkotsonis Mar 27, 2026
939d34b
make slipstream logs debug, get rid of noise for now
dgrkotsonis Mar 27, 2026
fc27c57
Update lockfile after rebase
dgrkotsonis Mar 27, 2026
261abcb
recompiled
dgrkotsonis Mar 27, 2026
bc5039f
remove noisy debug logs
dgrkotsonis Mar 31, 2026
0d0aa19
Add diagnostic logs for dynamic loading/unloading
dgrkotsonis Apr 1, 2026
6dd2b78
temporarily remove dynamic reloading of the plugins
dgrkotsonis Apr 1, 2026
38f5714
update plugin version
dgrkotsonis Apr 1, 2026
0246766
resolve some clippy issues
dgrkotsonis Apr 1, 2026
3ac009c
Cargo fmt
dgrkotsonis Apr 2, 2026
74e2950
gate imports to resolve clippy error
dgrkotsonis Apr 2, 2026
e22c364
ran fmt in plugins
dgrkotsonis Apr 2, 2026
bc2c61f
formatting
dgrkotsonis Apr 3, 2026
933fbf9
recommitted lockfile
dgrkotsonis Apr 3, 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
299 changes: 188 additions & 111 deletions Cargo.lock

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ members = [
"synthesizer/snark",
"utilities",
"utilities/derives",
"wasm"
"wasm",
"plugins/slipstream_plugin_interface",
"plugins/slipstream_plugin_manager"
]

[lib]
Expand Down Expand Up @@ -129,6 +131,7 @@ async = [ "snarkvm-ledger/async", "snarkvm-synthesizer/async" ]
cuda = [ "snarkvm-algorithms/cuda" ]
history = [ "snarkvm-synthesizer/history" ]
history-staking-rewards = [ "snarkvm-synthesizer/history-staking-rewards" ]
slipstream-plugins = [ "snarkvm-synthesizer/slipstream-plugins" ]
parameters_no_std_out = [ "snarkvm-parameters/no_std_out" ]
locktick = [
"snarkvm-console?/locktick",
Expand Down Expand Up @@ -390,6 +393,14 @@ default-features = false
path = "ledger/store"
version = "=4.6.0"

[workspace.dependencies.snarkvm-slipstream-plugin-interface]
path = "plugins/slipstream_plugin_interface"
version = "=4.6.0"

[workspace.dependencies.snarkvm-slipstream-plugin-manager]
path = "plugins/slipstream_plugin_manager"
version = "=4.6.0"

[workspace.dependencies.snarkvm-ledger-test-helpers]
path = "ledger/test-helpers"
version = "=4.6.0"
Expand Down
4 changes: 4 additions & 0 deletions ledger/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ history-staking-rewards = [
"snarkvm-ledger-store/history-staking-rewards",
"snarkvm-synthesizer/history-staking-rewards",
]
slipstream-plugins = [
"snarkvm-ledger-store/slipstream-plugins",
"snarkvm-synthesizer/slipstream-plugins",
]
locktick = [
"dep:locktick",
"snarkvm-ledger-puzzle/locktick",
Expand Down
9 changes: 7 additions & 2 deletions ledger/store/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ edition = "2024"

[features]
default = [ "indexmap/rayon" ]
history = [ ]
history-staking-rewards = [ ]
history = [ "dep:snarkvm-slipstream-plugin-manager" ]
history-staking-rewards = [ "dep:snarkvm-slipstream-plugin-manager" ]
slipstream-plugins = [ "dep:snarkvm-slipstream-plugin-manager" ]
locktick = [ "dep:locktick", "snarkvm-ledger-puzzle/locktick" ]
rocks = [ "rocksdb", "smallvec" ]
serial = [
Expand All @@ -42,6 +43,10 @@ wasm = [
]
test = [ ]

[dependencies.snarkvm-slipstream-plugin-manager]
workspace = true
optional = true

[dependencies.snarkvm-console]
workspace = true

Expand Down
195 changes: 191 additions & 4 deletions ledger/store/src/program/finalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,17 @@ use aleo_std_storage::StorageMode;
use anyhow::Result;
use core::marker::PhantomData;
use indexmap::IndexSet;
#[cfg(feature = "history")]
#[cfg(feature = "slipstream-plugins")]
use snarkvm_slipstream_plugin_manager::SlipstreamPluginManager;
#[cfg(feature = "slipstream-plugins")]
use std::sync::{Arc, OnceLock, RwLock, atomic::AtomicBool};
#[cfg(any(feature = "history", feature = "history-staking-rewards"))]
use std::{
borrow::Cow,
sync::atomic::{AtomicU32, Ordering},
};
#[cfg(all(feature = "history", feature = "slipstream-plugins"))]
type SerializedMappingEntries = Option<(Vec<u8>, Vec<u8>, Vec<(Vec<u8>, Vec<u8>)>)>;

/// TODO (howardwu): Remove this.
/// Returns the mapping ID for the given `program ID` and `mapping name`.
Expand Down Expand Up @@ -654,6 +660,15 @@ pub struct FinalizeStore<N: Network, P: FinalizeStorage<N>> {
storage: P,
/// PhantomData.
_phantom: PhantomData<N>,
/// Indicates that canonical finalize is currently in progress.
/// When `true`, storage writes notify registered Slipstream plugins.
#[cfg(feature = "slipstream-plugins")]
is_finalize_mode: Arc<AtomicBool>,
/// Optional plugin manager for streaming canonical mapping and staking updates.
/// Wrapped in `Arc` so that all clones of `FinalizeStore` share the same cell; the inner
/// `OnceLock` ensures it can be installed from a shared reference after construction.
#[cfg(feature = "slipstream-plugins")]
slipstream_plugin_manager: Arc<OnceLock<Arc<RwLock<SlipstreamPluginManager>>>>,
}

impl<N: Network, P: FinalizeStorage<N>> FinalizeStore<N, P> {
Expand All @@ -665,7 +680,14 @@ impl<N: Network, P: FinalizeStorage<N>> FinalizeStore<N, P> {
/// Initializes a finalize store from storage.
pub fn from(storage: P) -> Result<Self> {
// Return the finalize store.
Ok(Self { storage, _phantom: PhantomData })
Ok(Self {
storage,
_phantom: PhantomData,
#[cfg(feature = "slipstream-plugins")]
is_finalize_mode: Arc::new(AtomicBool::new(false)),
#[cfg(feature = "slipstream-plugins")]
slipstream_plugin_manager: Arc::new(OnceLock::new()),
})
}

/// Starts an atomic batch write operation.
Expand Down Expand Up @@ -714,6 +736,76 @@ impl<N: Network, P: FinalizeStorage<N>> FinalizeStore<N, P> {
self.storage.current_block_height()
}

/// Returns a reference to the canonical finalize mode flag.
///
/// When `true`, storage writes notify registered Slipstream plugins.
/// Set to `true` by the VM before canonical finalize runs and reset to `false` afterwards.
#[cfg(feature = "slipstream-plugins")]
pub fn is_finalize_mode(&self) -> &Arc<AtomicBool> {
&self.is_finalize_mode
}

/// Installs a Slipstream plugin manager to receive canonical mapping and staking updates.
///
/// May be called from a shared reference. Logs a warning if called more than once.
#[cfg(feature = "slipstream-plugins")]
pub fn set_slipstream_plugin_manager(&self, manager: Arc<RwLock<SlipstreamPluginManager>>) {
if self.slipstream_plugin_manager.set(manager).is_err() {
tracing::warn!("Slipstream plugin manager is already set; ignoring subsequent call.");
}
}

/// Returns the Slipstream plugin manager, if one has been installed.
///
/// The returned `Arc` is a lightweight additional handle to the same manager instance;
/// it does not clone the manager itself.
#[cfg(feature = "slipstream-plugins")]
pub fn slipstream_plugin_manager(&self) -> Option<Arc<RwLock<SlipstreamPluginManager>>> {
self.slipstream_plugin_manager.get().cloned()
}

/// Notifies all interested plugins of a staking reward, if canonical finalize is active.
///
/// Errors from plugin calls are logged but never propagated.
#[cfg(all(feature = "history-staking-rewards", feature = "slipstream-plugins"))]
pub fn notify_staking_reward(
&self,
staker: &Address<N>,
validator: &Address<N>,
reward: u64,
new_stake: u64,
block_height: u32,
) {
if !self.is_finalize_mode.load(Ordering::SeqCst) {
return;
}

if let Some(mgr) = self.slipstream_plugin_manager.get() {
let staker_bytes = match staker.to_bytes_le() {
Ok(b) => b,
Err(e) => {
tracing::warn!("Slipstream: failed to serialize staker address: {e}");
return;
}
};
let validator_bytes = match validator.to_bytes_le() {
Ok(b) => b,
Err(e) => {
tracing::warn!("Slipstream: failed to serialize validator address: {e}");
return;
}
};
match mgr.read() {
Ok(plugin_mgr) => {
plugin_mgr.notify_staking_reward(&staker_bytes, &validator_bytes, reward, new_stake, block_height)
}
Err(e) => tracing::warn!(
"Slipstream: plugin manager lock poisoned, skipping staking reward notification: {e}"
),
}
}
}

/// Returns the historical value of a mapping.
#[cfg(feature = "history")]
pub fn get_historical_mapping_value(
Expand Down Expand Up @@ -827,7 +919,51 @@ impl<N: Network, P: FinalizeStorage<N>> FinalizeStoreTrait<N> for FinalizeStore<
key: Plaintext<N>,
value: Value<N>,
) -> Result<FinalizeOperation<N>> {
self.storage.update_key_value(program_id, mapping_name, key, value)
// Serialize before moving, if a plugin notification may be needed.
#[cfg(all(feature = "history", feature = "slipstream-plugins"))]
let plugin_data = if self.is_finalize_mode.load(Ordering::SeqCst) {
if let Some(mgr) = self.slipstream_plugin_manager.get() {
match mgr.read() {
Ok(plugin_mgr) if plugin_mgr.history_mappings_enabled() => Some((
program_id.to_bytes_le()?,
mapping_name.to_bytes_le()?,
key.to_bytes_le()?,
value.to_bytes_le()?,
)),
Ok(_) => None,
Err(e) => {
tracing::warn!(
"Slipstream: plugin manager lock poisoned, skipping mapping update serialization: {e}"
);
None
}
}
} else {
None
}
} else {
None
};

let result = self.storage.update_key_value(program_id, mapping_name, key, value)?;

// Notify plugins of the update if in canonical finalize mode.
#[cfg(all(feature = "history", feature = "slipstream-plugins"))]
if let Some((pid, mname, k, v)) = plugin_data {
#[cfg(feature = "history")]
let height = self.storage.current_block_height().load(Ordering::SeqCst);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used?

#[cfg(all(not(feature = "history"), feature = "slipstream-plugins"))]
let height = 0u32;
if let Some(mgr) = self.slipstream_plugin_manager.get() {
match mgr.read() {
Ok(plugin_mgr) => plugin_mgr.notify_mapping_update(&pid, &mname, &k, &v, height),
Err(e) => tracing::warn!(
"Slipstream: plugin manager lock poisoned, skipping mapping update notification: {e}"
),
}
}
}
Ok(result)
}

/// Removes the key-value pair for the given `program ID`, `mapping name`, and `key` from storage.
Expand Down Expand Up @@ -860,7 +996,58 @@ impl<N: Network, P: FinalizeStorage<N>> FinalizeStore<N, P> {
mapping_name: Identifier<N>,
entries: Vec<(Plaintext<N>, Value<N>)>,
) -> Result<FinalizeOperation<N>> {
self.storage.replace_mapping(program_id, mapping_name, entries)
// Serialize mapping identity and all entries before moving them into storage,
// so they are available for plugin notification after the storage call.
#[cfg(all(feature = "history", feature = "slipstream-plugins"))]
let plugin_data: SerializedMappingEntries = if self.is_finalize_mode.load(Ordering::SeqCst) {
if let Some(mgr) = self.slipstream_plugin_manager.get() {
match mgr.read() {
Ok(plugin_mgr) if plugin_mgr.history_mappings_enabled() => {
let mut serialized_entries = Vec::with_capacity(entries.len());
for (key, value) in &entries {
serialized_entries.push((key.to_bytes_le()?, value.to_bytes_le()?));
}
Some((program_id.to_bytes_le()?, mapping_name.to_bytes_le()?, serialized_entries))
}
Ok(_) => None,
Err(e) => {
tracing::warn!(
"Slipstream: plugin manager lock poisoned, skipping mapping replace serialization: {e}"
);
None
}
}
} else {
None
}
} else {
None
};

let result = self.storage.replace_mapping(program_id, mapping_name, entries)?;

// Notify plugins of each updated key-value pair if in canonical finalize mode.
#[cfg(all(feature = "history", feature = "slipstream-plugins"))]
if let Some((pid, mname, serialized_entries)) = plugin_data {
#[cfg(feature = "history")]
let height = self.storage.current_block_height().load(Ordering::SeqCst);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same note as above

#[cfg(all(not(feature = "history"), feature = "slipstream-plugins"))]
let height = 0u32;
if let Some(mgr) = self.slipstream_plugin_manager.get() {
match mgr.read() {
Ok(plugin_mgr) => {
for (k, v) in &serialized_entries {
plugin_mgr.notify_mapping_update(&pid, &mname, k, v, height);
}
}
Err(e) => tracing::warn!(
"Slipstream: plugin manager lock poisoned, skipping mapping update notifications: {e}"
),
}
}
}

Ok(result)
}

/// Removes the mapping for the given `program ID` and `mapping name` from storage,
Expand Down
15 changes: 15 additions & 0 deletions plugins/slipstream_plugin_interface/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "snarkvm-slipstream-plugin-interface"
version = "4.6.0"
authors = [ "The Aleo Team <hello@aleo.org>" ]
description = "The SnarkVM Slipstream plugin interface."
homepage = "https://aleo.org"
repository = "https://github.com/ProvableHQ/snarkVM"
license = "Apache-2.0"
edition = "2024"

[dependencies.anyhow]
workspace = true

[dependencies.tracing]
workspace = true
68 changes: 68 additions & 0 deletions plugins/slipstream_plugin_interface/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Aleo Slipstream Plugin Interface

This crate enables a plugin to be added into a SnarkVM runtime to
take actions at the time of mapping updates at block finalization;
for example, saving historical mappings state and staking data to an external database. The plugin must
implement the `SlipstreamPlugin` trait. Please see the details of the
`slipstream_plugin_interface.rs` for the interface definition.

# Components

### `plugins/slipstream_plugin_interface`
Defines the `SlipstreamPlugin` trait — the interface all plugins must implement.

| Method | Description |
|---|---|
| `on_load` / `on_unload` | Lifecycle hooks |
| `notify_mapping_update` | Called when a mapping key-value is inserted/updated during canonical finalize; args are serialized to bytes for object-safety |
| `notify_staking_reward` | Called once per staker per block during staking reward distribution |
| `history_enabled` / `history_staking_rewards_enabled` | Flags plugins use to opt in to data streams |

### `plugins/slipstream_plugin_manager`
Manages loaded plugins and their backing `libloading::Library` handles.

- **`LoadedSlipstreamPlugin`** — wrapper holding a boxed plugin + its name; implements `Deref`/`DerefMut`
- **`SlipstreamPluginManager`**
- `unload()` — fires `on_unload()` on each plugin then drops the libraries
- `history_mappings_enabled()` / `history_staking_rewards_enabled()` — aggregate opt-in checks
- `notify_mapping_update()` — fan-out broadcast to all interested plugins
- **`SlipstreamService`** — async service wrapping the manager (separate file)

---

## Plugin Config File (JSON5)

Each plugin requires a config file:
```json5
{
"libpath": "/path/to/libmy_plugin.so", // required; relative paths resolve from the config file's dir
"name": "my_plugin" // optional; overrides the plugin's name() return value
}
```

---

## Plugin Library Convention

The shared library (`.so` / `.dylib` / `.dll`) must export a C function:
```rust
#[no_mangle]
pub extern "C" fn _create_plugin() -> *mut dyn SlipstreamPlugin {
Box::into_raw(Box::new(MyPlugin::new()))
}
```

---

## Startup

`SlipstreamPluginService::new()` takes a slice of config file paths:
```rust
let service = SlipstreamPluginService::new(&[
PathBuf::from("/etc/aleo/plugins/my_plugin.json5"),
])?;
```

> **Note:** Not yet wired up to any CLI flags or environment variables. How/where `SlipstreamPluginService` gets constructed and passed into the VM still needs to be plumbed in (likely in snarkOS or wherever the VM is instantiated).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we reword/remove?


> Errors from plugin callbacks (`notify_mapping_update`, `notify_staking_reward`) are logged as warnings and never propagated — a misbehaving plugin will not crash the node.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally like the "fail loudly" philosophy. But proper error handling/metrics is also more work so can be left for the future.

Loading