Introduction

Composability Labs
Sprkfi

--

This article presents a modular architecture for writing smart contracts which enables developers to upgrade their contracts while also protecting users from upgrades the developers introduce.

The idea presented here is not entirely novel and it can be seen as an extension of the EIP-2535 Diamond Standard.

This architecture is not the end game of what can be achieved however it acts as an introduction to the first stage. Subsequent stages require complexity beyond the scope of this presentation.

Architecture

The architecture consist of 4 components

  • Core Contract which is the API to its state
  • Modules which are logical components that perform work and settle their computation in the Core Contract if permitted
  • Access Control List (ACL) which determines the interactions between contracts
  • Registry which keeps track of user ACLs

Core Contract

A smart contract usually contains all of its logic within itself and thus updating a contract means redeploying the entire source code.

To minimise the need for redeploying the entire contract the Core Contract acts as a minimal interface to its storage meaning that most of the code is abstracted away into modules.

In practice, this means the contract exists to validate the caller (module) and upon validation updates its state with the arguments from the calling module.

Contract Extensions

Extensions are additional functions that may be implemented directly on the Core Contract but are not required for minimal functionality.

For example, a contract may not expose functionality to check the internal balance (ex. rebasing assets) of a user however this may lead to a poor user experience since the user may have no means of querying their balance for a subsequent action.

Modules

A module is a minimal contract that performs computation that would be carried out within the Core Contract.

Moving the functionality from Core into a module enables minimal changes to be implemented to the system without redeploying the entire contract.

Modules must make a call back to the Core Contract to settle. These calls can be specific to an implementation through the use of an ABI or generic by using the low-level call provided by the Sway standard library.

The low level call is more complex to implement however it enables the module to work with any number of compatible contracts which also unlocks the possibility of a module marketplace.

Access Control List (ACL)

The ACL is a contract that tracks which contracts can perform specific actions, namely, perform computation and settle in the Core Contract.

Developers

From the developer’s perspective, the ACL is used by the Core Contract to check if a module is permitted to call its function.

Users

From the user’s perspective, the ACL acts as a safety check to prevent a developer from switching a module when the user may not consent to that change.

It defines which modules the user allows to perform computation on their behalf within their workflow.

Registry

The registry is a contract that contains a mapping between each user and their ACL to query the modules.

Example System

The example contains pseudo-code which uses each component within the system. For more information check the repository.

Walk-through

The following steps broadly describe the workflow presented in the image above.

  • A user interacts with a DApp to perform an action, ex. deposit into a vault
  • The DApp queries the Registry to find the user’s ACL in order to query for available actions
  • The DApp queries the ACL and discovers the preferences the user has set.
  • The DApp calls a module to perform computation on behalf of the user.
  • The module runs its code and proceeds to call the Core Contract to settle its results.
  • The Core Contract checks its ACL to validate whether the module is authorised to settle.
  • Upon validation the Core Contract settles by updating its state with the provided arguments by the module.
  • Optionally, the Core Contract may send a message upstream to the module to continue the workflow.

Code

The following example is a simple vault.

Core

The contract keeps track of its ACL in order to perform validation of the calling modules and the balance of each asset from each user.

The deposit() function starts by validating the caller in the firewall() function. It contains assertions and calls to the ACL to ensure that the module is permitted to change its state.

Upon validation the balance is updated with the data provided by the module and a log is emitted.

contract;
configurable {
ACL: ContractId::from(/* some ID */)
}
storage {
// Map(user => Map(asset => amount))
balances: StorageMap<Identity, StorageMap<AssetId, u64>> = StorageMap {},
}
impl Vault for Contract {
#[payable]
#[storage(read, write)]
fn deposit(user: Identity) {
// Call the ACL contract and assert module is authorized to call this fn
firewall(String::from_ascii_str("deposit(Identity)"), ACL);
// Module forwards asset from the user
let balance = storage.balances.get(user).get(msg_asset_id()).try_read().unwrap_or(0);
storage
.balances
.get(user)
.insert(msg_asset_id(), balance + msg_amount());
log(DepositEvent {
user,
asset: msg_asset_id(),
amount: msg_amount(),
})
}
}

User Extension

The extension is an information provider therefore it has not been restricted to module only calls.

impl User for Contract {
#[storage(read)]
fn balance(user: Identity, asset: AssetId) -> u64 {
storage.balances.get(user).get(asset).try_read().unwrap_or(0)
}
}

Deposit Module

This module contains code that would be within the Core Contract.

It is restricted to a single contract through the implementation of the ABI over the low level call and after it finishes its computation it calls the target contract to settle.

In this example the additional complexity is omitted as that is up to the reader to implement to their requirements.

There may be multiple versions of the module for different benefits ex.

  • Module 1: 1% tax for usage
  • Module 2: 2% tax with 1% going to a treasury and the user gains additional benefits in return

As long as both modules are permitted to use the Core Contract then the user has the choice between modules.

