Advanced Topics

This section covers advanced module development features that go beyond basic functionality. While the core module implementation handles state management and transaction processing, you may need these additional capabilities for production use cases.

All features in this section are optional. Start with the basic module implementation and add these capabilities as your requirements grow.

Hooks

In addition to call, modules may optionally implement Hooks. Hooks can run at the begining and end of every rollup block and every transaction. BlockHooks are great for taking actions that need to happen before or after any transaction executes in a block - but be careful, no one pays for the computation done by BlockHooks, so doing any heavy computation can make your rollup vulnerable to DOS attacks.

TxHooks are useful for checking invariants, or to allow your module to monitor actions being taken by other modules. Unlike BlockHooks, TxHooks are paid for by the user who sent each transaction.

The FinalizeHook is great for doing indexing. It can only modify AccessoryState, which makes it cheap to run but means that the results will only be visible via the API.

Using the hooks is somewhat unusual - most applications only need to modify their state in response to user actions - but it's a powerful tool in some cases. See the documentation on BlockHooks and TxHooks and FinalizeHook more details.

Error Handling

When to Panic vs Return Errors

Panic when:

  • You encounter a bug that indicates broken invariants
  • The error is unrecoverable and continuing would compromise state integrity

When you panic, the rollup will shut down. This is correct for bugs that could corrupt your state.

Return errors when:

  • User input is invalid
  • Business logic conditions aren't met (insufficient balance, unauthorized access, etc.)
  • Any expected failure condition

Transaction errors automatically revert all state changes.

Writing Error Messages

Your error messages serve both end users and developers. Use anyhow with context to provide meaningful errors:

use anyhow::{Context, Result};

fn transfer(&self, from: &S::Address, to: &S::Address, token_id: &TokenId, amount: u64, state: &mut impl TxState<S>) -> Result<()> {
    let balance = self.balances
        .get(&(from, token_id), state)
        .context("Failed to read sender balance")?
        .unwrap_or(0);
    
    if balance < amount {
        // User-facing error message
        return Err(anyhow::anyhow!("Insufficient balance: {} < {}", balance, amount));
    }
    
    let new_balance = balance - amount;
    
    // Add context for debugging when operations fail
    self.balances
        .set(&(from, token_id), &new_balance, state)
        .with_context(|| format!("Failed to update balance for {} token {}", from, token_id))?;
    
    // ... rest of transfer logic
    Ok(())
}

Transaction reverts are normal and expected - log them at debug! level if needed for debugging, not as warnings or errors.

Native-Only Code

Some functionality should only run natively on the full nodes (and sequencer), not in the zkVM during proof generation. This is a critical concept for separating verifiable on-chain logic from off-chain operational tooling.

Any code that is not part of the core state transition must be gated with #[cfg(feature = "native")]:

#[cfg(feature = "native")]
impl<S: Spec> MyModule<S> {
    // This code only compiles natively, not in zkVM
    pub fn debug_state(&self, state: &impl StateAccessor<S>) {
        // ...
    }
}

This ensures that your zk-proofs remain small and your onchain logic remains deterministic. Common use cases for native-only code include:

  • Custom REST APIs and RPC methods
  • Metrics and logging integration
  • Debugging tools
  • Integrations with external services

Transaction Prioritization and MEV Mitigation

For latency-sensitive financial applications, managing transaction order and mitigating Maximum Extractable Value (MEV) is critical. The Sovereign SDK provides a powerful, sequencer-level tool to combat toxic orderflow by allowing developers to introduce fine-grained processing delays for specific transaction types.

This is a powerful technique for applications like on-chain Central Limit Orderbooks (CLOBs). By introducing a small, artificial delay on aggressive "take" orders, a rollup can implicitly prioritize "cancel" orders. This gives market makers a crucial window to pull stale quotes before they can be exploited by low-latency arbitrageurs, leading to fairer and more liquid markets.

This functionality is implemented via the get_transaction_delay_ms method on your Runtime struct. Because this is a sequencer-level scheduling feature and not part of the core state transition logic, it must be gated behind the native feature flag.

The method receives a decoded CallMessage and returns the number of milliseconds the sequencer should wait before processing it. A return value of 0 means the transaction should be processed immediately.

Example: Prioritizing Cancels in a CLOB

// In your-rollup/stf/src/runtime.rs

// In the `impl<S> sov_modules_stf_blueprint::Runtime<S> for Runtime<S>` block:

