Creating a new module

How to create a new module for a blockchain application.

Sample code

View the complete sample code of this guide on GitHub in the Lisk SDK examples repository.

If you wish to view an example of a module in a more complex blockchain application, check out the following examples:

As defined in the Hello World application overview, the module shall provide the following functionalities:

  • A hello message shall be stored in the user accounts.

    • The hello message has to be of type string.

    • The hello message has a maximum length of 64 characters.

  • A counter, counting the total number of sent hello messages. Anyone can request the current value of the hello counter.

  • All users can be notified immediately if a new hello message was sent. The notification includes information about the sender andress and the sent hello message.

Prerequisites

To use this guide, the following criteria is assumed:

1. Generating the module skeleton

While in the root folder of your blockchain application, generate a skeleton for the new module with Lisk Commander.

The command lisk generate:module expects 2 arguments:

  1. The module name.

  2. The module ID. Needs to be unique within the application. The minimum value is 1000.

For a complete overview of all available options of the generate:module command, visit the Lisk Commander command reference.

As an example, we use the module name hello and the module ID 1000:

lisk generate:module hello 1000

This will generate the following files:

Creating module skeleton with module name "hello" and module ID "1000"
Using template "lisk-ts"
Generating module skeleton.
Registering module...
identical .liskrc.json
   create src/app/modules/hello/hello_module.ts
   create test/unit/modules/hello/hello_module.spec.ts

No change to package.json was detected. No package manager install will be executed.

Your module is created and ready to use.

It will also automatically register the module with the application, by adding it to src/app/modules.ts.

Once the first module is added to the application, a manual change in modules.ts is required:

  • Remove the underscore from the _app parameter and change it to app

Otherwise, you will run into the following error, when trying to start the application:

reference error : app is not defined
src/app/modules.ts
import { Application } from 'lisk-sdk';
import { HelloModule } from "./modules/hello/hello_module";

export const registerModules = (app: Application): void => {

    app.registerModule(HelloModule);
};

Please be aware that once the account schema is implemented into the module, that the application will not start successfully anymore. This is due to the fact that the accounts in the genesis block are not including the account schema for the new module yet.

In order to solve this, simply generate a new genesis block by using the application CLI, as explained in the next guide How to create a new genesis block.

The file hello_module.ts contains the module skeleton and the file hello_module.spec.ts contains the related unit tests for the new module.

For more information on how to write a unit test for the blockchain application, check out the Testing the blockchain application guide.

The module skeleton can be viewed in hello_module.ts:

src/app/modules/hello/hello_module.ts
import {
    BaseModule,
    AfterBlockApplyContext,
    TransactionApplyContext,
    BeforeBlockApplyContext,
    AfterGenesisBlockApplyContext,
    // GenesisConfig
} from 'lisk-sdk';

export class HelloModule extends BaseModule {
    public actions = {
        // Example below
        // getBalance: async (params) => this._dataAccess.account.get(params.address).token.balance,
        // getBlockByID: async (params) => this._dataAccess.blocks.get(params.id),
    };
    public reducers = {
        // Example below
        // 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;
		// },
    };
    public name = 'hello';
    public transactionAssets = [];
    public events = [
        // Example below
        // 'hello:newBlock',
    ];
    public id = 1000;

    // public constructor(genesisConfig: GenesisConfig) {
    //     super(genesisConfig);
    // }

    // Lifecycle hooks
    public async beforeBlockApply(_input: BeforeBlockApplyContext) {
        // Get any data from stateStore using block info, below is an example getting a generator
        // const generatorAddress = getAddressFromPublicKey(_input.block.header.generatorPublicKey);
		// const generator = await _input.stateStore.account.get<TokenAccount>(generatorAddress);
    }

    public async afterBlockApply(_input: AfterBlockApplyContext) {
        // Get any data from stateStore using block info, below is an example getting a generator
        // const generatorAddress = getAddressFromPublicKey(_input.block.header.generatorPublicKey);
		// const generator = await _input.stateStore.account.get<TokenAccount>(generatorAddress);
    }

    public async beforeTransactionApply(_input: TransactionApplyContext) {
        // Get any data from stateStore using transaction info, below is an example
        // const sender = await _input.stateStore.account.getOrDefault<TokenAccount>(_input.transaction.senderAddress);
    }

    public async afterTransactionApply(_input: TransactionApplyContext) {
        // Get any data from stateStore using transaction info, below is an example
        // const sender = await _input.stateStore.account.getOrDefault<TokenAccount>(_input.transaction.senderAddress);
    }

    public async afterGenesisBlockApply(_input: AfterGenesisBlockApplyContext) {
        // Get any data from genesis block, for example get all genesis accounts
        // const genesisAccoounts = genesisBlock.header.asset.accounts;
    }
}

The command generate:module already created the class HelloModule which contains skeletons for the most important components of a module. The only properties which are set at this point are the module ID and the module name, which were defined previously while generating the module skeleton.

In fact it can be stated that with these 2 properties, it is already a complete module that can be registered with the application. However, this module is not performing any functions yet. To give the module a purpose, it is necessary to implement certain logic inside of the module.

The following sections explain how the different components of a module can be used to implement the desired logic for the module.

2. The module class

The module class always extends from the BaseModule, which is imported from the lisk-sdk package.

The properties name and id are pre-filled by the values that were used when generating the module skeleton in the previous step.

src/app/modules/hello/hello_module.ts
export class HelloModule extends BaseModule {

    // ...

    public name = 'hello';
    public id = 1000;

    // ...
}

3. Defining an account schema

