Creating a new plugin

This guide explains step-by-step, how to create a custom plugin for a blockchain application built with the Lisk SDK.

Prerequisites

To use this guide, it is assumed that the following criteria have been met:

First, create a new file named after the new plugin, for example my-plugin.js.

├── blockchain_app
│   ├── index.js
│   ├── my-plugin.js
│   └── package.json

1. Creating the plugin class

Open my-plugin.js and import the BasePlugin from the lisk-sdk package:

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

Next, define a new class MyPlugin, which extends from the BasePlugin:

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

class MyPlugin extends BasePlugin {

}

module.exports = { MyPlugin };

2. Setting the plugin alias

The alias of the plugin is used to identify the plugin within the application.

Define a getter alias(), which returns the plugin alias as string.

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

class MyPlugin extends BasePlugin {
  static get alias() {
    return "MyPlugin";
  }
}

module.exports = { MyPlugin };

3. Defining the plugin meta information

Next, define a getter info() to return the meta information about the plugin as an object.

In this example, we parse the package information from package.json and return author, version and name of the package.

const { BasePlugin } = require('lisk-sdk');
const pJSON = require('../package.json');

class MyPlugin extends BasePlugin {
  static get alias() {
    return "MyPlugin";
  }

  static get info() {
    return {
      author: pJSON.author,
      version: pJSON.version,
      name: pJSON.name,
    };
  }
}

module.exports = { MyPlugin };

4. Defining the plugin configuration

A plugin can be configured by setting the related properties in the application configuration.

The defaults() getter defines which properties are added to the application config by the plugin.

For a better overview, create a new file defaults.js and define the schema for the configuration options as shown in the example below:

defaults.js
const myConfig = {
    type: 'object',
    properties: {
        enable: {
            type: 'boolean',
        },
        myKey1: {
            type: 'integer',
            minimum: 1,
            maximum: 9999,
        },
        myKey2: {
            type: 'array',
            items: {
                type: 'string',
            },
        },
        myKey3: {
            type: 'object',
            properties: {
                myKey4: {
                    anyOf: [{ type: 'string' }, { type: 'boolean' }],
                },
                myKey5: {
                    type: 'array',
                },
            },
            required: ['myKey4'],
        },
      },
    required: ['enable', 'myKey1', 'myKey2'],
    default: {
        enable: true,
        myKey1: 5000,
        myKey2: ['127.0.0.1']
    },
}

module.exports = { myConfig };

This defines that the configuration options for the plugin can be provided in the following manner:

{
  //[...] other configuration options
  plugins: {
    myPlugin: {
      enable: true,
      key1: 5000,
      key2: ['127.0.0.1'],
      key3: { // optional key
        key4: '*',
        key5: ['GET', 'POST', 'PUT']
      },
    }
  }
}

To make the configuration options available to the plugin, require the prepared configuration options into my-plugin.js and return it in the default() getter.

const { BasePlugin } = require('lisk-sdk');
const { myConfig } = require('./defaults');
const pJSON = require('./package.json');

class MyPlugin extends BasePlugin {
  static get alias() {
    return "myPlugin";
  }

  static get info() {
    return {
      author: pJSON.author,
      version: pJSON.version,
      name: pJSON.name,
    };
  }

  get defaults() {
    return myConfig;
  }
}

module.exports = { MyPlugin };

5. Defining the plugin logic

The load() function of a plugin contains the plugin logic that is executed, when the plugin is initialized.

It can be used to retrieve, mutate, store and/or publish data in a specific way, depending on the purpose of the plugin.

The unload() method contains the logic that needs to be executed to unload the plugin correctly.

The channel, which is available inside of the load() function, allows access to the RPC endpoints in order to subscribe to events or to invoke certain actions within the application, to retrieve the desired data.

In this example, we subscribe to the event app:transaction:new, which is published everytime a new transaction is added to the application. Next, the transaction is decoded and checked for it’s moduleID and assetID. If the transaction is a register delegate transaction, the delegate name is saved under this._latestDelegate and a new event myPlugin:newDelegate is published, which is announcing the new delegate to the application.

Additionally, we subscribe to the event app:block:new, which is published everytime a new block is added to the blockchain. Next, the block is decoded and the timestamp of the block is pushed into the _knownTimestamps array. Then a new event myPlugin:timestamp is published, which returns the updated timestamp array.

my-plugin.js
const { BasePlugin, apiClient } = require('lisk-sdk');
const { myConfig } = require('./defaults');
const pJSON = require('../package.json');

