On-chain architecture

The on-chain architecture of the Application consists of two abstraction layers: Node and Modules.

The Node is the main object in charge of acting on the blockchain including:

  • forging blocks

  • synchronizing with the network

  • processing blocks from the network

  • …​ and broadcasting blocks and transactions to the network.

The Node is a private abstraction layer of the above mentioned activities, which is the reason it’s not visible to the SDK user.

Only the Module interface is exposed to the user, for defining any on-chain logic.

The life cycle of a block

If a node receives a new block, it always performs the following actions.

The bold steps are the steps exposed to the developer via the base module and base asset, see the Lisk Framework reference.

  1. Receive block

  2. Apply fork choice rule

  3. Validate block

    1. Validate transactions

      1. Validate transaction

      2. Validate transaction asset

  4. Verify block header

  5. Before block apply

  6. Apply block

    1. Apply transactions

      1. beforeTransactionApply

      2. Apply asset

      3. afterTransactionApply

  7. After block apply

  8. Save block and updated states

What is a module?

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

on chain architecture

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

Modules define an account schema to store the module related data in the account. The definition of this schema is totally flexible and it is possible to define very complex data structures as well, if needed.

The second part of modules enables to control the behaviour of the module. The BaseModule, which every module extends from, offers various lifecycle hooks that allow each module to execute certain logic before or after transactions or blocks. Also, it defines the logic for the transaction assets of the module.

As third part, modules expose an interface. This interface allows other components of the applications to interact with the module. Reducers are actions that can only be invoked by other modules of the application. Actions and Events are exposed to the plugins and to external services. More information about the exposed interface can be found in the section about the Communication Architecture.

When to create a custom module

Modules enable to…​

  • define how data is stored on the blockchain

  • define logic which is executed per block

  • define logic which is executed per transaction

In addition, all of the logic implemented using a module/asset must be deterministic and executable within the block time.

Transaction assets

Transaction assets contain all logic related to transactions that belong to the module. Formerly, this logic was implemented in a custom transaction. The implementation of a custom transaction asset is similar to a custom transaction, but much more simple.

When to create a custom asset

Create a custom asset for every transaction type that you want to use in the blockchain application.

Assets enable to…​

  • define a schema for data sent through transaction asset

  • validate the data

  • define logic which is executed per asset

How to create a custom module with an asset

The module and asset must extend the base module and base asset defined in the Lisk SDK.

Example: A hello module

A simple implementation of a custom module can be seen below:

hello_module.ts
const { BaseModule, codec } = require('lisk-sdk');
const { HelloAsset, HelloAssetID } = require('./hello_asset');
const {
    helloCounterSchema,
    helloAssetSchema,
    CHAIN_STATE_HELLO_COUNTER
} = require('./schemas');

class HelloModule extends BaseModule {
    name = 'hello'; (1)
    id = 1024; (2)
    accountSchema = { (3)
        type: 'object',
        properties: {
            helloMessage: {
                fieldNumber: 1,
                dataType: 'string',
            },
        },
        default: {
            helloMessage: '',
        },
    };
    transactionAssets = [ new HelloAsset() ]; (4)
    actions = { (5)
        amountOfHellos: async () => {
            const res = await this._dataAccess.getChainState(CHAIN_STATE_HELLO_COUNTER);
            const count = codec.decode(
                helloCounterSchema,
                res
            );
            return count;
        },
    };
    events = ['newHello']; (6)
    reducers = {}; (7)
    async beforeTransactionApply({transaction, stateStore, reducerHandler}) { (8)
        // Code in here is applied before each transaction is applied.
    };

    async afterTransactionApply({transaction, stateStore, reducerHandler}) { (9)
      // Code in here is applied after each transaction is applied.
      if (transaction.moduleID === this.id && transaction.assetID === HelloAssetID) {

        const helloAsset = codec.decode(
          helloAssetSchema,
          transaction.asset
        );

        this._channel.publish('hello:newHello', {
          sender: transaction._senderAddress.toString('hex'),
          hello: helloAsset.helloString
        });
      }
    };
    async afterGenesisBlockApply({genesisBlock, stateStore, reducerHandler}) { (10)
      // Set the hello counter to zero after the genesis block is applied
      await stateStore.chain.set(
        CHAIN_STATE_HELLO_COUNTER,
        codec.encode(helloCounterSchema, { helloCounter: 0 })
      );
    };
    async beforeBlockApply(context) { (11)
        // Code in here is applied before each block is applied.
    }
    async afterBlockApply(context) { (12)
        // Code in here is applied after each block is applied.
    }
}