In some cases, the new module will require storing some new data in the user accounts. If that is the case, then it is required to define the corresponding account schema in the module.

For more information about the account schema in modules, check out the section that covers the account schemas in the Modules introduction page.

For the Hello application, it is required to store a hello message in each user account, as defined in the application overview of the guide Creating a new blockchain application. The hello message should be of type string and it should have a minium length of 3, and a maximum length of 64 characters. All of this can be defined in the account schema.

The account schema for the Hello module is defined as follows:

src/app/modules/hello/hello_module.ts
export class HelloModule extends BaseModule {

    // ...

    public accountSchema = {
        type: 'object',
        properties: {
            helloMessage: {
                fieldNumber: 1,
                dataType: 'string',
                maxLength: 64,
            },
        },
        default: {
            helloMessage: '',
        },
    };

    // ...
}

If a module includes an account schema, it is necessary to update the genesis block after registering the module to the application.

4. Assets

A module can include various assets, each allowing the module to handle a new transaction type.

Before a new asset can be added, it is first required to create the custom asset as described in the Creating a new asset guide.

Assuming an asset CreateHelloAsset has been created for the module, then it will be included in the module as shown below:

src/app/modules/hello/hello_module.ts
import { BaseModule } from 'lisk-sdk';
const { CreateHelloAsset } = require('./assets/create_hello_asset');

export class HelloModule extends BaseModule {

    // ...

    public transactionAssets = [
       new CreateHelloAsset()
    ];

    // ...
}

5. Events

A list of events that this module is able to emit is covered here.

Modules, plugins, and external services can subscribe to these events.

See the Events section of the "Modules" introduction page and the Aliases section of the "Communication" page for more information.

Add a new event newHello. This event shall be published every time a user is updating their hello message. The events defined can be published to the application in the Lifecycle Hooks of the module.

src/app/modules/hello/hello_module.ts
export class HelloModule extends BaseModule {

    // ...
    public events = ['newHello'];

    // ...
}

6. Lifecycle Hooks

Lifecycle hooks allow a module to execute certain logic, before or after blocks or transactions are applied to the blockchain.

Inside of the lifecycle hooks, it’s possible to publish the above defined events to the application and to filter for certain transactions and blocks, before applying the logic.

See the "Lifecycle Hooks" section of the Modules introduction page for more information.

In the hello module, two different lifecycle hooks are defined.

afterTransactionApply

Publishes a new event hello:newHello for every applied hello transaction asset, and adds information about the sender of the transaction, and the corresponding hello message.

afterGenesisBlockApply

If the genesis block is applied, it will set the counter for posted hello transactions to zero.

src/app/modules/hello/hello_module.ts
export class HelloModule extends BaseModule {

    // ...

     // Lifecycle hooks
    public async afterTransactionApply(_input: TransactionApplyContext) {
        // Publish a `newHello` event for every received hello transaction
        // 1. Check for correct module and asset IDs
        if (_input.transaction.moduleID === this.id && _input.transaction.assetID === 0) {

            // 2. Decode the transaction asset
            const helloAsset = codec.decode(
                helloAssetSchema,
                _input.transaction.asset
            );

            // 3. Publish the event 'hello:newHello' and
            // attach information about the sender address and the posted hello message.
            this._channel.publish('hello:newHello', {
                sender: _input.transaction._senderAddress.toString('hex'),
                hello: helloAsset.helloString
            });
        }
    }

    public async afterGenesisBlockApply(_input: AfterGenesisBlockApplyContext) {
        // Set the hello counter to zero after the genesis block is applied
        await _input.stateStore.chain.set(
            CHAIN_STATE_HELLO_COUNTER,
            codec.encode(helloCounterSchema, { helloCounter: 0 })
        );
    }

    // ...
}

It is recommended to store the different schemas in a separate file, e.g. schemas.js, and import them in to the module and asset where required.

For more information about schemas, check out the Codec & schema page.

The following schemas are used in the lifecycle hooks:

src/app/modules/hello/schemas.js
// This key is used to save the data for the hello counter in the database
const CHAIN_STATE_HELLO_COUNTER = "hello:helloCounter";

// This schema is used to decode/encode the data of the hello counter from/for the database
const helloCounterSchema = {
  $id: "lisk/hello/counter",
  type: "object",
  required: ["helloCounter"],
  properties: {
    helloCounter: {
      dataType: "uint32",
      fieldNumber: 1,
    },
  },
};

// This schema is used to decode/encode the data of the asset of the hello transaction from/for the database
const helloAssetSchema = {
  $id: "lisk/hello/asset",
  type: "object",
  required: ["helloString"],
  properties: {
    helloString: {
      dataType: "string",
      fieldNumber: 1,
    },
  },
};

module.exports = {
  CHAIN_STATE_HELLO_COUNTER,
  helloCounterSchema,
  helloAssetSchema
};

7. Actions

A list of actions that plugins and external services can invoke.

See the "Actions" section of the Modules introduction page for more information.

Add a new action amountOfHellos.

If the action is invoked, it will return the total amount of sent hello messages in the network. The hello counter is set to zero after applying the genesis block in the Lifecycle Hooks, and incremented in the asset. This action simply returns the current value of the hello counter, which is retrieved from the database.

src/app/modules/hello/hello_module.ts
export class HelloModule extends BaseModule {

    // ...

    public actions = {
        amountOfHellos: async () => {
            const res = await this._dataAccess.getChainState(CHAIN_STATE_HELLO_COUNTER);
            const count = codec.decode(
                helloCounterSchema,
                res
            );
            return count;
        },
    };

    // ...
}