Modules

Modules hold all logic that is changing the state of the blockchain; or in other words all logic that makes changes on the blockchain.

Modules can be registered to a blockchain application to extend the on-chain logic.

When to create a module

Modules enable to…​

  • Define how data is stored on the blockchain.

  • Define logic that is executed per block.

  • Define logic that is executed per transaction.

For a more practical guide how to create a new module, check out the guide Creating a module.

If you wish to view an example of a fully implemented module, check out the following examples:

Adding a module to the application

Modules need to be registered to become available in the application. If the application was bootstrapped with Lisk Commander, they are registered in the file src/app/modules.ts.

Registering a new module requires the generation of a new genesis block if the module defines an Account schema. This always results in a hardfork of the blockchain of the application.

Check out the Generating a genesis block guide for more information on how to generate a new genesis block for your application.

Example: How to register a module with the application in modules.ts
import { Application } from 'lisk-sdk';
import { SomeModule } from "some-module"; (1)

export const registerModules = (app: Application): void => {
    app.registerModule(SomeModule); (2)
};
1 Import the module from an NPM package or from a local path.
2 Add this line to register the module to the application.

Module anatomy

All important parts of a module are explained in more detail and are shown in the diagram below.

Anatomy of a module

module asset

The module class

Each module is constructed as a class which extends from the BaseModule.

The base module provides an interface which needs to be completed by implementing the described components of a module as listed below.

const { BaseModule } = require('lisk-sdk');

class HelloModule extends BaseModule {

}

Module ID

The module ID is the unique identifier for a module in the application.

The module IDs 0-999 are reserved for official modules for the Lisk SDK. This means that the minimum ID for a new module is 1000. The maximum value for a module ID is 2^32 - 1(equals 4,294,967,295), because it is stored as a uint32 value.

It is also important to note, that module IDs do not need to be in succession, the only requirement is that they are unique within the blockchain application. So as an example, it is valid to register multiple modules to the application which have the following module IDs: 1003, 1000, 2500001 as they are in the allowed number range, and each ID is different.

Example: ID of the Hello module from the Hello World app
id = 1000;

Module name

The module name is the human readable unique identifier for the module.

It is used as a prefix in the alias of events and actions, and as a key label to to add the properties of the Account schema to the user accounts [1].

Example: Name of the Hello module from the Hello World app
name = 'hello';

Logger

The logger is accessible inside of a module under this._logger. As the name suggests, the logger creates log messages for the module for the different log levels:

  • trace

  • debug

  • info

  • warn

  • error

  • fatal

this._logger.debug(nextRound, 'Updating delegate list for');

The logger expects 2 arguments:

  1. Data of the log message (object).

  2. Message of the log message (string).

Genesis config

The genesis configuration is accessible in a module under the variable this.config.

console.log(this.config.blockTime);
// 10

Interfaces

Modules can expose interfaces (Actions, Events), which allow other components of the application to interact with the module.

Actions and Events are exposed to Plugins and to external services.

View the "Interfaces" section of the Communication page to see an overview about the different interfaces and their accessibility in modules, plugins, and external services.

dataAccess

Use the property this._dataAccess to access data from the blockchain in the module.

Updating and changing of data on the blockchain is only allowed inside of Assets and Lifecycle Hooks via The state store.
const res = await this._dataAccess.getChainState('hello:helloCounter');

The data is encoded in the database, therefore it needs to be decoded after receiving it with this._dataAccess.

For more information about this topic, check out the Schemas page.

The following functions are available via this._dataAccess:

export interface BaseModuleDataAccess {
	getChainState(key: string): Promise<Buffer | undefined>;
	getAccountByAddress<T>(address: Buffer): Promise<Account<T>>;
	getLastBlockHeader(): Promise<BlockHeader>;
}

Actions

Actions are functions which can be invoked via Remote-Procedure-Calls (RPC) by plugins and external services, to request data from the module.

Example: Actions of the Hello module from the Hello World app
actions = {
    amountOfHellos: async () => {
        const res = await this._dataAccess.getChainState(CHAIN_STATE_HELLO_COUNTER);
        const count = codec.decode(
            helloCounterSchema,
            res
        );
        return count;
    },
};

Events

Events are published by the module on relevant occasions. Plugins and external services can subscribe to these events and as a result, they will be notified immediately, every time a new event is published.

Example: Events of the Hello module from the Hello World app
events = ['newHello'];

State changes & execution logic

The parts which contain the logic to perform state mutation on the blockchain are possibly the most important part of the module, as they define the underlying business logic and general behavior of a module.

It is possible to change the state of the blockchain in the Reducers, Lifecycle Hooks or Assets of a module.

All of the logic implemented in a module / asset must be “deterministic” and executable within the block time.

The state store

The stateStore is used to mutate the state of the blockchain data, or to retrieve data from the blockchain.

