Building with Native API

The most optimal way to interact with the Native API on The Root Network is via @polkadot/api package in combination with our package @therootnetwork/api. It provides web3 developers the ability to query and interact with The Root Network node using Javascript/Typescript. For installation instructions, see Install Native API.

One of the most important things to understand about the @polkadot/api is that most interfaces are actually generated automatically when connected to a running node. This is quite a departure from other APIs in projects with static interfaces. While it may sound intimidating, this concept is actually a powerful feature present in any Substrate chain. It enables the API to be used in environments where the chain is customized.

To unpack this, we will start with the metadata and explain what it actually provides, since it is critical to understand how to interact with the API and any underlying chain.

Metadata

When the API connects to a node, one of the first things it does is retrieve the metadata and decorate the API based on the metadata information. The metadata effectively provides data in the form of api.<type>.<module>.<section> that fits into one of the following categories:

  • consts - All runtime constants, e.g. api.consts.balances.existentialDeposit. These are not functions; rather, accessing the endpoint immediately yields the result as defined.

  • query - All chain state, e.g. api.query.system.account(<accountId>).

  • tx - All extrinsics, e.g. api.tx.balances.transfer(<accountId>, <value>).

Additionally, the metadata also provides information on events. These are queryable via the api.query.system.events() interface and also appear on transactions. Both these cases are detailed later.

None of the information contained within the api.{consts, query, tx}.<module>.<method> endpoints are hardcoded in the API. Rather, everything is fully decorated by what the metadata exposes and is, therefore, completely dynamic. This means that when you connect to different chains, the metadata and API decoration will change and the API interfaces will reflect what is available on the chain you are connected to.

Runtime Types

Everything the API returns is a type with a consistent interface: Codec. This means that a Vec<u32> (an array of u32 values), as well as a Struct (an object) or an Enum, has the same consistent base interface. Specific types will have values based on the type - decorated and available.

As a minimum, anything returned by the API, be it a Vec<...>, Option<...>, Struct or any normal type will always have the following methods - as defined on the Codec interface:

  • .eq(<other value>) - checks for equality against the other value. In all cases, it will accept "like" values, i.e. in the case of a number, you can pass a primitive (such as 1), a hex value (such as 0x01) or even a Unit8Array

  • toHex() - returns a hex-base representation of the value, always prefixed by 0x

  • toHuman() - returns Human-parsable JSON structure with values formatted as per the settings

  • toJSON() - returns a JSON-like representation of the value. This is generally used when calling JSON.stringify(...) on the value

  • toString() - returns a string representation. In some cases, this performs additional encoding, i.e. for Address, AccountId and AccountIndex, it will encode to the ss58 address

  • .toU8a() - returns a Uint8Array representation of the encoded value (generally exactly as passed to the node, where values are SCALE encoded)

Additionally, the following getters and utilities are available:

  • .isEmpty - true if the value is an all-empty value, i.e. 0 in for numbers, all-zero for Arrays (or anything Uint8Array), false is non-zero

  • .hash - a Hash (once again with all the methods above) that is a blake2-256 representation of the contained value

Chain Defaults

In addition to the api.[consts | query | tx] detailed above, the API, upon connecting to a chain, fills in some information and makes it available directly on the API interface. These include -

  • api.genesisHash - The genesisHash of the connected chain

  • api.runtimeMetadata - The metadata as retrieved from the chain

  • api.runtimeVersion - The chain runtime version (including specification/implementation versions and types)

Chain State

The api.rpc.<section>.<method> provides low-level interfaces to read the chain state that's not necessarily bound to the pallet storage. It's also the commonplace to add additional data query for the custom pallets, e.g. api.rpc.tokenUri.

// Initialize the API as in previous sections
...

// Retrieve the chain name
const chain = await api.rpc.system.chain();

// Retrieve the latest header
const lastHeader = await api.rpc.chain.getHeader();

// Log the information
console.log(`${chain}: last block #${lastHeader.number} has hash ${lastHeader.hash}`);

It's possible to subscribe to RPC calls and receive up-to-date data. For instance, subscribe to a new block height change.

...
let count = 0;

// Subscribe to the new headers
const unsubscribe = await api.rpc.chain.subscribeNewHeads((lastHeader) => {
  console.log(`${chain}: last block #${lastHeader.number} has hash ${lastHeader.hash}`);

  if (++count === 10) {
    unsubscribe();
  }
});

Since we are dealing with a subscription, we now pass a callback into the subscribeNewHeads function, and this will be triggered on each header as they are imported. The same pattern applies to each of the api.rpc.subscribe functions. As the last parameter, a callback should be provided to stream the latest data as it becomes available.

In general, whenever we create a subscription, we would like to clean up after ourselves and unsubscribe. Assuming we only want to log the first 10 headers, the above example demonstrates the use of the unsubscribe() function.

Chain Storage

The api.query.<pallet>.<method> interfaces are used to read the public storage in each available pallet. Behind the scenes, the API uses the metadata information provided to construct queries based on the location and parameters provided to generate state keys and then queries these via RPC.

// Initialize the API as in previous sections
...

// The actual address that we will use
const ADDR = '0xE04CC55ebEE1cBCE552f250e85c57B70B2E2625b';

const [now, { nonce, data: balance }] = await Promise.all([
  api.query.timestamp.now(),
  api.query.system.account(ADDR)
]);

