Skip to content

Commit

Permalink
Tutorial: Writing STF without module-system (Sovereign-Labs#324)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkolad authored May 24, 2023
1 parent 3d39c0e commit d5cd26c
Show file tree
Hide file tree
Showing 10 changed files with 402 additions and 49 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"adapters/risc0",
"adapters/celestia",
"examples/demo-stf",
"examples/demo-simple-stf",
"examples/demo-rollup",
"examples/demo-nft-module",
"full-node/db/schemadb",
Expand Down
17 changes: 17 additions & 0 deletions examples/demo-simple-stf/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "demo-simple-stf"
version = "0.1.0"
edition = "2021"
resolver = "2"

[dependencies]
anyhow = { workspace = true}
serde = { workspace = true }
serde_json = { workspace = true, optional = true }
sha2 = { workspace = true }

sov-rollup-interface = { path = "../../rollup-interface" }


[dev-dependencies]
sov-rollup-interface = { path = "../../rollup-interface", features = ["mocks"] }
167 changes: 167 additions & 0 deletions examples/demo-simple-stf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Rollup from scratch.
Many rollups have concepts like `Account` or `Token` and access the state in a similar manner. This is where the [sov-modules-api](../../module-system/sov-modules-api/README.md) becomes useful. It offers a standardized approach to writing business rollup logic. However, there are cases where your rollup requirements may be so unique, that the `module-system` could become a hindrance. In this tutorial, we will bypass the `module-system` and directly create a simple rollup by implementing a `StateTransitionFunction` "from scratch".

In our rollup, we will verify whether the sender of a data blob possesses the preimage for a specific hash digest. It's important to note that our rollup is designed to be "stateless," meaning that implementing state access is not covered in this tutorial. However, if you're interested, you can refer to the [sov-state](../../module-system/sov-state/README.md) for an example of how it can be done.

## Implementing state transition function.
The [State Transition Function
interface](../../rollup-interface/specs/interfaces/stf.md) serves as the core component of our rollup, where the business logic will reside.
Implementations of this trait can be inegrated with any ZKVM and DA Layer resulting in a fully functional rollup. To begin, we will create a structure called `CheckHashPreimageStf` and implement the `StateTransitionFunction` trait for it. You can find the complete code in the `lib.rs` file, we will go over the most important parts here:


```rust
pub struct CheckHashPreimageStf {}
```

The `ApplyBlobResult` represents the outcome of the state transition, and its specific usage will be explained later:

```rust

#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub enum ApplyBlobResult {
Failure,
Success,
}
```

Now let's discuss the implementation. Firstly, we define some types that are relevant to our rollup:

```rust
// Since our rollup is stateless, we don't need to consider the StateRoot.
type StateRoot = ();

// This represents the initial configuration of the rollup, but it is not supported in this tutorial.
type InitialState = ();

// We could incorporate the concept of a transaction into the rollup, but we leave it as an exercise for the reader.
type TxReceiptContents = ();

// This is the type that will be returned as a result of `apply_blob`.
type BatchReceiptContents = ApplyBlobResult;

// This data is produced during actual batch execution or validated with proof during verification. However, in this tutorial, we won't use it.
type Witness = ();

// This represents a proof of misbehavior by the sequencer, but we won't utilize it in this tutorial.
type MisbehaviorProof = ();
```

Now that we have defined the necessary types, we need to implement the following functions:

```rust
// Perform one-time initialization for the genesis block.
fn init_chain(&mut self, _params: Self::InitialState) {
// Do nothing
}

// Called at the beginning of each DA-layer block - whether or not that block contains any
// data relevant to the rollup.
fn begin_slot(&mut self, _witness: Self::Witness) {
// Do nothing
}
```

These functions handle the initialization and preparation stages of our rollup, but as we are not modifying the rollup state, their implementation is simply left empty.

Next we need to write the core logic in `apply_blob`:
```rust
// The core logic of our rollup.
fn apply_blob(
&mut self,
blob: impl BlobTransactionTrait,
_misbehavior_hint: Option<Self::MisbehaviorProof>,
) -> BatchReceipt<Self::BatchReceiptContents, Self::TxReceiptContents> {
let blob_data = blob.data();
let mut reader = blob_data.reader();

// Read the data from the blob as a byte vec.
let mut data = Vec::new();

// Panicking within the `StateTransitionFunction` is generally not recommended.
// But here if we encounter an error while reading the bytes, it suggests a serious issue with the DA layer or our setup.
reader
.read_to_end(&mut data)
.unwrap_or_else(|e| panic!("Unable to read blob data {}", e));

// Check if the sender submitted the preimage of the hash.
let hash = sha2::Sha256::hash(&data);
let desired_hash = [
102, 104, 122, 173, 248, 98, 189, 119, 108, 143, 193, 139, 142, 159, 142, 32, 8, 151,
20, 133, 110, 226, 51, 179, 144, 42, 89, 29, 13, 95, 41, 37,
];

let result = if hash == desired_hash {
ApplyBlobResult::Success
} else {
ApplyBlobResult::Failure
};

// Return the `BatchReceipt`
BatchReceipt {
batch_hash: hash,
tx_receipts: vec![],
inner: result,
}
}
```
The above function reads the data from the blob, computes the `hash`, compares it with the `desired_hash`, and returns a `BatchReceipt` indicating whether the preimage was successfully submitted or not.

The last method is `end_slot`, like before the implementation is trivial:

```rust
fn end_slot(
&mut self,
) -> (
Self::StateRoot,
Self::Witness,
Vec<ConsensusSetUpdate<OpaqueAddress>>,
) {
((), (), vec![])
}
```
### Exercise:
In the current implementation, every blob contains the data we pass to the hash function.
As an exercise, you can introduce the concept of transactions. In this scenario,
the blob would contain multiple transactions (containing data) that we can loop over to check hash equality.
The first transaction that finds the correct hash would break the loop and return early.

## Testing.
The `sov_rollup_interface::mocks` crate provides two utilities that are useful for testing:

1. The `MockZkvm` is an implementation of the `Zkvm` trait that can be used in tests.
1. The `TestBlob` is an implementation of the `BlobTransactionTrait` trait that can be used in tests. It accepts an `Address` as a generic parameter. For testing purposes, we implement our own Address type as follows:

```rust
#[derive(PartialEq, Debug, Clone, Eq, serde::Serialize, serde::Deserialize)]
pub struct DaAddress {
pub addr: [u8; 32],
}

impl AddressTrait for DaAddress {}

```
You can find more details in the `stf_test.rs` file.


The following test checks the rollup logic. In the test, we call `init_chain, begin_slot, and end_slot` for completeness, even though these methods do nothing.


```rust
#[test]
fn test_stf() {
let address = DaAddress { addr: [1; 32] };
let preimage = vec![0; 32];

let test_blob = TestBlob::<DaAddress>::new(preimage, address);
let stf = &mut CheckHashPreimageStf {};

StateTransitionFunction::<MockZkvm>::init_chain(stf, ());
StateTransitionFunction::<MockZkvm>::begin_slot(stf, ());

let receipt = StateTransitionFunction::<MockZkvm>::apply_blob(stf, test_blob, None);
assert_eq!(receipt.inner, ApplyBlobResult::Success);

StateTransitionFunction::<MockZkvm>::end_slot(stf);
}
```

99 changes: 99 additions & 0 deletions examples/demo-simple-stf/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use sov_rollup_interface::{
da::BlobTransactionTrait,
jmt::SimpleHasher,
stf::{BatchReceipt, ConsensusSetUpdate, OpaqueAddress, StateTransitionFunction},
zk::traits::Zkvm,
Buf,
};
use std::io::Read;

#[derive(PartialEq, Debug, Clone, Eq, serde::Serialize, serde::Deserialize)]

pub struct CheckHashPreimageStf {}

#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum ApplyBlobResult {
Failure,
Success,
}

