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 00000000 --nonce 0 --offline
? Please enter: tokenID:  0000000000000000
? Please enter: amount:  1000000000
? Please enter: recipientAddress: lsk44ejwrjb4g44hj9mbtc43h8bcu54g2gbbyyvrk
? 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":"0802100018022080c2d72f2a20e03c09bdc8c023d94cf66a5d352e6258380210d97d545abbf75668ea3736e3123229088094ebdc031214ab0041a7d3f7b2c290b5b834d46bdc7b7eb858151a0b73656e6420746f6b656e733a40faa2626d7306506b1999f48aa2f4b1ffdee01e641fa76d37a9d1d6fd8c225a81065c856ea625c52d138a7e3ba86b62913dc8e5aef8b5e307641ab66e0277a60b"}
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 00000000 --nonce 0 --offline --json

This creates a response as seen below:

{
  "transaction": "0802100018022080c2d72f2a20e03c09bdc8c023d94cf66a5d352e6258380210d97d545abbf75668ea3736e3123229088094ebdc031214ab0041a7d3f7b2c290b5b834d46bdc7b7eb858151a0b73656e6420746f6b656e733a40faa2626d7306506b1999f48aa2f4b1ffdee01e641fa76d37a9d1d6fd8c225a81065c856ea625c52d138a7e3ba86b62913dc8e5aef8b5e307641ab66e0277a60b"
}
{
	"transaction": {
		"module": "token",
		"command": "transfer",
		"fee": "10000000",
		"nonce": "0",
		"senderPublicKey": "a3f96c50d0446220ef2f98240898515cbba8155730679ca35326d98dcfb680f0",
		"signatures": [
			"eee00368b7933b6bd06f7ba410261749197b800fc79a816ad15cb3225af6e48cc56c5af1961d6865b8c1ccf9466997e55e8edcf2681c3161a307270bd3d9b800"
		],
		"params": {
			"tokenID": "0400000000000000",
			"amount": "10000000",
			"recipientAddress": "lskzbqjmwmd32sx8ya56saa4gk7tkco953btm24t8",
			"data": "Hey there"
		},
		"id": "1da484cd297c7654b111987708220e53cb29535ae94276a2fe1486dcfee7e31d"
	}
}

Option 2: Creating transactions offline with Lisk Elements

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,
    },
  },
};

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.

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

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

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 Transfer Tokens 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
	const transactionErrors = validator.validator.validate(transactionSchema, unsignedTransaction);

	if (transactionErrors && transactionErrors.length) {
		throw new validator.LiskValidationError([...transactionErrors]);
	}

	// Create the asset for the Token Transfer transaction
	const transferParams = {
		tokenID: Buffer.from('0000000000000000','hex'),
		amount: BigInt(2000000000),
		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 }

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 which will sign a given unsigned transaction object.

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

  1. The unsigned transaction

  2. The chain ID

  3. The privateKey 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 = '00000000';

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');

// Adjust the RPC_ENDPOINT to point to your RPC node, if required.
const RPC_ENDPOINT = 'ws://localhost: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
{
  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 00 00 00 00 00>,
    amount: 2000000000n,
    recipientAddress: <Buffer 85 c1 2d 39 04 1b c0 9e 1f 89 df ef fe 4b 87 cf cf e7 9f b2>,
    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
{
  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 00 00 00 00 00>,
    amount: 2000000000n,
    recipientAddress: <Buffer 85 c1 2d 39 04 1b c0 9e 1f 89 df ef fe 4b 87 cf cf e7 9f b2>,
    data: 'Happy birthday!'
  },
  signatures: [
    <Buffer cd 28 ce 82 43 67 7b 16 a5 b9 f1 9b 6d 71 58 c0 bb b2 c9 9f 11 dc 25 34 96 d2 6a 14 d4 1c a6 03 d0 ab 49 67 4a 1c ee df aa a7 36 8e d0 1b a3 fa e2 81 ... 14 more bytes>
  ],
  id: <Buffer be 19 fd 1d e0 29 d0 78 e8 03 bb fb b0 8e bc 70 d1 34 e8 c1 d1 37 1f 0a 65 b5 7b 3d 76 9a 45 37>
}

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

Dry-Run Result
{
  result: 0,
  events: [
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e612036665651a0803000008000000002080ade2042800',
      index: 0,
      module: 'token',
      name: 'lock',
      topics: [Array],
      height: 12636
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e612036665651a0803000008000000002080ade2042800',
      index: 1,
      module: 'token',
      name: 'unlock',
      topics: [Array],
      height: 12636
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e61208030000080000000018c89cbc022000',
      index: 2,
      module: 'token',
      name: 'burn',
      topics: [Array],
      height: 12636
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e61214821a6aa707422e6d444b42afb2a303d014d620221a08030000080000000020b890a6022800',
      index: 3,
      module: 'token',
      name: 'transfer',
      topics: [Array],
      height: 12636
    },
    {
      data: '0a14fa892e1aa42a8af96c45dfd5afc428b3dba950e61214821a6aa707422e6d444b42afb2a303d014d6202218c89cbc0220b890a602',
      index: 4,
      module: 'fee',
      name: 'generatorFeeProcessed',
      topics: [Array],
      height: 12636
    },
    {
      data: '0800',
      index: 5,
      module: 'token',
      name: 'commandExecutionResult',
      topics: [Array],
      height: 12636
    }
  ]
}