#[cfg(feature = "native")]
fn get_transaction_delay_ms(&self, call: &Self::Decodable) -> u64 {
    // `Self::Decodable` is the auto-generated `RuntimeCall` enum for your runtime.
    // It has one variant for each module in your `Runtime` struct.
    match call {
        // Introduce a small 10ms delay on all "take" orders to give
        // market makers time to cancel stale orders.
        // (Here, `Clob` is the variant corresponding to the `clob` field in your `Runtime` struct,
        // and `PlaceTakeOrder` is the variant of the `clob` module's `CallMessage` enum.)
        Self::Decodable::Clob(clob::CallMessage::PlaceTakeOrder { .. }) => 50,

        // All other CLOB operations, like placing or cancelling "make" orders,
        // are processed immediately with zero delay.
        Self::Decodable::Clob(..) => 0,
        
        // All other transactions in other modules are also processed immediately.
        _ => 0,
    }
}

This feature gives you precise control over your sequencer's processing queue, enabling sophisticated MEV mitigation strategies without altering your core onchain business logic.

Adding Custom REST APIs

You can easily add custom APIs to your module by implementing the HasCustomRestApi trait. This trait has two methods - one which actually implements the routes, and an optional one which provides an OpenApi spec. You can see a good example in the Bank module:

#![cfg(feature = "native")]
impl<S: Spec> HasCustomRestApi for Bank<S> {
    type Spec = S;

    fn custom_rest_api(&self, state: ApiState<S>) -> axum::Router<()> {
        axum::Router::new()
            .route(
                "/tokens/:tokenId/total-supply",
                get(Self::route_total_supply),
            )
            .with_state(state.with(self.clone()))
    }

    fn custom_openapi_spec(&self) -> Option<OpenApi> {
        let mut open_api: OpenApi =
            serde_yaml::from_str(include_str!("../openapi-v3.yaml")).expect("Invalid OpenAPI spec");
        for path_item in open_api.paths.paths.values_mut() {
            path_item.extensions = None;
        }
        Some(open_api)
    }
}

async fn route_balance(
    state: ApiState<S, Self>,
    mut accessor: ApiStateAccessor<S>,
    Path((token_id, user_address)): Path<(TokenId, S::Address)>,
) -> ApiResult<Coins> {
    let amount = state
        .get_balance_of(&user_address, token_id, &mut accessor)
        .unwrap_infallible() // State access can't fail because no one has to pay for gas.
        .ok_or_else(|| errors::not_found_404("Balance", user_address))?;

    Ok(Coins { amount, token_id }.into())
}

REST API methods get access to an ApiStateAccessor. This special struct gives you access to both normal and Accessory state values. You can freely read and write to state during your API calls, which makes it easy to reuse code from the rest of your module. However, it's important to remember API calls do not durably mutate state. Any state changes are thrown away at the end of the request.

If you implement a custom REST API, your new routes will be automatically nested under your module's router. So, in the following example, the tokens/:tokenId/total-supply function can be found at /modules/bank/tokens/:tokenId/total-supply. Similarly, your OpenApi spec will get combined with the auto-generated one automatically.

Note that for for custom REST APIs, you'll need to manually write an OpenApi specification if you want client support.

Legacy RPC Support

In addition to custom RESTful APIs, the Sovereign SDK lets you create JSON-RPC methods. This is useful to provide API compatibility with existing chains like Ethereum and Solana, but we recommend using REST APIs whenever compatibility isn't a concern.

To implement RPC methods, simply annotate an impl block on your module with the #[rpc_gen(client, server)] macro, and then write methods which accept an ApiStateAcessor as their final argument and return an RpcResult. You can see some examples in the Evm module.

#![cfg(feature = "native")]
#[rpc_gen(client, server)]
impl<S: Spec> Evm<S> {
    /// Handler for `net_version`
    #[rpc_method(name = "eth_getStorageAt")]
    pub fn get_storage_at(
        &self,
        address: Address,
        index: U256,
        state: &mut ApiStateAccessor<S>,
    ) -> RpcResult<U256> {
        let storage_slot = self
            .account_storage
            .get(&(&address, &index), state)
            .unwrap_infallible()
            .unwrap_or_default();
        Ok(storage_slot)
    }
}

Mastering Your Module

By leveraging Hooks, robust error handling, and custom APIs, you can build sophisticated, production-grade modules that are both powerful and easy to operate.

With a deep understanding of module implementation, you may next want to optimize your rollup's performance. The next section on "Understanding Performance" will dive into state access patterns and cryptographic considerations that can significantly impact your application's throughput.