impl<VM: Zkvm> StateTransitionFunction<VM> for CheckHashPreimageStf {
// Since our rollup is stateless, we don't need to consider the StateRoot.
type StateRoot = ();

// This represents the initial configuration of the rollup, but it is not supported in this tutorial.
type InitialState = ();

// We could incorporate the concept of a transaction into the rollup, but we leave it as an exercise for the reader.
type TxReceiptContents = ();

// This is the type that will be returned as a result of `apply_blob`.
type BatchReceiptContents = ApplyBlobResult;

// This data is produced during actual batch execution or validated with proof during verification.
// However, in this tutorial, we won't use it.
type Witness = ();

// This represents a proof of misbehavior by the sequencer, but we won't utilize it in this tutorial.
type MisbehaviorProof = ();

// Perform one-time initialization for the genesis block.
fn init_chain(&mut self, _params: Self::InitialState) {
// Do nothing
}

// Called at the beginning of each DA-layer block - whether or not that block contains any
// data relevant to the rollup.
fn begin_slot(&mut self, _witness: Self::Witness) {
// Do nothing
}

// The core logic of our rollup.
fn apply_blob(
&mut self,
blob: impl BlobTransactionTrait,
_misbehavior_hint: Option<Self::MisbehaviorProof>,
) -> BatchReceipt<Self::BatchReceiptContents, Self::TxReceiptContents> {
let blob_data = blob.data();
let mut reader = blob_data.reader();

// Read the data from the blob as a byte vec.
let mut data = Vec::new();

// Panicking within the `StateTransitionFunction` is generally not recommended.
// But here if we encounter an error while reading the bytes, it suggests a serious issue with the DA layer or our setup.
reader
.read_to_end(&mut data)
.unwrap_or_else(|e| panic!("Unable to read blob data {}", e));

// Check if the sender submitted the preimage of the hash.
let hash = sha2::Sha256::hash(&data);
let desired_hash = [
102, 104, 122, 173, 248, 98, 189, 119, 108, 143, 193, 139, 142, 159, 142, 32, 8, 151,
20, 133, 110, 226, 51, 179, 144, 42, 89, 29, 13, 95, 41, 37,
];

let result = if hash == desired_hash {
ApplyBlobResult::Success
} else {
ApplyBlobResult::Failure
};

// Return the `BatchReceipt`
BatchReceipt {
batch_hash: hash,
tx_receipts: vec![],
inner: result,
}
}

fn end_slot(
&mut self,
) -> (
Self::StateRoot,
Self::Witness,
Vec<ConsensusSetUpdate<OpaqueAddress>>,
) {
((), (), vec![])
}
}
63 changes: 63 additions & 0 deletions examples/demo-simple-stf/tests/stf_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use demo_simple_stf::{ApplyBlobResult, CheckHashPreimageStf};
use sov_rollup_interface::{
mocks::{MockZkvm, TestBlob},
stf::StateTransitionFunction,
};

