DEV Community

Cover image for Creating Custom Blockchain Transactions with the SDK — Introducing Lisk Bills
Michiel Mulders
Michiel Mulders

Posted on • Originally published at blog.lisk.io

Creating Custom Blockchain Transactions with the SDK — Introducing Lisk Bills

The Lisk Bills webinar is now live on our YouTube channel. Subscribe for more educational developer content.

Lisk’s Alpha SDK Phase officially began in late July with the release of SDK 2.1.0. We decided what better way to showcase the potential of custom transactions than to create our own proof-of-concept (PoC) blockchain application. To explore the possibilities of custom transactions at their best, we decided to build an invoicing application and through this register two new custom transactions on our blockchain.

Introduction to Custom Transactions

Lisk SDK allows you to define your own custom transaction types where you can implement the required logic for your blockchain use-case. The custom transaction types are an extension to the default set of transactions that is already part of the Lisk Protocol. You can read more about the predefined types here.

The beginning of the Alpha SDK phase of our roadmap allows you to create your own proof-of-concept blockchain applications aligned with our architecture. This phase of our roadmap also allows us to get feedback on how the development experience can be improved via discussion on our community channels.

Custom Transactions to Grow the Lisk Ecosystem

Custom transactions offer great business value to the Lisk Ecosystem as they allow for a lot of creativity. We believe custom transactions are the “creative spark” for the Lisk Ecosystem to see a whole bunch of innovative projects being created. We already see community members coming up with their own solutions ranging from hardware-utilizing rental bike tracking system, Lisk.Bike, to the usage of our modular JavaScript library for an innovative take on a classic strategy game, Lisk Tic-Tac-Toe. Now is your time to get creative!

Benefits of Custom Transactions

Every account object has the ability to store data in its asset field. Custom transactions make clever use of this. The use of the asset field allows for any type of stringified data to be passed to the transaction. This allows for greater flexibility and creativity when defining custom logic.

Besides that, every custom transaction can access and modify all account-related data and only read transaction-related data from the database. This allows for more advanced interactions between data and even between different custom transactions. For example, our PoC used the data from the Invoice transaction to verify the validity of the Payment transaction.

You can also create a token in the asset field with some basic transfer and verification logic. In the end, this is just another way of smart contract logic.

Let’s continue with exploring the technicals of our Lisk Bills PoC.

Lisk Bills — Blockchain-Based Invoicing

Lisk Bills Logo

As we like the Keep it Simple and Stupid (KISS) approach, we have built a minimal frontend with React that uses the Lisk Alpha SDK to interact directly with your blockchain application. The PoC includes two actors, the client and the freelancer.

Imagine Alice (Freelancer) and Bob (Client). Bob is looking for a new logo for his website and decides to consult a freelancer. While looking for a good designer, he comes across Alice who offers some spectacular designs in her portfolio. Bob is so excited he decides to immediately employ Alice’s skillset.

A few days go by, and Alice returns the promised logo together with an invoice. However, Bob is a big fan of blockchain technology as it helps to ease the settlement process. It often happens parties disagree about the agreed price, product, or even shipping terms. Bob, therefore, believes blockchain can help with recording all this information right from the beginning, so no disputes can occur and human error can be eliminated. The blockchain should act as proof for the invoice.

For the above reason, Bob asks Alice to create the invoice via a Lisk custom transaction.

In order to do so, Alice first has to log in with her passphrase to the Lisk Bills application.

Lisk Bills Sign In

Custom Transaction 1: Invoice

Now Alice has been logged in, she can create an invoice. To create the custom invoice transaction, Alice has to input the following details:

  • Client holds Bob’s Lisk address or business name.
  • RequestedAmount holds the amount Bob is due to Alice.
  • Description to describe the delivered design service.

Lisk Bills Diagram Transactions

The following data is stored in the asset field of the transaction. As this is a normal BaseTransaction, we can simply specify Bob’s Lisk address as the recipient for the transaction.

Lisk Bills Send Invoice

Before we dive into the technicals, make sure to open or clone the lisk-sdk-examples repository. The code for both custom transactions can be found in invoice/transactions/invoice_transaction.js and invoice/transactions/payment_transaction.js.

Technicals

First of all, let’s take a look at the class definition. The InvoiceTransaction extends the BaseTransaction which means it’s inheriting its properties. As the name suggests, BaseTransaction is the most basic interface for creating new transaction types. Other transaction types also exist in the system, later we will show an example of extending the TransferTransaction type.

When extending the BaseTransaction we can provide extra business logic for the following methods: Prepare, Validate Asset, Apply Asset and Undo Asset. You can find out more about these methods in our documentation.

