Creating transactions and signing them offline

Option 1: Using the node CLI to create a transaction offline

The CLI of a node can be used to create a transaction, even if the node is currently offline.

The flag --offline is used here, so that the transaction can be signed, even if the node is not connected to any network at the moment.

The --offline flag also requires the flags --chain-id and --nonce to be specified.

./bin/run transaction:create token transfer 10000000 --chain-id 00000001 --nonce 0 --offline
? Please enter: tokenID:  0000000100000000
? Please enter: amount:  1000000000
? Please enter: recipientAddress: lskg6prjbqpm6m8rsvmsg6dgyx3e89drknbvxg7x8
? Please enter: data:  Happy Birthday!
? Please enter passphrase:  [hidden]

After all relevant information about the transaction is given, the transaction is created and returned in hex format:

{"transaction":"0a05746f6b656e12087472616e7366657218002080ade2042a20eef0f24cc71fe2bd48a2b8c10b9a44fa304674069230a25b11ee82d8638ab363322d0a0800000001000000001080c8afa0251a14fa892e1aa42a8af96c45dfd5afc428b3dba950e6220548656c6c6f3a40c536e178e4000c6631f099f7f7af3ea12d89fef0b11e8ac5cb9ff025283849a8ab9fa03ac8542dd36f5eda8af48a279569d67943dc9faf385040d03375ecc201"}
How to additionally return the transaction in JSON format

To also see the decoded transaction object on creation, add the --json parameter:

./bin/run transaction:create token transfer 10000000 --chain-id 00000001 --nonce 0 --offline --json

This creates a response as seen below:

{
  "transaction": "0a05746f6b656e12087472616e7366657218002080ade2042a20eef0f24cc71fe2bd48a2b8c10b9a44fa304674069230a25b11ee82d8638ab363322d0a0800000001000000001080c8afa0251a14fa892e1aa42a8af96c45dfd5afc428b3dba950e6220548656c6c6f3a40c536e178e4000c6631f099f7f7af3ea12d89fef0b11e8ac5cb9ff025283849a8ab9fa03ac8542dd36f5eda8af48a279569d67943dc9faf385040d03375ecc201"
}
{
  "transaction": {
    "module": "token",
    "command": "transfer",
    "fee": "10000000",
    "nonce": "0",
    "senderPublicKey": "eef0f24cc71fe2bd48a2b8c10b9a44fa304674069230a25b11ee82d8638ab363",
    "signatures": [
      "c536e178e4000c6631f099f7f7af3ea12d89fef0b11e8ac5cb9ff025283849a8ab9fa03ac8542dd36f5eda8af48a279569d67943dc9faf385040d03375ecc201"
    ],
    "params": {
      "tokenID": "0000000100000000",
      "amount": "10000000000",
      "recipientAddress": "lskg6prjbqpm6m8rsvmsg6dgyx3e89drknbvxg7x8",
      "data": "Hello"
    },
    "id": "a01f5a6e5e753a872652cbefc1578bcf90b99a89cbfc522b4afded53ce344cbc"
  }
}

Option 2: Creating transactions offline with Lisk Elements

The relevant files discussed in this guide are schemas.js, account.json, create-offline.js, sign-offline.js, api-client.js, dry-run.js and index.js.

The following Lisk Elements packages are required to create and sign a transaction offline:

Provide the transaction schema and params schema

To create, validate, and sign transactions offline with Lisk Elements, it is necessary to access their schemas.

The corresponding param schemas can be found in the module reference pages listed below:

Create a new file schemas.js and add all required schemas in this file, as shown in the snippet below.

In this example, we will send a Transfer Token transaction, therefore two different schemas are required:

  1. The Transaction Schema (always required).

  2. The Transfer Token params schema.

schemas.js
const transactionSchema = {
    $id: '/lisk/transaction',
    type: 'object',
    required: ['module', 'command', 'nonce', 'fee', 'senderPublicKey', 'params'],
    properties: {
        module: {
            dataType: 'string',
            fieldNumber: 1,
            minLength: 1,
            maxLength: 32,
        },
        command: {
            dataType: 'string',
            fieldNumber: 2,
            minLength: 1,
            maxLength: 32,
        },
        nonce: {
            dataType: 'uint64',
            fieldNumber: 3,
        },
        fee: {
            dataType: 'uint64',
            fieldNumber: 4,
        },
        senderPublicKey: {
            dataType: 'bytes',
            fieldNumber: 5,
            minLength: 32,
            maxLength: 32,
        },
        params: {
            dataType: 'bytes',
            fieldNumber: 6,
        },
        signatures: {
            type: 'array',
            items: {
                dataType: 'bytes',
            },
            fieldNumber: 7,
        },
    },
};

