This directory contains an opinionated framework for building rollups with the Sovereign SDK. It aims to provide a "batteries included" development experience. Using the Module System still allows you to customize key components of your rollup like its hash function and signature scheme, but it also forces you to rely on some reasonable default values for things like serialization schemes (Borsh), address formats (bech32), etc.
By developing with the Module System, you get access to a suite of pre-built modules supporting common functions like generating accounts, minting and transferring tokens, and incentivizing sequencers. You also get access to powerful tools for generating RPC implementations, and a powerful templating system for implementing complex state transitions.
The basic building block of the Module System is a module
. Modules are structs in Rust, and are required to implement the Module
trait.
You can find a complete tutorial showing how to implement a custom module here.
Modules typically live in their own crates (you can find a template here) so that they're easily
re-usable. A typical struct definition for a module looks something like this:
#[derive(ModuleInfo)]
pub struct Bank<C: sov_modules_api::Context> {
/// The address of the bank module.
#[address]
pub(crate) address: C::Address,
/// A mapping of addresses to tokens in the bank.
#[state]
pub(crate) tokens: sov_state::StateMap<C::Address, Token<C>>,
}
At first glance, this definition might seem a little bit intimidating because of the generic C
.
Don't worry, we'll explain that generic in detail later.
For now, just notice that a module is a struct with an address and some #[state]
fields specifying
what kind of data this module has access to. Under the hood, the ModuleInfo
derive macro will do some magic to ensure that
any #[state]
fields get mapped onto unique storage keys so that only this particular module can read or write its state values.
At this stage, it's also very important to note that the state values are external to the module. This struct definition defines the
shape of the values that will be stored, but the values themselves don't live inside the module struct. In other words, a module doesn't
secretly have a reference to some underlying database. Instead a module defines the logic used to access state values,
and the values themselves live in a special struct called a WorkingSet
.
This has several consequences. First, it means that modules are always cheap to clone. Second it means that calling my_module.clone()
always yields the same result as calling MyModule::new()
. Finally, it means that every method of the module which reads or
modifies state needs to take a WorkingSet
as an argument.
The module might contain a field for the gas configuration. If annotated with #[gas]
under a struct that derives ModuleInfo
, it will attempt to read a constants.json
file from the root of the project, and inject it into the Default::default()
implementation of the module.
Here is an example constants.json
file:
{
"gas": {
"create_token": 4,
"transfer": 5,
"burn": 2,
"mint": 2,
"freeze": 1
}
}
The ModuleInfo
macro will look for a gas
field inside the JSON, that must be an object, and will look for the name of the module inside of the gas
object. If present, it will parse that object as gas configuration; otherwise, it will parse the gas
object directly. On the example above, it will attempt to parse a structure that looks like this:
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BankGasConfig<GU: GasUnit> {
pub create_token: GU,
pub transfer: GU,
pub burn: GU,
pub mint: GU,
pub freeze: GU,
}
The GasUnit
generic type will be defined by the runtime Context
. For DefaultContext
, we use TupleGasUnit<2>
- that is, a gas unit with a two dimensions. The same setup is defined for ZkDefaultContext
. Here is an example of a constants.json
file, specific to the Bank
module:
{
"gas": {
"comment": "this field will be ignored, as there is a matching module field",
"Bank": {
"create_token": [4, 19],
"transfer": [5, 25],
"burn": [2, 7],
"mint": [2, 6],
"freeze": [1, 4]
}
}
}
As you can see above, the fields can be either array, numeric, or boolean. If boolean, it will be converted to either 0
or 1
. If array, each element is expected to be either a numeric or boolean. The example above will create a gas unit of two dimensions. If the Context
requires less dimensions than available, it will pick the first ones of relevance, and ignore the rest. That is: with a Context
of one dimension, , the effective config will be expanded to:
BankGasConfig {
create_token: [4],
transfer: [5],
burn: [2],
mint: [2],
freeze: [1],
}
In order to charge gas from the working set, the function charge_gas
can be used.
fn call(
&self,
msg: Self::CallMessage,
context: &Self::Context,
working_set: &mut WorkingSet<C>,
) -> Result<sov_modules_api::CallResponse, Error> {
match msg {
call::CallMessage::CreateToken {
salt,
token_name,
initial_balance,
minter_address,
authorized_minters,
} => {
self.charge_gas(working_set, &self.gas.create_token)?;
// Implementation elided...
}
On the example above, we charge the configured unit from the working set. Concretely, we will charge a unit of [4, 19]
from both DefaultContext
and ZkDefaultContext
. The working set will be the responsible to perform a scalar conversion from the dimensions to a single funds value. It will perform an inner product of the loaded price, with the provided unit.
Let's assume we have a working set with the loaded price [3, 2]
. The charged gas of the operation above will be [3] · [4] = 3 × 4 = 12
for a single dimension context, and [3, 2] · [4, 19] = 3 × 4 + 2 × 19 = 50
for both DefaultContext
and ZkDefaultContext
. This approach is intended to unlock Dynamic Pricing.
The aforementioned Bank
struct, with the gas configuration, will look like this:
#[derive(ModuleInfo)]
pub struct Bank<C: sov_modules_api::Context> {
/// The address of the bank module.
#[address]
pub(crate) address: C::Address,
/// The gas configuration of the sov-bank module.
#[gas]
pub(crate) gas: BankGasConfig<C::GasUnit>,
/// A mapping of addresses to tokens in the bank.
#[state]
pub(crate) tokens: sov_state::StateMap<C::Address, Token<C>>,
}
The first interface that modules expose is defined by the public methods from the rollup's impl
. These methods are
accessible to other modules, but cannot be directly invoked by other users. A good example of this is the bank.transfer_from
method:
impl<C: Context> Bank<C> {
pub fn transfer_from(&self, from: &C::Address, to: &C::Address, coins: Coins, working_set: &mut WorkingSet<C>) {
// Implementation elided...
}
}
This function transfers coins from one address to another without a signature check. If it was exposed to users, it would allow for the theft of funds. But it's very useful for modules to be able to initiate funds transfers without access to users' private keys. (Of course, modules should be careful to get the user's consent before transferring funds. By using the transfer_from interface, a module is declaring that it has gotten such consent.)
This leads us to a very important point about the Module System. All modules are trusted. Unlike smart contracts on Ethereum, modules cannot be dynamically deployed by users - they're fixed up-front by the rollup developer. That doesn't mean that the Sovereign SDK doesn't support smart contracts - just that they live one layer higher up the stack. If you want to deploy smart contracts on your rollup, you'll need to incorporate a module which implements a secure virtual machine that users can invoke to store and run smart contracts.
The second interface exposed by modules is the call
function from the Module
trait. The call
function defines the
interface which is accessible to users via on-chain transactions, and it typically takes an enum as its first argument. This argument
tells the call
function which inner method of the module to invoke. So a typical implementation of call
looks something like this:
impl<C: sov_modules_api::Context> sov_modules_api::Module for Bank<C> {
// Several definitions elided here ...
fn call(&self, msg: Self::CallMessage, context: &Self::Context, working_set: &mut WorkingSet<C>) {
match msg {
CallMessage::CreateToken {
token_name,
minter_address,
} => Ok(self.create_token(token_name, minter_address, context, working_set)?),
CallMessage::Transfer { to, coins } => { Ok(self.transfer(to, coins, context, working_set)?) },
CallMessage::Burn { coins } => Ok(self.burn(coins, context, working_set)?),
}
}
}
The third interface that modules expose is an rpc implementation. To generate an RPC implementation, simply annotate your impl
block
with the #[rpc_gen]
macro from sov_modules_api::macros
.
#[rpc_gen(client, server, namespace = "bank")]
impl<C: sov_modules_api::Context> Bank<C> {
#[rpc_method(name = "balanceOf")]
pub(crate) fn balance_of(
&self,
user_address: C::Address,
token_address: C::Address,
working_set: &mut WorkingSet<C>,
) -> RpcResult<BalanceResponse> {
Ok(BalanceResponse {
amount: self.get_balance_of(user_address, token_address, working_set),
})
}
}
This will generate a public trait in the bank crate called BankRpcImpl
, which understands how to serve requests with the following form:
{
"jsonrpc": "2.0",
"id": 2,
"method": "bank_balanceOf",
"params": { "user_address": "SOME_ADDRESS", "token_address": "SOME_ADDRESS" }
}
For an example of how to instantiate the generated trait as a server bound to a specific port, see the demo-rollup package.
Note that only one impl block per module may be annotated with rpc_gen
, but that the block may contain as many rpc_method
annotations as you want.
For an end-to-end walkthrough showing how to implement an RPC server using the Module System, see here
In addition to Module
, there are two traits that are ubiquitous in the modules system - Context
and Spec
. To understand these
two traits it's useful to remember that the high-level workflow of a Sovereign SDK rollup consists of two stages.
First, transactions are executed in native code to generate a "witness". Then, the witness is fed to the zk-circuit,
which re-executes the transactions in a (more expensive) zk environment to create a proof. So, pseudocode for the rollup
workflow looks roughly like this:
use sov_modules_api::DefaultContext;
fn main() {
// First, execute transactions natively to generate a witness for the zkVM
let native_rollup_instance = my_state_transition::<DefaultContext>::new(config);
let witness = Default::default();
native_rollup_instance.begin_slot(witness);
for batch in batches.cloned() {
native_rollup_instance.apply_batch(batch);
}
let (_new_state_root, populated_witness) = native_rollup_instance.end_batch();
// Then, re-execute the state transitions in the zkVM using the witness
let proof = MyZkvm::prove(|| {
let zk_rollup_instance = my_state_transition::<ZkDefaultContext>::new(config);
zk_rollup_instance.begin_slot(populated_witness);
for batch in batches {
zk_rollup_instance.apply(batch);
}
let (new_state_root, _) = zk_rollup_instance.end_batch();
MyZkvm::commit(new_state_root)
});
}
This distinction between native execution and zero-knowledge re-execution is deeply baked into the Module System. We take the philosophy that your business logic should be identical no matter which "mode" you're using, so we abstract the differences between the zk and native modes behind a few traits.
The most important trait we use to enable this abstraction is the Spec
trait. A (simplified) Spec
is defined like this:
pub trait Spec {
type Address;
type Storage;
type PrivateKey;
type PublicKey;
type Hasher;
type Signature;
type Witness;
}
As you can see, a Spec
for a rollup specifies the concrete types that will be used for many kinds of cryptographic operations.
That way, you can define your business logic in terms of abstract cryptography, and then instantiate it with cryptography, which
is efficient in your particular choice of zkVM.
In addition to the Spec
trait, the Module System provides a simple Context
trait which is defined like this:
pub trait Context: Spec + Clone + Debug + PartialEq {
/// Sender of the transaction.
fn sender(&self) -> &Self::Address;
/// Constructor for the Context.
fn new(sender: Self::Address) -> Self;
}
Modules are expected to be generic over the Context
type. If a module is generic over multiple type parameters, then the type bound over Context
is always on the first of those type parameters. The Context
trait gives them a convenient handle to access all of the cryptographic operations
defined by a Spec
, while also making it easy for the Module System to pass in authenticated transaction-specific information which
would not otherwise be available to a module. Currently, a Context
is only required to contain the sender
(signer) of the transaction,
but this trait might be extended in the future.
Putting it all together, recall that the Bank struct is defined like this.
pub struct Bank<C: sov_modules_api::Context> {
/// The address of the bank module.
pub(crate) address: C::Address,
/// A mapping of addresses to tokens in the bank.
pub(crate) tokens: sov_state::StateMap<C::Address, Token<C>>,
}
Notice that the generic type C
is required to implement the sov_modules_api::Context
trait. Thanks to that generic, the Bank struct can
access the Address
field from Spec
- meaning that your bank logic doesn't change if you swap out your underlying address schema.
Similarly, since each of the banks helper functions is automatically generic over a context, it's easy to define logic which
can abstract away the distinctions between zk
and native
execution. For example, when a rollup is running in native mode
its Storage
type will almost certainly be ProverStorage
, which holds its data in a
Merkle tree backed by RocksDB. But if you're running in zk mode the Storage
type will instead be ZkStorage
, which reads
its data from a set of "hints" provided by the prover. Because all the rollups modules are generic, none of them need to worry
about this distinction.
For more information on Context
and Spec
, and to see some example implementations, check out the sov_modules_api
docs.
Like in the bank
module the CallMessage
can be parameterized by C::Context
. To ensure a smooth wallet experience, we need the CallMessage
to implement schemars::JsonSchema
trait. However, simply adding derive(schemars::JsonSchema)
to the CallMessage
definition results in the following error:
the trait JsonSchema is not implemented for C
The reason for this issue is that the standard derive mechanism for JsonSchema
cannot determine the correct trait bounds for the Context
. To resolve this, we need to provide the following hint:
schemars(bound = "C::Address: ::schemars::JsonSchema", rename = "CallMessage")
Now, the schemars::derive
understands that it is sufficient for only C::Address
to implement schemars::JsonSchema
If CallMessage
in your module uses an associated type from Context
you might need to provide a similar hint.