A warning ought to be mentioned. When there are a lot of modules interacting with your settlement there may come a time when complexity arises from mixing different modules. This may lead to unintended consequences which may not have been foreseen when looking at each module individually.

contract;
use vault::Vault;impl Deposit for Contract {
#[payable]
fn deposit(vault: ContractId) {
let target = abi(Vault, vault.into());
// insert additional module logic before calling the target to deposit

target.deposit {
asset_id: msg_asset_id().into(),
coins: msg_amount(),
}(msg_sender().unwrap())
}
}

Access Control List (Developer)

The ACL for the developer contains code to associate a module with a specific function on the Core Contract. This functionality may also be revoked.

Each ACL ought to be deployed for each new Core Contract. If there is ever a time when this contract is compromised it will limit the scope to a single application rather than all applications.

In this example we store the hash of the function signature rather than the function selector. The reader is free to make an adjustment here and in the Core Contract in order to enable low level calls.

configurable {
OWNER: Address = Address::from(ZERO_B256)
}
storage {
// Map(fn => Map(module, bool))
ACL: StorageMap<b256, StorageMap<ContractId, bool>> = StorageMap {}
}
impl VaultACL for Contract {
#[storage(read, write)]
fn add(function: b256, module: ContractId) {
require(
msg_sender().unwrap().as_address().unwrap() == OWNER,
AuthError::Unauthorized
);
storage.ACL.get(function).insert(module, true);
log(AddModuleEvent { function, module })
}
#[storage(read, write)]
fn remove(function: b256, module: ContractId) {
require(
msg_sender().unwrap().as_address().unwrap() == OWNER,
AuthError::Unauthorized
);
storage.ACL.get(function).insert(module, false);
log(RemoveModuleEvent { function, module })
}
}
impl Info for Contract {
#[storage(read)]
fn authorized(function: b256, module: ContractId) -> bool {
storage.ACL.get(function).get(module).try_read().unwrap_or(false)
}
}

Access Control List (User)

Similar to the developer ACL the user has a contract deployed for each application to reduce the risk upon a contract being compromised or access being lost.

configurable {
OWNER: Address = Address::from(ZERO_B256),
}
storage {
// Map(module => bool)
ACL: StorageMap<ContractId, bool> = StorageMap {},
}
impl UserACL for Contract {
#[storage(read, write)]
fn add(module: ContractId) {
require(
msg_sender().unwrap().as_address().unwrap() == OWNER,
AuthError::Unauthorized,
);
storage.ACL.insert(module, true);
log(AddModuleEvent { module })
}
#[storage(read, write)]
fn remove(module: ContractId) {
require(
msg_sender().unwrap().as_address().unwrap() == OWNER,
AuthError::Unauthorized,
);
storage.ACL.insert(module, false);
log(RemoveModuleEvent { module })
}
}
impl Info for Contract {
#[storage(read)]
fn authorized(module: ContractId) -> bool {
storage.ACL.get(module).try_read().unwrap_or(false)
}
}

Registry

The registry keeps track of users and their ACL.

It follows the same simple API for storing its data.

contract;
configurable {
OWNER: Address = Address::from(ZERO_B256),
}
storage {
// Map(user => acl)
registry: StorageMap<Address, Option<ContractId>> = StorageMap {},
}
impl Registry for Contract {
#[storage(read, write)]
fn add(acl: ContractId, user: Address) {
require(
msg_sender().unwrap().as_address().unwrap() == OWNER,
AuthError::Unauthorized,
);
storage.registry.insert(user, Some(acl));
log(AddACLEvent { acl, user })
}
#[storage(read, write)]
fn remove(user: Address) {
require(
msg_sender().unwrap().as_address().unwrap() == OWNER,
AuthError::Unauthorized,
);
let acl = storage.registry.get(user).read();
require(acl.is_some(), RemoveError::MissingACL);
storage.registry.insert(user, None); log(RemoveACLEvent { acl: acl.unwrap(), user })
}
}
impl Info for Contract {
#[storage(read)]
fn acl(user: Address) -> Option<ContractId> {
storage.registry.get(user).try_read().unwrap_or(None)
}
}

Summary

The modular architecture splits contracts into smaller components which makes it easier to maintain and implement new features while minimising the attack surface.

The modules may be open to use for the network reducing the need for everyone to redeploy the same code.

Users may configure custom workflow systems through the use of various modules which enables them to closer satisfy their preferences.

There is a trade-off between modularity and execution speed through Fuel’s parallelism. When a module changes the state of the core contract that code must be executed sequentially because of the state access list. If a contract cannot be reasonably designed to be modular and execution speed is paramount then the developer may opt to deploying multiple copies of the same monolithic contract with different configurable arguments in order to gain maximum execution speed.

If upgradability is a priority and parallelism may not be as important, or cannot be reasonably designed into the system, then the modular approach may be taken.

--

--

Composability Labs
Sprkfi
Editor for

Building on Sway, contributing to Sway Community growth