module.exports = { HelloModule };
1 name(required): will be used for a key of the account schema if defined.
2 id(required): will be used for a fieldNumber for the account schema, and as moduleID when sending a transaction.
3 accountSchema: defines the account schema for the module. Defined properties will be added to every account under the name of the module.
4 transactionAssets: A list of all custom assets that belong to the module.
5 actions: A list of actions that can be invoked by plugins and external services.
6 events: A list of events that other plugins and external services can subscribe to.
7 reducers: A list of actions that can be invoked by other modules.
8 beforeTransactionApply: Code in here is applied before each transaction is applied.
9 afterTransactionApply: Code in here is applied after each transaction is applied.
10 afterGenesisBlockApply: Code in here is applied after the genesis block is applied.
11 beforeBlockApply: Code in here is applied before each block is applied.
12 afterBlockApply: Code in here is applied after each block is applied.

Example: The hello asset

A simple implementation of a custom asset looks like this:

hello_asset.ts
const {
    BaseAsset,
    codec,
} = require('lisk-sdk');
const {
    helloCounterSchema,
    CHAIN_STATE_HELLO_COUNTER
} = require('./schemas');

const HelloAssetID = 0;

class HelloAsset extends BaseAsset {
    name = 'helloAsset'; (1)
    id = HelloAssetID; (2)
    schema = { (3)
        $id: '/hello/asset',
        type: 'object',
        required: ["helloString"],
        properties: {
            helloString: {
                dataType: 'string',
                fieldNumber: 1,
            },
        }
    };

    validate({asset}) { (4)
        if (!asset.helloString || typeof asset.helloString !== 'string' || asset.helloString.length > 64) {
          throw new Error(
                'Invalid "asset.hello" defined on transaction: A string value no longer than 64 characters is expected'
            );
        }
    };

    async apply({ asset, stateStore, reducerHandler, transaction }) { (5)
        const senderAddress = transaction.senderAddress;
        const senderAccount = await stateStore.account.get(senderAddress);

        senderAccount.hello.helloMessage = asset.helloString;
        stateStore.account.set(senderAccount.address, senderAccount);

        let counterBuffer = await stateStore.chain.get(
            CHAIN_STATE_HELLO_COUNTER
        );

        let counter = codec.decode(
            helloCounterSchema,
            counterBuffer
        );

        counter.helloCounter++;

        await stateStore.chain.set(
            CHAIN_STATE_HELLO_COUNTER,
            codec.encode(helloCounterSchema, counter)
        );
    }
}

module.exports = { HelloAsset, HelloAssetID };
1 name(required): used for UI purpose.
2 id(required): used as AssetID when sending a transaction.
3 schema(required): defines the asset schema for the transaction.
4 validate is used to validate the asset data before it is applied. Throws an error, in case the validation fails.
5 apply(required): defines a state change induced by this asset. In HelloAsset, it adds the hello string that was sent in the transaction to the senders account and increments the helloCounter.

SDK default modules

Name Description

DPoS module

The DPoS module is responsible for handling all DPoS related logics. Specifically:

  • Snapshotting vote weights

  • Calculating productivity

  • Handling registerDelegate, voteDelegate, unlockToken and reportDelegateMisbehavior transaction assets

  • Setting the next delegates set

Keys module

The Keys module handles all logic related to the signatures.

It should verify the signatures based on the multi-signature rules including non-multi-signature accounts. It also handles the registration of multi-signature accounts.

Sequence module

The Sequence module handles all logic related to the nonce.

It should verify the nonce for all transactions and increment if valid.

Token module

The Token module handles all logic related to balance. Specifically:

  • Validating and subtracting fees for all transactions

  • Checking the minimum remaining balance requirement

  • Giving block rewards to the block generator

  • Transferring account balances