const transferParamsSchema = {
    $id: '/lisk/transferParams',
    title: 'Transfer transaction params',
    type: 'object',
    required: ['tokenID', 'amount', 'recipientAddress', 'data'],
    properties: {
        tokenID: {
            dataType: 'bytes',
            fieldNumber: 1,
            minLength: 8,
            maxLength: 8,
        },
        amount: {
            dataType: 'uint64',
            fieldNumber: 2,
        },
        recipientAddress: {
            dataType: 'bytes',
            fieldNumber: 3,
            format: 'lisk32',
        },
        data: {
            dataType: 'string',
            fieldNumber: 4,
            minLength: 0,
            maxLength: 64,
        },
    },
};

module.exports = { transferParamsSchema, transactionSchema };

Providing the account credentials

To create and sign the transaction, the credentials of the account sending the transactions are required.

In particular, the following account credentials are required:

  1. publicKey: To create the transaction

  2. privateKey: To sign the transaction

Create a file account.json and add all relevant account credentials into this file. You can also create a new account using the keys:create CLI command.

In this example, we use the following example account credentials:

account.json
{
  "address": "lskg6prjbqpm6m8rsvmsg6dgyx3e89drknbvxg7x8",
  "keyPath": "m/44'/134'/0'",
  "publicKey": "ec10255d3e78b2977f04e59ea9afd3e9a2ce9a6b44619ef9f6c47c29695b1df3",
  "privateKey": "ac3e34eb369d52a3cddf0bc4312d9b0aa3625b04721039bb114f4c607fb5256eec10255d3e78b2977f04e59ea9afd3e9a2ce9a6b44619ef9f6c47c29695b1df3",
  "binaryAddress": "fa892e1aa42a8af96c45dfd5afc428b3dba950e6"
}

Retrieving binaryAddress

Each account’s credential contains an address in the Lisk32 format. To convert an address into a binary string format, you can use lisk-console.

  1. Start a Lisk console session.

    lisk-console
  2. Pass the Lisk32 address to the getAddressFromLisk32Address function:

    lisk.cryptography.address.getAddressFromLisk32Address('lskg6prjbqpm6m8rsvmsg6dgyx3e89drknbvxg7x8').toString('hex')
  3. The console will output the resultant binary address, as shown below:

    'fa892e1aa42a8af96c45dfd5afc428b3dba950e6'

For all the available conversions, please refer to the lisk.cryptography.address package.

Creating the transaction

Create a new file create-offline.js to create the unsigned transaction object.

Define the unsigned transaction object manually by following the transaction schema.

It is recommended to verify the correct format of the transaction with the validator.validate() function of the @liskhq/lisk-validator package afterwards.

Then, manually define the parameters for the Token Transfer command, and add them to the unsigned transaction.

create-offline.js
const { validator } = require('@liskhq/lisk-client');
const { transactionSchema } = require('./schemas');
// Example account credentials
const account = require('./account.json');

const createTxOffline = () => {
	// Adjust the values of the unsigned transaction manually
	const unsignedTransaction = {
		module: "token",
		command: "transfer",
		fee: BigInt(10000000),
		nonce: BigInt(23),
		senderPublicKey: Buffer.from(account.publicKey, 'hex'),
		params: Buffer.alloc(0),
		signatures: [],
	};

	// Validate the transaction
	validator.validator.validate(transactionSchema, unsignedTransaction);

	// Create the asset for the Token Transfer transaction
	const transferParams = {
		tokenID: Buffer.from('0000000100000000', 'hex'),
		amount: BigInt(1000),
		recipientAddress: Buffer.from(account.binaryAddress, 'hex'),
		data: 'Happy birthday!'
	};

	// Add the transaction params to the transaction object
	unsignedTransaction.params = transferParams;

	// Return the unsigned transaction object
	return unsignedTransaction;
}

module.exports = { createTxOffline }
For an offline transaction to dry-run successfully, the tokenID and chainID of the node must be the same as the ones used in the creation of an offline transaction.

The transaction object is now returned, and ready to be signed by the sender in the next step.

Signing the transaction

Create a new file sign-offline.js to create a script that will sign a given unsigned transaction object.

To sign the transaction, use the signTransaction() function of the @liskhq/lisk-transactions package. It requires the following parameters:

  1. The unsigned transaction

  2. The chain ID

  3. The private key of the account signing the transaction

  4. The params schema for the command addressed in the transaction

sign-offline.js
const { transactions } = require('@liskhq/lisk-client');
const { transferParamsSchema } = require('./schemas');
const account = require('./account.json');

const chainID = '00000001';

const signTx = (unsignedTransaction) => {
	const signedTransaction = transactions.signTransaction(
		unsignedTransaction,
		Buffer.from(chainID, 'hex'),
		Buffer.from(account.privateKey, 'hex'),
		transferParamsSchema
	);

	return signedTransaction;
}

module.exports = { signTx }

Verifying the transaction

A transaction dry-run can only be performed online, by connecting to a node.

Without dry-running the transaction, its validity cannot be verified, and the transaction might fail.

To connect to a node, create a function getClient() which provides an instance of the Lisk API client.

Create a new file api-client.js and paste the following code:

api-client.js
const { apiClient } = require('@liskhq/lisk-client');

const RPC_ENDPOINT = 'ws://127.0.0.1:7887/rpc-ws';
let clientCache;

