Anatomy of a Module
As we begin our journey into building a production-ready rollup, the first step is to understand the two most important architectural concepts in the Sovereign SDK: the Runtime and its Modules.
Runtime vs. Modules
The runtime is the orchestrator of your rollup. It receives transactions, deserializes them, and routes them to the appropriate modules for execution. Think of it as the central nervous system that connects all your application logic. The Runtime struct you define in your rollup code specifies which modules are included.
Modules contain the actual business-logic. Each module manages its own state and defines the specific actions (called "call messages") that users can perform. Modules are usually small and self-contained, but they can contain dependencies on other modules when it makes sense to.
Now that we understand this high-level structure, let's dissect the ValueSetter module you built and enhance it with production-grade features.
Dissecting the ValueSetter Module
The Module Struct: State and Dependencies
First, let's look at the ValueSetter struct, which defined its state variables and its dependencies on other modules.
#[derive(Clone, ModuleInfo, ModuleRestApi)]
pub struct ValueSetter<S: Spec> {
#[id]
pub id: ModuleId,
#[state]
pub value: StateValue<u32>,
#[state]
pub admin: StateValue<S::Address>,
}
This struct is defined by several key attributes and the Spec generic:
#[derive(ModuleInfo)]: This derive macro is mandatory. It performs essential setup, like laying out your state values in the database.#[id]: Every module must have exactly one field with this attribute. The SDK uses it to store the module's unique, auto-generated identifier.#[state]: This attribute marks a field as a state variable that will be stored in the database. More on state management later.- The
SpecGeneric: All modules are generic over aSpec. This provides core types likeS::Addressand makes your module portable across things like DA layers, zkVMs, and address formats. #[module]: While not used in this example, this attribute declares a dependency on another module. For example, if ourValueSetterneeded to charge a fee, we could add#[module] pub bank: sov_bank::Bank<S>, allowing us to call methods likeself.bank.transfer(...)from our own logic.
The ModuleRestApi Trait
Deriving the ModuleRestApi trait is optional but highly recommended. It automatically generates RESTful API endpoints for the #[state] items in your module. Each item's endpoint will have the name {hostname}/modules/{module-name}/{field-name}/, with all items automatically converted to kebab-casing. For example, for the value field in our ValueSetter module, the SDK generates an endpoint at the path /modules/value-setter/value.
Note that ModuleRestApi can't always generate endpoints for you. If it can't figure out how to generate an endpoint for a particular state value, it will simply skip it by default. If you want to override this behavior and throw a compiler error if endpoint generation fails, you can add the #[rest_api(include)] attribute.
State Management In-Depth
The SDK provides several "state" types for different use cases. All three types of state can be added to your module struct using the #[state] attribute.
StateValue<T>: Stores a single item of typeT. We used this for thevalueandadminvariables in our example.StateMap<K, V>: Stores a key-value mapping. This is ideal for balances or other user-specific data.StateVec<T>: Stores an ordered list of items, accessible by index.
The generic types can be any (deterministically) serializable Rust data structure.
Accessory State: For each state type, there is a corresponding AccessoryState* variant (e.g., AccessoryStateMap). Accessory state is special: it can be read via the API, but it is write-only during transaction execution. This makes it a simple and cheap storage to use for data that doesn't affect onchain logic, like purchase histories for an off-chain frontend.
The Module Trait
The Module trait is where your business logic lives. Let's review the pieces you implemented for ValueSetter in the quickstart.
-
type Configandfn genesis(): You created aValueSetterConfigand used it in thegenesismethod to initialize theadminstate. This is a standard pattern:Configdefines the initial data, read fromgenesis.json, andgenesis()applies it to the module's state when the rollup is first deployed. -
type CallMessageandfn call(): You defined aCallMessageenum for the publicSetValueaction. This enum is the public API of your module, representing the actions a user can take. Thecall()method is the entry point for these actions. The runtime passes in theCallMessageand aContextcontaining metadata like the sender's address, which you used for the admin check. -
Error Handling: In your
callmethod, you usedanyhow::ensure!to handle a user error (an invalid sender). When acallmethod returns anErr, the SDK guarantees that all state changes are automatically reverted, ensuring atomicity. ThisResult-based approach is for predictable user errors, while unrecoverable system bugs should cause apanic!. A more detailed guide is available in theAdvanced Topicssection.
A Quick Tip on Parametrizing Your Types Over S
If you parameterize your
CallMessageorEventoverS(for example, to include an address of typeS::Address), you must add the#[schemars(bound = "S: Spec", rename = "MyEnum")]attribute on top your enum definition. This is a necessary hint forschemars, a library that generates a JSON schema for your module's API. It ensures that your generic types can be correctly represented for external tools.
Quick Tip: Handling
VectorandStringin CallMessageUse the fixed‑size wrappers
SafeVectorandSafeStringfor any fields that are deserialized directly into aCallMessage; they limit payload size and prevent DoS attacks. After deserialization, feel free to convert them to regularVectorandStringvalues and use them as usual.
Adding Events
Your ValueSetter module works, but it's a "black box." Off-chain applications have no way of knowing when the value changes without constantly polling the API. To solve this, we introduce Events.
Events are the primary mechanism for streaming on-chain data to off-chain systems like indexers and front-ends in real-time. Let's add one to our module.
First, define an Event enum.
// In examples/value-setter/src/lib.rs
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serialize(Borsh, Serde)]
#[serde(rename_all = "snake_case")]
pub enum Event {
ValueUpdated(u32),
}
Next, update your Module implementation to use this new Event type and emit it from the call method.
// In examples/value-setter/src/lib.rs
impl<S: Spec> Module for ValueSetter<S> {
type Spec = S;
type Config = ValueSetterConfig<S>;
type CallMessage = CallMessage;
type Event = Event; // Change this from ()
// The `genesis` method is unchanged.
fn genesis(&mut self, _header: &<S::Da as sov_modules_api::DaSpec>::BlockHeader, config: &Self::Config, state: &mut impl GenesisState<S>) -> Result<()> {
// ...
}
fn call(&mut self, msg: Self::CallMessage, context: &Context<S>, state: &mut impl TxState<S>) -> Result<()> {
match msg {
CallMessage::SetValue(new_value) => {
let admin = self.admin.get(state)??;
anyhow::ensure!(admin == *context.sender(), "Only the admin can set the value.");
self.value.set(&new_value, state)?;
// NEW: Emit an event to record this change.
self.emit_event(state, Event::ValueUpdated(new_value));
Ok(())
}
}
}
}
Now, whenever the admin successfully calls set_value, the module will emit a ValueUpdated event.
A key guarantee of the Sovereign SDK is that event emission is atomic with transaction execution—if a transaction reverts, so do its events. This ensures any off-chain system remains consistent with the on-chain state.
To make it simple to build scalable and faul-tolertant off-chain data pipelines, the sequencer provides a websocket endpoint that streams sequentially numbered transactions along with their corresponding events. If a client disconnects, it can reliably resume the stream from the last transaction it processed.
Next Step: Ensuring Correctness
You now have a strong conceptual understanding of how a Sovereign SDK module is structured.
In the next chapter, "Testing Your Module," we'll show you how to test your modules.