Also, pay attention to the static getter function for retrieving the type of the transaction. As an example, we have chosen 13 to be the type number for this transaction. Besides that, you can set the fee you want users to pay for using this transaction type. For now, we have set this to 1 LSK (10 to the 8th beddows).

class InvoiceTransaction extends BaseTransaction {
  static get TYPE () {
    return 13;
  }

  static get FEE () {
    return `${10 ** 8}`;
  }

  ...
}

Enter fullscreen mode Exit fullscreen mode

Prepare

The prepare function is responsible for loading the required data used inside the applyAsset() and undoAsset() function. Here, we try to load the account data for the sender as we want to add data to his asset field in the applyAsset() function. This data will be loaded from the StateStore object that provides access to data in the database.

We can cache the sender account like this by passing an array with filters.

await store.account.cache([
    {
        address: this.senderId,
    },
]);
Enter fullscreen mode Exit fullscreen mode

However, we actually don’t have to manually cache the data. We can simply call the parent method for the prepare function in the abstract BaseTransaction class which will by default cache the sender account to deduct the fee in the apply step.

async prepare(store) {
    await super.prepare(store);
}
Enter fullscreen mode Exit fullscreen mode

Validate Asset

Before a transaction reaches the apply step it gets validated. Check the transaction’s asset correctness from the schema perspective (no access to StateStore here). You can invalidate the transaction by pushing an error into the result array.

validateAsset() {
    const errors = [];
    if (!this.asset.client || typeof this.asset.client !== 'string') {
        errors.push(
            new TransactionError(
                'Invalid "asset.client" defined on transaction',
                this.id,
                '.asset.client',
                this.asset.client,
                'A string value',
            )
        );
    }
    if (!this.asset.requestedAmount || typeof this.asset.requestedAmount !== 'string') {
        errors.push(
            new TransactionError(
                'Invalid "asset.requestedAmount" defined on transaction',
                this.id,
                '.asset.requestedAmount',
                this.asset.requestedAmount,
                'A string value',
            )
        );
    }
    if (!this.asset.description || typeof this.asset.description !== 'string') {
        errors.push(
            new TransactionError(
                'Invalid "asset.description" defined on transaction',
                this.id,
                '.asset.description',
                this.asset.description,
                'A string value',
            )
        );
    }
    return errors;
}
Enter fullscreen mode Exit fullscreen mode

Apply Asset

As you can see, we finally use the loaded account which we put in the store during the prepare step. Next, we update the invoice count and record the ID of the invoice in an array with sent invoices. We will use this data in our frontend to display all invoices.

applyAsset(store) {
    const sender = store.account.get(this.senderId);

    // Save invoice count and IDs
    sender.asset.invoiceCount = sender.asset.invoiceCount === undefined ? 1 : sender.asset.invoiceCount++;
    sender.asset.invoicesSent = sender.asset.invoicesSent === undefined ? [this.id] : [...sender.asset.invoicesSent, this.id];
    store.account.set(sender.address, sender);
    return [];
}
Enter fullscreen mode Exit fullscreen mode

Undo Asset

Do not underestimate the importance of the undoAsset() function. The Undo function allows us to rollback to a previous blockchain state. Therefore, we should exactly tell our blockchain application how it should roll back changes.

The Undo function is of most importance for the fork recovery mechanism. In case a fork occurs on a chain with tip B and we want to roll back to a common height in order to re-apply blocks up to the tip of chain A, we need the Undo function to do the actual rollback to this common height.

For the invoice proof of concept, the code reduces the invoiceCount and removed the invoice ID from the invoicesSent array.

undoAsset(store) {
    const sender = store.account.get(this.senderId);

    // Rollback invoice count and IDs
    sender.asset.invoiceCount = sender.asset.invoiceCount === 1 ? undefined : sender.asset.invoiceCount--;
    sender.asset.invoicesSent = sender.asset.invoicesSent.length === 1 
        ? undefined 
        : sender.asset.invoicesSent.splice(
            sender.asset.invoicesSent.indexOf(this.id),
            1,
        );
    );
    store.account.set(sender.address, sender);
    return [];
}
Enter fullscreen mode Exit fullscreen mode

Ok, we have explored the functions for the invoice transaction. Let’s move to the payment transaction.

Custom Transaction 2: Payment

Now Bob has received the Invoice Transaction in his wallet, he decides to pay for the invoice. In order to fulfill the transaction, we would normally send a TransferTransaction which is natively supported by Lisk SDK.

However, doing so would make this a very boring tutorial. Therefore, Bob decides to use another custom transaction to showcase Lisk’s possibilities. This custom Payment Transaction holds logic to verify the transferred amount is at least equal to the RequestedAmount. Also, the transaction requires Bob to specify the ID of the invoice he wants to fulfill.

