Quickstart: Your First Module
In this section, you’ll write and deploy your own business logic as a rollup.
We'll start with a very basic ValueSetter
module that's already included in the rollup-starter
.
The ValueSetter
module currently stores a single number that any user can update. We want to ensure that only one user (the admin) has permission to update this number.
This requires four changes:
- Add an
admin
field to the module's state to store the admin address. - Create a configuration struct so that we can set the admin address when the rollup launches.
- Initialize the
admin
from the configuration struct in thegenesis
method, which sets up the module's initial state. - Add a check in the
call
method to verify that the transaction sender is the admin.
Let's get started.
Step 1: Understand the Starting Point
First, navigate to the value-setter
module in the starter repository and open the src/lib.rs
file.
# From the sov-rollup-starter root
cd examples/value-setter/
The code in this file defines the module's structure and a call
method that lets anyone set the value.
Here’s the simplified lib.rs
that we'll start with:
// In examples/value-setter/src/lib.rs
#[derive(Clone, ModuleInfo, ModuleRestApi)]
pub struct ValueSetter<S: Spec> {
#[id]
pub id: ModuleId,
/// Holds the value
#[state]
pub value: StateValue<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, UniversalWallet)]
#[serialize(Borsh, Serde)]
#[serde(rename_all = "snake_case")]
pub enum CallMessage {
SetValue(u32),
}
impl<S: Spec> Module for ValueSetter<S> {
type Spec = S;
type Config = (); // No configuration yet!
type CallMessage = CallMessage;
type Event = ();
// The `call` method handles incoming transactions.
// Notice it doesn't check *who* is calling.
fn call(&mut self, msg: Self::CallMessage, _context: &Context<S>, state: &mut impl TxState<S>) -> Result<()> {
match msg {
CallMessage::SetValue(new_value) => {
self.value.set(&new_value, state)?;
Ok(())
}
}
}
}
Step 2: Implement the Admin Logic
Now, let's secure our module. We'll perform the four edits we outlined earlier.
a) Add the admin
State Variable
First, we need a place to store the admin's address. We'll add a new admin
field to the ValueSetter
struct and mark it with the #[state]
attribute.
// In examples/value-setter/src/lib.rs
#[derive(Clone, ModuleInfo, ModuleRestApi)]
pub struct ValueSetter<S: Spec> {
// ... existing code ...
/// The new state value to hold the address of the admin.
#[state]
pub admin: StateValue<S::Address>,
}
b) Define a Configuration Struct
Next, we need a way to tell the module who the admin is when the rollup first starts. We do this by defining a Config
struct. The SDK will automatically load data from a genesis.json
file into this struct.
// In examples/value-setter/src/lib.rs
// Add the module's configuration, read from genesis.json
#[derive(Clone, Debug, PartialEq, Eq)]
#[serialize(Serde)]
#[serde(rename_all = "snake_case")]
pub struct ValueSetterConfig<S: Spec> {
pub admin: S::Address,
}
c) Initialize the Admin at Genesis
With our Config
struct defined, we can now implement the genesis
method. This function is called once when the rollup is launched. It takes the config
as an argument and uses it to set the initial state.
We also need to tell the Module
implementation to use our new ValueSetterConfig
.
// In examples/value-setter/src/lib.rs
// ... existing code ...
impl<S: Spec> Module for ValueSetter<S> {
type Spec = S;
type Config = ValueSetterConfig<S>; // Use the new config struct
type CallMessage = CallMessage;
type Event = ();
// `genesis` initializes the module's state. Here, we set the admin address.
fn genesis(&mut self, _header: &<S::Da as sov_modules_api::DaSpec>::BlockHeader, config: &Self::Config, state: &mut impl GenesisState<S>) -> Result<()> {
self.admin.set(&config.admin, state)?;
Ok(())
}
fn call(&mut self, msg: Self::CallMessage, context: &Context<S>, state: &mut impl TxState<S>) -> Result<()> {
// ... existing code ...
Note: The
genesis
method is called only once, when the rollup first starts. If you've previously run the rollup, you'll need to clear the database and restart from scratch to ensure thegenesis
method runs again and theadmin
is set. You can do this using themake clean-db
command.
d) Add the Admin Check in call
The final piece. We'll modify the call
method to read the admin
address from state and compare it to the transaction sender. If they don't match, the transaction fails.
// In examples/value-setter/src/lib.rs
// ... existing code ...
fn call(&mut self, msg: Self::CallMessage, context: &Context<S>, state: &mut impl TxState<S>) -> Result<()> {
match msg {
CallMessage::SetValue(new_value) => {
// Read the admin's address from state.
let admin = self.admin.get_or_err(state)??;
// Ensure the sender is the admin.
anyhow::ensure!(admin == *context.sender(), "Only the admin can set the value.");
// If the check passes, update the state.
self.value.set(&new_value, state)?;
Ok(())
}
}
}
}
Step 3: Configure the Genesis State
Our genesis
method reads the admin's address from a configuration file. We need to provide that value in configs/mock_da/genesis.json
.
The SDK automatically deserializes this JSON into our ValueSetterConfig
struct (since we plugged in said struct as the Config
associated type of our module) when the rollup starts.
// In sov-rollup-starter/configs/mock_da/genesis.json
{
// ... other module configs
"value_setter": {
"admin": "0x9b08ce57a93751aE790698A2C9ebc76A78F23E25"
}
}
Previously, the value_setter
field was null
. Now, we've given it the data our module needs to initialize the admin address.
How is the Module Integrated?
You might be wondering how the rollup knows about the value-setter
module in the first place. In the sov-rollup-starter
, we've already "wired it up" for you to keep this quickstart focused on module logic.
For your own future modules, the process involves:
- Adding the module crate to the workspace in the root
Cargo.toml
. - Adding it as a dependency to the core logic in
crates/stf/Cargo.toml
. - Adding the module as a field on the
Runtime
struct incrates/stf/src/runtime.rs
.
You can remove value-setter
from these files to see what it's like to build and integrate a module from scratch.
Step 4: Build, Run, and Interact!
Now let's see your logic in action.
-
Build and Run the Rollup: From the root directory, start the rollup.
cargo run
-
Query the Initial State: In another terminal, use
curl
to check the initial value. It should benull
because ourgenesis
method only sets theadmin
, not thevalue
.curl http://127.0.0.1:12346/modules/value-setter/state/value # Expected output: {"value":null}
-
Submit a Transaction: Now, let's change the value. We'll edit the example js script in starter to call our module.
- Open the
examples/starter-js/src/index.ts
file. - The
signer
in this script corresponds to theadmin
address we set ingenesis.json
. - Find the
callMessage
variable and replace it with a call to yourvalue_setter
module.
// In sov-rollup-starter/examples/starter-js/src/index.ts // Replace the existing call message with this one: const callMessage: RuntimeCall = { value_setter: { // The module's name in the Runtime struct set_value: 99, // The CallMessage variant (in snake_case) and its new value }, };
- Install js dependencies, and run the script to send the transaction:
# From the sov-rollup-starter/examples/starter-js directory npm install npm run start
- Open the
-
Verify the Change: Now for the "Aha!" moment. Query the state again:
curl http://127.0.0.1:12346/modules/value-setter/state/value # Expected output: {"value":99}
Congratulations! You have successfully written and interacted with your own custom logic on a Sovereign SDK rollup!