Inside of a module, the stateStore is available for Reducers, Assets and all Lifecycle Hooks.

Interface of stateStore
interface StateStore {
	readonly account: {
		get<T = AccountDefaultProps>(address: Buffer): Promise<Account<T>>;
		getOrDefault<T = AccountDefaultProps>(address: Buffer): Promise<Account<T>>;
		set<T = AccountDefaultProps>(address: Buffer, updatedElement: Account<T>): Promise<void>;
		del(address: Buffer): Promise<void>;
	};
	readonly chain: {
		lastBlockHeaders: ReadonlyArray<BlockHeader>;
		lastBlockReward: bigint;
		networkIdentifier: Buffer;
		get(key: string): Promise<Buffer | undefined>;
		set(key: string, value: Buffer): Promise<void>;
	};
}

The reducerHandler

Reducers of modules can be invoked inside of the Lifecycle Hooks and Assets of other modules via the reducerHandler.

Example: Invoking the "debit" reducer of the Token module
// debit tokens from sender account
await reducerHandler.invoke("token:debit", {
  address: senderAddress,
  amount: asset.initValue,
});

Assets

Assets are responsible for executing logic that introduces state changes on the blockchain, based on input parameters which are provided by the users as transactions.

A blockchain application can accept many different kinds of transactions, depending on its use case. Every transaction type is handled by a specific asset of a module in the application. The default application already supports the following transactions:

To add support for a new transaction to the application, it is required to implement a new asset and to add the asset to a module.

Example: Assets of the Hello module from the Hello World app
transactionAssets = [ new HelloAsset() ];
To learn how to create a new asset, check out the Creating a module asset guide.

Asset anatomy

Each asset is constructed as a class which extends from the BaseAsset.

The base asset provides an interface which needs to be completed by implementing the described components of an asset listed below.

asset

Transaction asset schema

The asset schema defines the custom data structure of the transaction.

It defines which properties can be included, if they are optional or required, and also which data types are to expect.

If a transaction object does not match the corresponding schema, the transaction will not be accepted by the node.

Asset schemas are defined in a modified JSON schema. For more information about this topic, check out the Schemas page.

Example of an asset schema
schema = {
    $id: 'lisk/hello/asset', (1)
    type: 'object',
    required: ["helloString"], (2)
    properties: { (3)
        helloString: {
            dataType: 'string',
            fieldNumber: 1,
        },
    }
};
1 The ID under which assets are saved in the database.
2 The required properties of the transaction asset.
3 Contains the properties of the transaction asset.

Validate

As the name suggests, the validate() function validates the posted transaction data to check it contains the expected format.

The following variables are available inside the validate() function:

  • asset: The custom data of the transaction (defined in Transaction asset schema) posted to the node.

  • transaction: The complete transaction object which was posted to the node.

If the function throws any errors, the transaction will not be applied by the node.

If the function does not throw any errors, the transaction will passed to the apply() function.

Example: validate() function of the CreateNFT asset of the NFT example app
validate({asset}) {
    if (asset.name === "Mewtwo") {
        throw new Error("Illegal NFT name: Mewtwo");
    }
};

Apply

The apply() function of an asset applies the desired business logic on the blockchain, based on the data posted in the transaction.

The following variables are available inside the apply() function:

Example: apply() function of the Hello asset of the Hello World example app
async apply({ asset, stateStore, reducerHandler, transaction }) {
    // Get sender account details
    const senderAddress = transaction.senderAddress;
    const senderAccount = await stateStore.account.get(senderAddress);
    // Add the hello string to the sender account
    senderAccount.hello.helloMessage = asset.helloString;
    stateStore.account.set(senderAccount.address, senderAccount);
    // Get the hello counter and decode it
    let counterBuffer = await stateStore.chain.get(
        CHAIN_STATE_HELLO_COUNTER
    );
    let counter = codec.decode(
        helloCounterSchema,
        counterBuffer
    );
    // Increment the hello counter by +1
    counter.helloCounter++;
    // Save the updated counter on the chain
    await stateStore.chain.set(
        CHAIN_STATE_HELLO_COUNTER,
        codec.encode(helloCounterSchema, counter)
    );
}

Reducers

Reducers are functions which can be invoked via Remote-Procedure-Calls (RPC) by other modules.

Reducers have access to the state store.

Modules and Assets can invoke reducers through The reducerHandler.