class MyPlugin extends BasePlugin {
  _latestDelegate = undefined;
  _knownTimestamps = [];

  static get alias() {
    return "MyPlugin";
  }

  static get info() {
    return {
      author: pJSON.author,
      version: pJSON.version,
      name: pJSON.name,
    };
  }

  get defaults() {
    return myConfig;
  }

  async load(channel) {
     if (!this.options.enable) {
        return;
     }

    this._api = await apiClient.createIPCClient('~/.lisk/my-app');

    channel.subscribe('app:transaction:new', (data) => {
      const txBuffer = Buffer.from(data.transaction, 'hex');
      const transaction = this._api.transaction.decode(txBuffer);
      if ( transaction.moduleID === 5 && transaction.assetID === 0 ) {
        this._latestDelegate = transaction.username;
        channel.publish('myPlugin:newDelegate', {
          name: transaction.username,
        });
      }
    });
    channel.subscribe('app:block:new', (data) => {
      const decodedBlock = this.codec.decodeBlock(data.block);
      this._knownTimestamps.push(decodedBlock.header.timestamp);
      channel.publish('myPlugin:timestamp', { timestamp: decodedBlock.header.timestamp });
    });
  }

  async unload() {
    this._latestDelegate = undefined;
    this._knownTimestamps = [];
  }
}

module.exports = { MyPlugin };

6. Defining the plugin interfaces

Similar to modules, plugins expose actions and events, which are interfaces that allow other plugins or external services to interact with the plugin.

In this example, two events are added:

  • newDelegate, which is published in the load() function, when a new delegate is registered in ther network.

  • timestamp, which is published in the load() function, when a new block is added to the blockchain.

In addition, two actions are added:

  • If getKnownTimestamp is invoked, it returns the list of timestamps of the blocks that were added to the chain, while the plugin was active.

  • If getLatestDelegate is invoked, it returns the last delegate name that was registered in the network.

const { BasePlugin, apiClient } = require('lisk-sdk');
const { myConfig } = require('./defaults');
const pJSON = require('../package.json');

class MyPlugin extends BasePlugin {
  _latestDelegate = undefined;
  _knownTimestamps = [];

  static get alias() {
    return "MyPlugin";
  }

  static get info() {
    return {
      author: pJSON.author,
      version: pJSON.version,
      name: pJSON.name,
    };
  }

  get defaults() {
    return myConfig;
  }

  get events() {
    return ['newDelegate','timestamp'];
  }

  get actions() {
    return {
      getKnownTimestamp: () => this._knownTimestamps,
      getLatestDelegate: () => this._latestDelegate
    };
  }

  async load(channel) {
    this._api = await apiClient.createIPCClient('~/.lisk/my-app');

    channel.subscribe('app:transaction:new', (data) => {
      const txBuffer = Buffer.from(data.transaction, 'hex');
      const transaction = this._api.transaction.decode(txBuffer);
      if ( transaction.moduleID === 5 && transaction.assetID === 0 ) {
        this._latestDelegate = transaction.username;
        channel.publish('myPlugin:newDelegate', {
          name: transaction.username,
        });
      }
    });
    channel.subscribe('app:block:new', ({ data }) => {
      const decodedBlock = this.codec.decodeBlock(data.block);
      this._knownTimestamps.push(decodedBlock.header.timestamp);
      channel.publish('myPlugin:timestamp', { timestamp: decodedBlock.header.timestamp });
    });
  }

  async unload() {
    this._latestDelegate = undefined;
    this._knownTimestamps = [];
  }
}

module.exports = { MyPlugin };

7. Registering the plugin with the application

Finally, it is required to register the newly created module in the application:

index.js
const { Application, genesisBlockDevnet, configDevnet } = require('lisk-sdk');
const { MyPlugin } = require('./my-plugin.js');

// Create a custom config based on the configDevnet
const appConfig = utils.objects.mergeDeep({}, configDevnet, {
  label: 'my-app',
  genesisConfig: { communityIdentifier: 'hello' },
  rpc: {
    enable: true,
    mode: 'ws',
    port: 8888,
  },
  network: {
    port: 8887,
  },
  logger: {
    consoleLogLevel: 'info',
  },
});

const app = Application.defaultApplication(genesisBlockDevnet, appConfig);

app.registerPlugin(MyPlugin);

app
	.run()
	.then(() => app.logger.info('App started...'))
	.catch(error => {
		console.error('Faced error in application', error);
		process.exit(1);
	});

Now save and close index.js. The new plugin MyPlugin will now be available, the next time the application is started with node index.js.