console.log(`${now}: balance of ${balance.free} and a nonce of ${nonce}`);

Similar to RPC calls, sometimes it's necessary to subscribe to the chain state to handle your application logic. Fortunately, the API provides a straightforward way to do that.

...

// Retrieve the current timestamp via subscription
const unsubscribe = await api.query.timestamp.now((moment) => {
  console.log(`The last block has a timestamp of ${moment}`);

  // unsubscribe(); call to stop receiving updates
});

Instead of the await returning the actual one-time value, it returns a subscription unsubscribe() function. This function can be used to stop the subscription and clean up any underlying RPC connections. The supplied callback will contain the value streamed from the node as it changes.

Transaction

Transaction endpoints are exposed, as determined by the metadata, on the api.tx.<pallet>.<method> endpoint. These allow you to submit transactions for inclusion in blocks, whether it's transfers, setting information or any other operation your chain supports.

The Root Network supports the ECDSA key-pair standard. To submit a transaction, make sure you have a valid account with a small amount of XRP tokens to cover the transaction fee. Create a keyring instance as follows:

import { Keyring } from "@polkadot/keyring";
import { stringToU8a, u8aToHex, hexToU8a } from "@polkadot/util";

// create a keyring with some non-default values specified
const keyring = new Keyring({ type: "ethereum" });
const seedU8a = hexToU8a(ALICE_PRIVATE_KEY);
const alice = keyring.addFromSeed(seedU8a);

Once that is done, to transfer a balance from Alice to Bob as per the example above, use the code below:

...
// Sign and send a transfer from Alice to Bob
const txHash = await api.tx.balances
  .transfer(BOB, 12345)
  .signAndSend(alice);

// Show the hash
console.log(`Submitted with hash ${txHash}`);

The result of this call is the transaction hash. This is a hash of the data; receiving it does not mean the transaction has been included. It only means that the hash has been accepted for propagation by the node.

Caution: Transaction hash in Substrate-based blockchain is generally unique within a block but not across the chain. Check out this article for more details.

Similar to api.rpc and api.query interfaces, it's possible to subscribe to the transaction state change to determine if it's been included in the block or not.

...
// Make a transfer from Alice to Bob, waiting for inclusion
const unsubscribe = await api.tx.balances
  .transfer(BOB, 12345)
  .signAndSend(alice, (result) => {
    console.log(`Current status is ${result.status.type}`);

    if (result.status.isInBlock) {
      console.log(`Transaction included at blockHash ${result.status.asInBlock}`);
    } else if (result.status.isFinalized) {
      console.log(`Transaction finalized at blockHash ${result.status.asFinalized}`);
      unsubscribe();
    }
  });

As per all previous subscriptions, the transaction subscription returns unsubscribe() and the actual method has a subscription callback. The result object has two parts: events (to be covered in the next section) and the status enum.

When the status enum is in Finalized state (checked via isFinalized), the underlying value contains the block's hash where the transaction has been finalized. Finalized will follow InBlock, which is the block where the transaction has been included. InBlock does not mean the block is finalized, but rather applies to the transaction state, where Finalized means that the transaction cannot be forked off the chain.

Transaction Events

Any transaction will emit events. As a bare minimum, this will always be either a system.ExtrinsicSuccess or system.ExtrinsicFailed event for the specific transaction. These provide the overall execution result for the transaction, i.e. execution has succeeded or failed.

Depending on the transaction sent, some other events may be emitted. For instance, for a balances.transfer this could include one or more of Transfer, NewAccount or ReapedAccount, as defined in the substrate balances event defaults.

To display or act on these events, we can do the following:

...
// Make a transfer from Alice to BOB, waiting for inclusion
const unsubscribe = await api.tx.balances
  .transfer(BOB, 12345)
  .signAndSend(alice, ({ events = [], status, txHash }) => {
    console.log(`Current status is ${status.type}`);

    if (status.isFinalized) {
      console.log(`Transaction included at blockHash ${status.asFinalized}`);
      console.log(`Transaction hash ${txHash.toHex()}`);

      // Loop through Vec<EventRecord> to display all events
      events.forEach(({ phase, event: { data, method, section } }) => {
        console.log(`\t' ${phase}: ${section}.${method}:: ${data}`);
      });

      unsubscribe();
    }
  });

Caution: Be aware that when a transaction status is isFinalized, it means it is included, but it may still have failed - for instance, if you try to send a larger amount that you have free, the transaction is included in a block. However, from an end-user perspective, the transaction failed since the transfer did not occur. In these cases, a system.ExtrinsicFailed event will be available in the events array.

Payment Information

The RPC endpoints expose weight/payment information that takes an encoded extrinsic and calculates the on-chain weight fees for it. A wrapper for this is available on the tx itself, taking exactly the same parameters as you would pass to a normal .signAndSend operation, specifically .paymentInfo(sender, <any options>). To expand on our previous example:

// construct a transaction
const transfer = api.tx.balances.transfer(BOB, 12345);

// retrieve the payment info
const { partialFee, weight } = await transfer.paymentInfo(alice);

console.log(`transaction will have a weight of ${weight}, with ${partialFee.toHuman()} weight fees`);

// send the tx
transfer.signAndSend(alice, ({ events = [], status }) => { ... });

Last updated

© 2023 -> ♾️