name: evolve-modules description: Write modules for the Evolve SDK using AccountCode trait and account_impl macro. Use when creating modules, writing account code, using AccountState, storage prefixes, or developing new blockchain modules.
Writing Evolve Modules
Modules in Evolve are stateless code executors that implement the AccountCode trait. They interact with blockchain state through the Environment interface and are composed via the #[account_impl] macro.
Full documentation: docs/module-system/
Module Structure
A minimal module using #[derive(AccountState)] for compile-time storage validation:
use evolve_core::account_impl;
#[account_impl(MyModule)]
pub mod account {
use evolve_collections::{item::Item, map::Map};
use evolve_core::{AccountId, Environment, EnvironmentQuery, SdkResult};
use evolve_macros::{exec, init, query};
#[derive(evolve_core::AccountState)]
pub struct MyModule {
#[storage(0)]
pub owner: Item<AccountId>,
#[storage(1)]
pub data: Map<AccountId, u64>,
}
impl MyModule {
#[init]
pub fn initialize(
&self,
owner: AccountId,
env: &mut dyn Environment,
) -> SdkResult<()> {
self.owner.set(&owner, env)?;
Ok(())
}
#[exec]
pub fn set_data(
&self,
key: AccountId,
value: u64,
env: &mut dyn Environment,
) -> SdkResult<()> {
self.data.set(&key, &value, env)?;
Ok(())
}
#[query]
pub fn get_data(
&self,
key: AccountId,
env: &mut dyn EnvironmentQuery,
) -> SdkResult<Option<u64>> {
self.data.may_get(&key, env)
}
}
}
The #[derive(AccountState)] macro:
- Validates unique storage prefixes at compile time
- Generates
new()andDefaultimplementations automatically - Prevents storage collisions that cause state corruption
Function Markers
| Marker | Environment | Purpose | Generated Message |
|---|---|---|---|
#[init] |
&mut dyn Environment |
One-time initialization | InitializeMsg |
#[exec] |
&mut dyn Environment |
State mutations | <FnName>Msg |
#[query] |
&mut dyn EnvironmentQuery |
Read-only | <FnName>Msg |
#[payable] |
Add to #[exec] |
Accept fungible assets | - |
Storage Collections
Use #[storage(n)] to assign unique prefix bytes (validated at compile time):
use evolve_collections::{item::Item, map::Map};
#[derive(evolve_core::AccountState)]
pub struct MyModule {
#[storage(0)]
pub config: Item<Config>, // single value
#[storage(1)]
pub balances: Map<AccountId, u128>, // key-value
}
Multi-Prefix Collections
Vector and UnorderedMap require multiple prefixes. Use manual initialization:
pub struct ComplexModule {
history: Vector<Event>, // needs 2 prefixes
validators: UnorderedMap<AccountId, Validator>, // needs 4 prefixes
}
impl ComplexModule {
pub const fn new() -> Self {
Self {
history: Vector::new(0, 1),
validators: UnorderedMap::new(2, 3, 4, 5),
}
}
}
Stateless Helper Fields
Use #[skip_storage] for fields that don't need storage prefixes:
#[derive(evolve_core::AccountState)]
pub struct MyModule {
#[storage(0)]
pub data: Item<Data>,
#[skip_storage]
pub helper: MyHelper, // Initialized with Type::new()
}
Storage Operations
// Item<T>
item.set(&value, env)?; // write
item.get(env)?; // read (panics if missing)
item.may_get(env)?; // read -> Option<T>
item.update(|v| Ok(v + 1), env)?; // atomic update
// Map<K, V>
map.set(&key, &value, env)?;
map.get(&key, env)?; // panics if missing
map.may_get(&key, env)?; // -> Option<V>
map.remove(&key, env)?;
map.update(&key, |v| Ok(v.unwrap_or(0) + 1), env)?;
// Vector<T>
vector.push(&value, env)?;
vector.get(index, env)?;
vector.len(env)?;
vector.pop(env)?;
Emitting Events
Use env.emit_event() to emit events from module functions:
use evolve_core::events_api::Event;
#[exec]
pub fn transfer(
&self,
to: AccountId,
amount: u128,
env: &mut dyn Environment,
) -> SdkResult<()> {
// ... transfer logic ...
// Emit event with name and borsh-serializable data
env.emit_event("transfer", &TransferEvent {
from: env.sender(),
to,
amount,
})?;
Ok(())
}
Events are collected during execution and included in the transaction result.
Error Handling
Define custom errors with define_error!:
use evolve_core::{define_error, ERR_UNAUTHORIZED};
define_error!(ERR_INSUFFICIENT_BALANCE, 0x1, "insufficient balance");
define_error!(ERR_ALREADY_EXISTS, 0x2, "already exists");
#[exec]
pub fn withdraw(&self, amount: u128, env: &mut dyn Environment) -> SdkResult<()> {
let balance = self.balances.may_get(&env.sender(), env)?.unwrap_or(0);
if balance < amount {
return Err(ERR_INSUFFICIENT_BALANCE);
}
// ...
}
Authorization Patterns
Check sender for privileged operations:
#[exec]
pub fn admin_action(&self, env: &mut dyn Environment) -> SdkResult<()> {
if env.sender() != self.owner.get(env)? {
return Err(ERR_UNAUTHORIZED);
}
// privileged logic
Ok(())
}
For system-only operations:
use evolve_core::RUNTIME_ACCOUNT_ID;
#[exec]
pub fn system_only(&self, env: &mut dyn Environment) -> SdkResult<()> {
if env.sender() != RUNTIME_ACCOUNT_ID {
return Err(ERR_UNAUTHORIZED);
}
Ok(())
}
Internal vs External Functions
Separate authorization from logic:
// Internal: no auth checks
pub fn mint_unchecked(
&self,
recipient: AccountId,
amount: u128,
env: &mut dyn Environment,
) -> SdkResult<()> {
self.balances.update(&recipient, |b| Ok(b.unwrap_or(0) + amount), env)?;
self.total_supply.update(|s| Ok(s.unwrap_or(0) + amount), env)?;
Ok(())
}
// External: with auth
#[exec]
pub fn mint(
&self,
recipient: AccountId,
amount: u128,
env: &mut dyn Environment,
) -> SdkResult<()> {
if self.supply_manager.get(env)? != Some(env.sender()) {
return Err(ERR_UNAUTHORIZED);
}
self.mint_unchecked(recipient, amount, env)
}
Cross-Module Calls
The macro generates a Ref wrapper for type-safe calls:
use evolve_token::account::TokenRef;
#[exec]
pub fn do_transfer(&self, env: &mut dyn Environment) -> SdkResult<()> {
let token = TokenRef::from(self.token_id.get(env)?);
token.transfer(recipient, amount, env)?;
Ok(())
}
Or use raw InvokeRequest for flexibility:
use evolve_core::InvokeRequest;
use evolve_fungible_asset::TransferMsg;
let request = InvokeRequest::new(&TransferMsg { to: recipient, amount })?;
env.do_exec(token_id, &request, vec![])?;
Implementing Interfaces
Implement standard interfaces for composability:
use evolve_fungible_asset::{FungibleAssetInterface, FungibleAssetMetadata};
impl FungibleAssetInterface for Token {
#[exec]
fn transfer(&self, to: AccountId, amount: u128, env: &mut dyn Environment) -> SdkResult<()> {
// implementation
}
#[query]
fn get_balance(&self, account: AccountId, env: &mut dyn EnvironmentQuery) -> SdkResult<Option<u128>> {
self.balances.may_get(&account, env)
}
#[query]
fn metadata(&self, env: &mut dyn EnvironmentQuery) -> SdkResult<FungibleAssetMetadata> {
self.metadata.get(env)
}
#[query]
fn total_supply(&self, env: &mut dyn EnvironmentQuery) -> SdkResult<u128> {
self.total_supply.get(env)
}
}
Testing Modules
Unit Tests with MockEnv
#[cfg(test)]
mod tests {
use super::account::MyModule;
use evolve_core::AccountId;
use evolve_testing::MockEnv;
#[test]
fn test_basic_flow() {
let contract = AccountId::new(1);
let sender = AccountId::new(2);
let mut env = MockEnv::new(contract, sender);
let module = MyModule::default();
module.initialize(sender, &mut env).unwrap();
module.set_data(AccountId::new(3), 42, &mut env).unwrap();
let value = module.get_data(AccountId::new(3), &mut env).unwrap();
assert_eq!(value, Some(42));
}
#[test]
fn test_unauthorized() {
let contract = AccountId::new(1);
let owner = AccountId::new(2);
let mut env = MockEnv::new(contract, owner);
let module = MyModule::default();
module.initialize(owner, &mut env).unwrap();
// Change sender to non-owner
env = env.with_sender(AccountId::new(999));
let result = module.admin_action(&mut env);
assert!(result.is_err());
}
}
Integration Tests with TestApp
use testapp::{TestApp, GenesisAccounts};
#[test]
fn test_with_full_stf() {
let mut app = TestApp::new();
let accounts = app.accounts();
app.system_exec_as(accounts.alice, |env| {
// Interact with modules through refs
let token = TokenRef::from(accounts.atom);
token.transfer(accounts.bob, 100, env)
}).unwrap();
app.next_block();
}
Determinism Requirements
All module code must be deterministic. These patterns are banned:
| Pattern | Why | Alternative |
|---|---|---|
HashMap, HashSet |
Non-deterministic iteration | BTreeMap or evolve_collections |
std::time |
Varies between nodes | BlockInfo module |
rand |
Non-deterministic | Derive from chain state |
f32, f64 |
Platform-dependent | evolve_math::FixedPoint |
The workspace has clippy lints configured (.clippy.toml) to warn on these.
See docs/module-system/determinism.md for full details.
Checklist for New Modules
- Use
#[derive(AccountState)]- Compile-time storage prefix validation - Unique
#[storage(n)]values - Each field gets unique byte prefix - Authorization on exec functions - Check
env.sender()appropriately - Use checked arithmetic -
checked_add,checked_subto prevent overflow - Handle missing values - Use
may_get()?.ok_or(ERR_...)?notunwrap() - No non-deterministic code - No HashMap, std::time, rand, floats
- Write unit tests - Test with MockEnv for fast iteration
- Write integration tests - Test with TestApp for full STF coverage
Files
crates/app/sdk/macros/src/lib.rs-#[account_impl]and#[derive(AccountState)]macroscrates/app/sdk/core/src/lib.rs- Core traits (AccountCode,Environment)crates/app/sdk/collections/src/- Storage collectionscrates/app/sdk/x/token/src/lib.rs- Reference implementation (Token)crates/app/sdk/x/scheduler/src/lib.rs- Block scheduling hooks (Scheduler)docs/module-system/- Full documentation (architecture, storage, errors, testing, determinism)