If the transferred amount is too low or the invoice ID just doesn’t exist, the transaction fails. Bob keeps his side of the agreement and sends the requested amount to Alice’s invoice ID. Bob even adds a tip for Alice’s great work.

Lisk Bills Diagram Transactions

This is how the UI implementation looks like for paying an invoice with our Lisk Bills application.

Lisk Bills Pay Invoice

Technicals

Again, let’s take a look at the class definition. The PaymentTransaction extends the TransferTransaction which means it’s inheriting its properties like a different fee and transfer-related verification checks. Also, pay attention to the static getter function for retrieving the type of the transaction. As we can not have identical transaction types, the PaymentTransaction has received type 14.

class PaymentTransaction extends TransferTransaction {

    static get TYPE () {
        return 14;
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

Also, note we don’t define a static getter function for FEE. We didn’t implement it here as we don’t want to overwrite the FEE defined in the TransferTransaction. In short, we want to use the 0.1 fee defined in TransferTransaction.

Prepare

The prepare function is responsible for loading the required data into the store to be used inside the applyAsset() and undoAsset() function. For the PaymentTransaction, we are loading the transaction that holds the invoice using the ID sent with this.asset.data.

async prepare(store) {
    await super.prepare(store);
    await store.transaction.cache([
        {
            id: this.asset.data,
        },
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Validate Asset

As you might have noticed, we didn’t implement the validateAsset() function for the payment transaction. The only check we have to perform is validating if the sent number of tokens is at least equal to the requested number of tokens.

In order to validate this, we need access to the StateStore as we need to cache the invoice transaction. Because we can only perform static checks in the validateAsset() function that don’t use the StateStore, this check is moved to the apply step.

Apply Asset

The applyAsset() function first tries to find the corresponding invoice transaction. If this transaction exists, we further check for the number of tokens transferred is at least equal to the requested amount in the invoice. If this check succeeds, the transaction gets applied.

applyAsset(store) {
    const errors = super.applyAsset(store);

    const transaction = store.transaction.find(
        transaction => transaction.id === this.asset.data
    ); // Find related invoice in transactions for invoiceID

    if (transaction) {
        if (this.amount.lt(transaction.asset.requestedAmount)) {
            errors.push(
                new TransactionError(
                    'Paid amount is lower than amount stated on invoice',
                    this.id,
                    '.amount',
                    transaction.requestedAmount,
                    'Expected amount to be equal or greated than `requestedAmount`',
                )
            );
        }
    } else {
        errors.push(
            new TransactionError(
                'Invoice does not exist for ID',
                this.id,
                '.asset.invoiceID',
                this.asset.data,
                'Existing invoiceID registered as invoice transaction',
            )
        );
    }

    return errors;
}
Enter fullscreen mode Exit fullscreen mode

Undo Asset

No rollback logic is required for the undo step of the payment transaction. We do not modify any data in the store with the set method, so no need for defining undo steps to revert this data change.

However, do not forget to call super.undoAsset(store) as the Undo step will make sure the fee Alice has paid gets returned to her account’s balance.

How to Register Custom Transactions?

Ok, we have prepared both of our custom transactions. Bob and Alice are very happy to use both transactions in order to finalize their deal. However, we don’t know yet how to register these new transactions on our blockchain application.

The invoice/index.js file holds the startup code for running your custom blockchain and also registers both transactions. It’s as simple as that!

const { Application, genesisBlockDevnet, configDevnet } = require('lisk-sdk');
const { InvoiceTransaction, PaymentTransaction } = require('./transactions/index');

const app = new Application(genesisBlockDevnet, configDevnet);

app.registerTransaction(InvoiceTransaction);
app.registerTransaction(PaymentTransaction);

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

Ok, we are all done! At last, let’s take a brief look at the considerations regarding the use of custom transactions.

Considerations of Using Custom Transactions

Currently, we expect users to run their own blockchain instance that registers their freshly created custom transaction.

It took us a few weeks to build this prototype. We have deliberately kept it simple to act as a learning resource, and as inspiration for the community. It is not production-ready.

Conclusion

Lisk aims to allow for creativity within the blockchain industry by providing the ability to process data with custom business logic. This concept is very similar to smart contracts as they also hold custom business logic. We are pleased to introduce you to Lisk Bills as the first example of what is possible with our SDK.

We hope this freedom will spark a whole bunch of new innovative blockchain applications build on top of Lisk using the newly released Lisk Alpha SDK. Currently, we do not plan to support custom transactions on the Lisk mainnet but they are intended to be used inside your own blockchain application.

Lisk is on a mission to enable you to create decentralized, efficient, and transparent blockchain applications. Join us:

Top comments (0)