use sov_rollup_interface::traits::AddressTrait;
use std::fmt::Display;

#[derive(PartialEq, Debug, Clone, Eq, serde::Serialize, serde::Deserialize)]
pub struct DaAddress {
pub addr: [u8; 32],
}

impl AddressTrait for DaAddress {}

impl AsRef<[u8]> for DaAddress {
fn as_ref(&self) -> &[u8] {
&self.addr
}
}

impl From<[u8; 32]> for DaAddress {
fn from(addr: [u8; 32]) -> Self {
DaAddress { addr }
}
}

impl<'a> TryFrom<&'a [u8]> for DaAddress {
type Error = anyhow::Error;

fn try_from(addr: &'a [u8]) -> Result<Self, Self::Error> {
if addr.len() != 32 {
anyhow::bail!("Address must be 32 bytes long");
}
let mut addr_bytes = [0u8; 32];
addr_bytes.copy_from_slice(addr);
Ok(Self { addr: addr_bytes })
}
}

impl Display for DaAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.addr)
}
}

#[test]
fn test_stf() {
let address = DaAddress { addr: [1; 32] };
let preimage = vec![0; 32];

let test_blob = TestBlob::<DaAddress>::new(preimage, address);
let stf = &mut CheckHashPreimageStf {};

StateTransitionFunction::<MockZkvm>::init_chain(stf, ());
StateTransitionFunction::<MockZkvm>::begin_slot(stf, ());

let receipt = StateTransitionFunction::<MockZkvm>::apply_blob(stf, test_blob, None);
assert_eq!(receipt.inner, ApplyBlobResult::Success);

StateTransitionFunction::<MockZkvm>::end_slot(stf);
}
4 changes: 2 additions & 2 deletions examples/demo-stf/src/bank_cmd/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ mod test {
use bank::query::QueryMessage;
use demo_stf::{
app::{create_demo_config, create_new_demo, DemoApp, LOCKED_AMOUNT, SEQUENCER_DA_ADDRESS},
helpers::{query_and_deserialize, TestBlob},
helpers::{new_test_blob, query_and_deserialize},
};
use sov_app_template::{Batch, RawTx, SequencerOutcome};
use sov_modules_api::Address;
Expand Down Expand Up @@ -277,7 +277,7 @@ mod test {

let apply_blob_outcome = StateTransitionFunction::<MockZkvm>::apply_blob(
demo,
TestBlob::new(Batch { txs }, &SEQUENCER_DA_ADDRESS),
new_test_blob(Batch { txs }, &SEQUENCER_DA_ADDRESS),
None,
)
.inner;
Expand Down
Loading

0 comments on commit d5cd26c

Please sign in to comment.