Skip to content

Clarify the intended architecture for configurable Wasm transaction plugins #613

Description

@raushan728

Summary

While looking into the WasmPlugin TODO, I started tracing the current transaction plugin architecture to understand what would be required for an implementation.

The overall direction is clear:

  • allow operator-provided transaction plugins
  • keep built-in plugins alongside external plugins
  • load plugins from configuration
  • integrate them into TransactionPluginRunner

However, after following the current implementation, I noticed that the remaining work extends beyond the host-side wrapper. The current TODO explains where a WasmPlugin should fit, but it doesn't yet define the contract between the Rust host and the Wasm guest.

I'd like to clarify the intended architecture so the implementation matches the long-term direction of the project rather than introducing an ABI that later needs to change.


Current implementation

Transaction plugin interface

#[async_trait]
trait TransactionPlugin: Send + Sync {
async fn validate(
&self,
transaction: &mut VersionedTransactionResolved,
_config: &Config,
_rpc_client: &RpcClient,
fee_payer: &Pubkey,
context: PluginExecutionContext,
) -> Result<(), KoraError>;
/// Returns (errors, warnings) for this plugin's config requirements.
/// Called at startup by the config validator.
fn validate_config(&self, _config: &Config) -> (Vec<String>, Vec<String>) {
(vec![], vec![])
}
}

This defines the current TransactionPlugin interface.

The trait currently receives:

  • &mut VersionedTransactionResolved
  • &Config
  • &RpcClient
  • &Pubkey
  • PluginExecutionContext

This works naturally for native Rust plugins, but it raises questions for a Wasm guest because some of these values cannot cross the Wasm boundary directly.


Wasm TODO

// TODO: WasmPlugin — operators should be able to register custom plugins via a config
// path (e.g. `plugins = [{type = "wasm", path = "my_plugin.wasm"}]`) without requiring a
// Kora source change or new release. A WasmPlugin implementing TransactionPlugin would
// load a .wasm module at startup and call it for each transaction. The migration is clean:
// WasmPlugin sits alongside typed built-ins until we're ready to drop hardcoded dispatch.
//
// pub struct WasmPlugin { engine: wasmtime::Engine, module: wasmtime::Module }
// impl TransactionPlugin for WasmPlugin { ... }
//
// TransactionPluginType would gain a `Wasm { path: PathBuf }` variant alongside GasSwap.

The TODO clearly explains the desired direction:

  • operator-provided Wasm plugins
  • configuration-driven loading
  • WasmPlugin
  • wasmtime
  • coexistence with existing built-in plugins

This provides the host-side direction, but it doesn't yet define the guest-side interface.


Current plugin construction

for plugin in &config.kora.plugins.enabled {
if !enabled.insert(plugin.clone()) {
continue;
}
match plugin {
TransactionPluginType::GasSwap => {
plugins.push(Box::new(GasSwapPlugin));
}
TransactionPluginType::DeployAuthority => {
plugins.push(Box::new(DeployAuthorityPlugin));
}
}
}
Self { plugins }
}

Currently the runner constructs only built-in plugins through a fixed match.

This seems like the natural place where external plugins would eventually be integrated.


Configuration model

price_ttl: DEFAULT_CACHE_PRICE_TTL,
}
}
}
impl CacheConfig {
/// Resolve the Redis URL to use. Priority: `KORA_REDIS_URL` env var over the `url`
/// field from the TOML config. Returns `None` when neither is set.
pub fn resolved_url(&self) -> Option<String> {
std::env::var("KORA_REDIS_URL").ok().or_else(|| self.url.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum TransactionPluginType {
GasSwap,
DeployAuthority,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
#[serde(default)]
pub struct PluginsConfig {
/// List of enabled transaction plugins, executed for sign/signAndSend flows
pub enabled: Vec<TransactionPluginType>,
}

Currently the configuration is limited to built-in plugin types.

Supporting external plugins will likely require extending this configuration model while keeping the current format backward compatible.


Configuration validation

pub fn validate_config(config: &Config) -> (Vec<String>, Vec<String>) {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut seen = HashSet::new();
for plugin_type in &config.kora.plugins.enabled {
if !seen.insert(plugin_type.clone()) {
continue;
}
let plugin: Box<dyn TransactionPlugin> = match plugin_type {
TransactionPluginType::GasSwap => Box::new(GasSwapPlugin),
TransactionPluginType::DeployAuthority => Box::new(DeployAuthorityPlugin),
};
let (e, w) = plugin.validate_config(config);
errors.extend(e);
warnings.extend(w);
}
(errors, warnings)
}

and

if config.kora.sign_max_retries > HIGH_SIGN_MAX_RETRIES_WARNING_THRESHOLD {
warnings.push(format!(
"sign_max_retries ({}) is very high - consider reducing to prevent long request hangs",
config.kora.sign_max_retries
));
}
let mut unique_plugins = std::collections::HashSet::new();
for plugin in &config.kora.plugins.enabled {
if !unique_plugins.insert(plugin.clone()) {
warnings.push(format!("Duplicate transaction plugin configured: {:?}", plugin));
}
}
let (plugin_errors, plugin_warnings) = TransactionPluginRunner::validate_config(config);
errors.extend(plugin_errors);
warnings.extend(plugin_warnings);

The existing validation flow already provides a natural place for plugin-specific validation.


Existing plugin examples

GasSwap:

https://github.com/solana-foundation/kora/blob/e84bcaba4532135af941cd4654c3bb3a100ad2b7/crates/lib/src/plugin/plugin_gas_swap.rs

DeployAuthority:

https://github.com/solana-foundation/kora/blob/e84bcaba4532135af941cd4654c3bb3a100ad2b7/crates/lib/src/plugin/plugin_deploy_authority.rs

One interesting observation is that DeployAuthorityPlugin depends on RpcClient for on-chain lookups, while GasSwapPlugin performs only local validation.

That difference made me wonder what the intended capabilities of a Wasm plugin are expected to be.


Questions

While exploring the implementation, I wasn't able to determine the intended Wasm ABI.

Some questions came up:

  1. Should a Wasm plugin expose a single validation entry point, or is another interface expected?

  2. Is the intention for Wasm plugins to support transaction mutation, or should they only perform validation?

  3. How should a Wasm plugin perform operations that currently rely on RpcClient (for example, logic similar to DeployAuthorityPlugin)?

  4. Is there an intended serialization format between the host and the guest?

  5. Is wasmtime still the preferred runtime, or was it simply used as an example in the TODO?

  6. Would it make sense to define a small ABI/interface document before implementing the runtime?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions