Implementing a Module
A module is the basic unit of functionality in the Sovereign SDK. It's a self-contained piece of onchain logic that manages its own state and defines how users can interact with it.
The best way to learn how modules work is to build one. In this section, we'll create a simple but complete ValueSetter
module from scratch. This module will allow a designated admin address to set a u32
value in the rollup's state. After the walkthrough, we'll dive deeper into each of the concepts introduced.
Think of this tutorial as your guide to the fundamental components of a module. Once you understand the concepts, we recommend starting your own module by copying the ExampleModule
provided in the starter repository. It has all the necessary dependencies and file structure pre-configured for you.
A Step-by-Step Walkthrough: The ValueSetter
Module
1. Defining the Module Struct
First, we define the module's structure and the state it will manage. This struct tells the SDK what data to store onchain.
use sov_modules_api::{Module, ModuleId, ModuleInfo, StateValue, Spec};
// This is the struct that will represent our module.
// It must derive `ModuleInfo` to be a valid module.
#[derive(Clone, ModuleInfo)]
pub struct ValueSetter<S: Spec> {
/// The `#[id]` attribute is required and uniquely identifies the module instance.
#[id]
pub id: ModuleId,
/// The `#[state]` attribute marks a field as a state variable.
/// `StateValue` stores a single, typed value.
#[state]
pub value: StateValue<u32>,
/// We'll also store the address of the admin who is allowed to change the value.
/// S:Address is the address type of our rollup. More on `Spec` later.
#[state]
pub admin: StateValue<S::Address>,
}
2. Defining Types for the Module
Trait
Next, we define the associated types required by the Module
trait: its configuration, its callable methods, and its events.
// Continuing in the same file...
use schemars::JsonSchema;
use sov_modules_api::macros::{serialize, UniversalWallet};
// The configuration for our module at genesis. This will be deserialized from `genesis.json`.
#[derive(Clone, Debug, PartialEq, Eq)]
#[serialize(Borsh, Serde)]
#[serde(rename_all = "snake_case")]
pub struct ValueSetterConfig<S: Spec> {
pub initial_value: u32,
pub admin: S::Address,
}
// The actions a user can take. Our module only supports one action: setting the value.
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, UniversalWallet)]
#[serialize(Borsh, Serde)]
#[serde(rename_all = "snake_case")]
pub enum CallMessage {
SetValue(u32),
}
// The event our module will emit after a successful action.
#[derive(Clone, Debug, PartialEq, Eq)]
#[serialize(Borsh, Serde)]
#[serde(rename_all = "snake_case")]
pub enum Event {
ValueChanged(u32),
}
3. Implementing the Module
Trait Logic
With our types defined, we can now implement the Module
trait itself.
use anyhow::Result;
use sov_modules_api::{Context, GenesisState, TxState, EventEmitter};
// Now, we implement the `Module` trait.
impl<S: Spec> Module for ValueSetter<S> {
type Spec = S;
type Config = ValueSetterConfig<S>;
type CallMessage = CallMessage;
type Event = Event;
// `genesis` is called once when the rollup is deployed to initialize the state.
fn genesis(&mut self, _header: &<S::Da as sov_modules_api::DaSpec>::BlockHeader, config: &Self::Config, state: &mut impl GenesisState<S>) -> Result<()> {
self.value.set(&config.initial_value, state)?;
self.admin.set(&config.admin, state)?;
Ok(())
}
// `call` is called when a user submits a transaction to the module.
fn call(&mut self, msg: Self::CallMessage, context: &Context<S>, state: &mut impl TxState<S>) -> Result<()> {
match msg {
CallMessage::SetValue(new_value) => {
self.set_value(new_value, context, state)?;
Ok(())
}
}
}
}
4. Writing the Business Logic
The final piece is to write the private set_value
method containing our business logic.
impl<S: Spec> ValueSetter<S> {
fn set_value(&mut self, new_value: u32, context: &Context<S>, state: &mut impl TxState<S>) -> Result<()> {
let admin = self.admin.get_or_err(state)??;
if admin != *context.sender() {
return Err(anyhow::anyhow!("Only the admin can set the value.").into());
}
self.value.set(&new_value, state)?;
self.emit_event(state, Event::ValueChanged(new_value));
Ok(())
}
}
With that, you've implemented a complete module! Now, let's break down the concepts we used in more detail.
Anatomy of a Module: A Deeper Look
Derived Traits: ModuleInfo
and ModuleRestApi
You should always derive ModuleInfo
on your module, since it does important
work like laying out your state values in the database. If you forget to derive
this trait, the SDK will throw a helpful error.
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
walkthrough, the SDK would generate 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.
The Spec
Generic
Modules are generic over a Spec
type, which provides access to core rollup types. By being generic over the Spec
, you ensure that you can easily change things like the Address
format used by your module later on without rewriting your logic.
Key types provided by Spec
include:
S::Address
: The address format used on the rollup.S::Da::Address
: The address format of the underlying Data Availability layer.S::Da::BlockHeader
: The block header type of the DA layer.
#[state]
, #[module]
, and #[id]
fields
#[id]
: Every module must have exactly one#[id]
field. TheModuleInfo
macro uses this to store the module's unique, auto-generated identifier.#[module]
: This attribute declares a dependency on another module. For example, if ourValueSetter
needed to pay a fee, we could add#[module] pub bank: sov_bank::Bank<S>
, allowing us to callself.bank.transfer(...)
in our logic.#[state]
: This attribute marks a field as a state variable that will be stored in the database.
State Types In-Depth
The SDK provides several state types, each for a different use case:
StateValue<T>
: Stores a single item of type T. We used this forvalue
andadmin
variables.StateMap<K, V>
: Stores a key-value mapping.StateVec<T>
: Stores an ordered list of items, accessible by index.
The generic types can be any deterministic Rust data structure, anything from a simple u32
to a complex BTreeMap
.
Accessory State: For each state type, there is a corresponding AccessoryState*
variant (e.g., AccessoryStateMap
). Accessory state is special: it can be read and written via the API, but it is write-only during a transaction. This makes it much cheaper to use for data that doesn't affect onchain logic, like indexing purchase histories for an off-chain frontend.
Codecs: By default, all state is serialized using Borsh. If you need to store a type from a third-party library that only supports serde, you can specify a different codec: StateValue<ThirdPartyType, BcsCodec>.
The Module
Trait and its Methods
The Module
trait is the core of your application's onchain logic. The implementation you wrote in the walkthrough satisfies this trait's requirements.
Let's look at a simplified version of the trait definition to understand its components:
trait Module {
/// The configuration needed to initialize the module, deserialized from `genesis.json`.
type Config;
/// A module-defined enum representing the actions a user can take.
type CallMessage: Debug + BorshSerialize + BorshDeserialize + Clone;
/// A module-defined enum representing the events emitted by successful calls.
type Event: Debug + BorshSerialize + BorshDeserialize + 'static + core::marker::Send;
/// `genesis` is called once when the rollup is deployed to initialize state.
///
/// The logic here must be deterministic, but since it only runs once,
/// efficiency is not a primary concern.
fn genesis(
&mut self,
genesis_rollup_header: &<<Self::Spec as Spec>::Da as DaSpec>::BlockHeader,
config: &Self::Config,
state: &mut impl GenesisState<Self::Spec>,
) -> Result<(), Error>;
/// `call` accepts a `CallMessage` and executes it, changing the module's state.
fn call(
&mut self,
message: Self::CallMessage,
context: &Context<Self::Spec>,
state: &mut impl TxState<Self::Spec>,
) -> Result<CallResponse, Error>;
}
genesis
The genesis
function is called once when the rollup is deployed. It uses the module's Config
struct (defined as the associated type Config
) to initialize the state. This Config
is deserialized from the genesis.json
file.
call
The call
function provides the transaction processing logic. It accepts a structured CallMessage
from a user and a Context
containing metadata like the sender's address. If your call
function returns an error, the SDK automatically reverts all state changes and discards any events, ensuring that transactions are atomic.
You can define the CallMessage
to be any type you wish, but an enum is usually best. Be sure to derive borsh
and serde
serialization, as well as schemars::JsonSchema
and UniversalWallet
. This ensures your CallMessage
is portable across different languages and frontends.
A Note on Gas and Security: Just like Ethereum smart contracts, modules accept inputs that are pre-validated by the chain. Your call method does not need to worry about authenticating the transaction sender. The SDK also automatically meters gas for state accesses. You only need to manually charge gas (using Module::charge_gas(...)
) if your module performs heavy computation outside of state reads/writes.
Events
Events are the primary way your module communicates with the outside world. They are structured data included in transaction receipts and are essential for:
- Querying via REST API.
- Streaming in real-time via WebSockets.
- Building off-chain indexers and databases.
Important: Events are only emitted when transactions succeed. If a transaction reverts, all its events are discarded. This makes events perfect for reliably indexing onchain state.
Error Handling
Modules use anyhow::Result
for error handling, providing rich context that helps both developers and users understand what went wrong.
When your call method returns an Err, the SDK automatically reverts all state changes made during the transaction. This ensures that your module's logic is atomic.
use anyhow::{Context, Result};
// Simplified code snippet from Bank module
fn transfer(&self, from: &S::Address, amount: u64, state: &mut impl TxState<S>) -> Result<()> {
let balance = self.balances.get(from, state)?
.with_context(|| format!("Failed to read balance for sender {}", from))?
.unwrap_or(0);
if balance < amount {
return Err(anyhow::anyhow!("Insufficient balance: {} < {}", balance, amount));
}
// ...
Ok(())
}
For more details on error handling patterns, see the Advanced Topics section.
Next Step: Ensuring Correctness
You now have a deep understanding of how to define, implement, and structure a module. With this foundation, you're ready to test your module.
In the next section, "Testing Your Module," we'll show you how to use the SDK's powerful testing framework to write comprehensive tests for your new module.