Example: Reducers of the Token module
public reducers = {
    // Credit tokens to an account
    credit: async (params: Record<string, unknown>, stateStore: StateStore): Promise<void> => {
        const { address, amount } = params;
        if (!Buffer.isBuffer(address)) {
            throw new Error('Address must be a buffer');
        }
        if (typeof amount !== 'bigint') {
            throw new Error('Amount must be a bigint');
        }
        if (amount <= BigInt(0)) {
            throw new Error('Amount must be a positive bigint.');
        }
        const account = await stateStore.account.getOrDefault<TokenAccount>(address);
        account.token.balance += amount;
        if (account.token.balance < this._minRemainingBalance) {
            throw new Error(
                `Remaining balance must be greater than ${this._minRemainingBalance.toString()}`,
            );
        }
        await stateStore.account.set(address, account);
    },
    // Debit tokens from an account
    debit: async (params: Record<string, unknown>, stateStore: StateStore): Promise<void> => {
        const { address, amount } = params;
        if (!Buffer.isBuffer(address)) {
            throw new Error('Address must be a buffer');
        }
        if (typeof amount !== 'bigint') {
            throw new Error('Amount must be a bigint');
        }
        if (amount <= BigInt(0)) {
            throw new Error('Amount must be a positive bigint.');
        }
        const account = await stateStore.account.getOrDefault<TokenAccount>(address);
        account.token.balance -= amount;
        if (account.token.balance < this._minRemainingBalance) {
            throw new Error(
                `Remaining balance must be greater than ${this._minRemainingBalance.toString()}`,
            );
        }
        await stateStore.account.set(address, account);
    },
    // Get the balance of an specific account
    getBalance: async (
        params: Record<string, unknown>,
        stateStore: StateStore,
    ): Promise<bigint> => {
        const { address } = params;
        if (!Buffer.isBuffer(address)) {
            throw new Error('Address must be a buffer');
        }
        const account = await stateStore.account.getOrDefault<TokenAccount>(address);
        return account.token.balance;
    },
    // Returns the minimum remaining balance for accounts
    getMinRemainingBalance: async (): Promise<bigint> => this._minRemainingBalance,
};

Lifecycle Hooks

Lifecycle hooks allow the execution of logic at specific moments in the block lifecycle of the application.

lifecycle hooks
Example: afterTransactionApply() of the Hello module from the Hello World app
async afterTransactionApply({transaction, stateStore, reducerHandler}) {
  // If the transaction is a hello transaction
  if (transaction.moduleID === this.id && transaction.assetID === HelloAssetID) {
    // Decode the transaction asset
    const helloAsset = codec.decode(
      helloAssetSchema,
      transaction.asset
    );

    // And publish a new hello:newHello event,
    // including the latest hello message and the sender.
    this._channel.publish('hello:newHello', {
      sender: transaction._senderAddress.toString('hex'),
      hello: helloAsset.helloString
    });
  }
};

beforeTransactionApply()

This hook is applied before each transaction.

The following variables are available inside this hook:

afterTransactionApply()

This hook is applied after each transaction.

The following variables are available inside this hook:

afterGenesisBlockApply()

This hook is applied after the genesis block.

The following variables are available inside this hook:

beforeBlockApply()

This hook is applied before each block.

The following variables are available inside this hook:

afterBlockApply()

This hook is applied after each block.

The following variables are available inside this hook:

Consensus

consensus offers different consensus related functions to get and set the list of active delegates, and to get the finalized height of the blockchain.

consensus interface
{
	getDelegates: () => Promise<Delegate[]>; (1)
	updateDelegates: (delegates: Delegate[]) => Promise<void>; (2)
	getFinalizedHeight: () => number; (3)
}
1 Get a list of the actively forging delegates in the current round.
2 Update the list of delegates for the current round.
3 Returns the currently finalized height of the blockchain.

Account schema

The account schema allows a module to store module-specific data in the user accounts [1].

The definition of this schema is totally flexible and it is possible to define very complex data structures as well if necessary.

Account schemas are defined in a modified JSON schema. For more information about this topic, check out the Schemas page.

Example: Account schema of the Hello module from the Hello World app
accountSchema = {
    type: 'object',
    properties: {
        helloMessage: {
            fieldNumber: 1,
            dataType: 'string',
        },
    },
    default: {
        helloMessage: '',
    },
};

The defined properties in the account schema will be available for every user account. They will be grouped under a key named after the Module name.

If a module with module name hello is registered in a default application with the above example of an account schema, the user accounts would appear as shown below:

The properties token, sequence, keys, dpos exist in the user account, because the blockchain application already has several modules registered by default.
Example user account
{
  "address": "ae6fff8b9c9c3a8b38193d2186638f684d64d887",
  "token": {
    "balance": "20000000000"
  },
  "sequence": {
    "nonce": "0"
  },
  "keys": {
    "numberOfSignatures": 0,
    "mandatoryKeys": [],
    "optionalKeys": []
  },
  "dpos": {
    "delegate": {
      "username": "",
      "pomHeights": [],
      "consecutiveMissedBlocks": 0,
      "lastForgedHeight": 0,
      "isBanned": false,
      "totalVotesReceived": "0"
    },
    "sentVotes": [],
    "unlocking": []
  },
  "hello": {
    "helloMessage": ""
  }
}

1. For more information about accounts, check the Accounts page of the Lisk protocol.