const getClient = async () => {
  if (!clientCache) {
    clientCache = await apiClient.createWSClient(RPC_ENDPOINT);
  }
  return clientCache;
};

module.exports = { getClient };

Create a new file dry-run.js to create a function that performs a dry-run for a given transaction.

Require the function getClient() to retrieve the API client, and use it to perform a dry-run of the transaction.

dry-run.js
const { getClient } = require('./api-client');

const dryRun = async (signedTransaction) => {
	const client = await getClient();
	const encTx = client.transaction.encode(signedTransaction);
	const result = await client.invoke('txpool_dryRunTransaction', { "transaction": encTx.toString("hex") });

	return result;
}

module.exports = { dryRun };

Executing the script

Finally, create a new file index.js to execute the scripts we defined above one after another.

index.js
const { createTxOffline } = require('./create-offline');
const { signTx } = require('./sign-offline');
const { dryRun } = require('./dry-run');

(async () => {
	// 1. Create an unsigned transaction
	const tx = createTxOffline();
	console.log("Unsigned Transaction: ", tx);

	// 2. Sign the transaction
	const signedTx = signTx(tx);
	console.log("Signed Transaction: ", signedTx);

	// 3. Perform a dry-run for the signed transaction
	const dryRunResult = await dryRun(signedTx)
	console.log("Dry-Run Result: ", dryRunResult);

	process.exit(0);
})();

An unsigned Transaction looks like this:

Unsigned Transaction
Unsigned Transaction:  {
  module: 'token',
  command: 'transfer',
  fee: 10000000n,
  nonce: 23n,
  senderPublicKey: <Buffer ec 10 25 5d 3e 78 b2 97 7f 04 e5 9e a9 af d3 e9 a2 ce 9a 6b 44 61 9e f9 f6 c4 7c 29 69 5b 1d f3>,
  params: {
    tokenID: <Buffer 00 00 00 01 00 00 00 00>,
    amount: 1000n,
    recipientAddress: <Buffer fa 89 2e 1a a4 2a 8a f9 6c 45 df d5 af c4 28 b3 db a9 50 e6>,
    data: 'Happy birthday!'
  },
  signatures: []
}

Values for the properties signatures and id are added to a transaction when it is signed by a user.

Signed Transaction
Signed Transaction:  {
  module: 'token',
  command: 'transfer',
  fee: 10000000n,
  nonce: 23n,
  senderPublicKey: <Buffer ec 10 25 5d 3e 78 b2 97 7f 04 e5 9e a9 af d3 e9 a2 ce 9a 6b 44 61 9e f9 f6 c4 7c 29 69 5b 1d f3>,
  params: {
    tokenID: <Buffer 00 00 00 01 00 00 00 00>,
    amount: 1000n,
    recipientAddress: <Buffer fa 89 2e 1a a4 2a 8a f9 6c 45 df d5 af c4 28 b3 db a9 50 e6>,
    data: 'Happy birthday!'
  },
  signatures: [
    <Buffer 64 08 a2 9d 7f 39 55 ed 5e 47 9f a6 90 b1 c2 61 8f 07 ab cc 70 bd 10 05 44 2f 89 b5 74 9f b7 b5 16 1d 73 db 79 9e ab e7 07 7e f5 40 bd e3 91 de 99 33 ... 14 more bytes>
  ],
  id: <Buffer ea 7e a3 a8 dd bf 9f 88 0a da eb 17 5a 47 d5 b8 bf 70 39 80 09 63 66 a7 be 7f 9d eb 01 43 73 b3>
}

If the dry-run result is 1, the transaction is valid.

Dry-Run Result
Dry-Run Result:  {
  result: 1,
  events: [
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e612036665651a0800000001000000002080ade2042800',
      index: 0,
      module: 'token',
      name: 'lock',
      topics: [Array],
      height: 2940
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e61214fa892e1aa42a8af96c45dfd5afc428b3dba950e61a08000000010000000020e8072800',
      index: 1,
      module: 'token',
      name: 'transfer',
      topics: [Array],
      height: 2940
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e612036665651a0800000001000000002080ade2042800',
      index: 2,
      module: 'token',
      name: 'unlock',
      topics: [Array],
      height: 2940
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e6121403cb3daae6009976ebac3b8935444bc3677b68821a08000000010000000020b0bed7042800',
      index: 3,
      module: 'token',
      name: 'transfer',
      topics: [Array],
      height: 2940
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e61208000000010000000018d0ee0a2000',
      index: 4,
      module: 'token',
      name: 'burn',
      topics: [Array],
      height: 2940
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e6121403cb3daae6009976ebac3b8935444bc3677b688218d0ee0a20b0bed704',
      index: 5,
      module: 'fee',
      name: 'generatorFeeProcessed',
      topics: [Array],
      height: 2940
    },
    {
      data: '0801',
      index: 6,
      module: 'token',
      name: 'commandExecutionResult',
      topics: [Array],
      height: 2940
    }
  ]
}