Data management for dApps built on Ternoa š«§
Igor Papandinas
Posted on December 28, 2022
Building a decentralized application involves to understand some additional concepts to classic javascript full-stack development. Data management from various inputs remains one of the most complex part. This article aims to provide some tips & tricks to handle data properly and make dApps development easier.
We will be using the Ternoa blockchain and its advanced NFT features as an example. First we will walk you through the process of on-chain data fetching and population using the Ternoa Indexer and IPFS. We will also cover the interaction with the Ternoa network through its SDK by creating, signing and submitting transactions. Parsing the response will keep the dApp up to date. Finally we will see how to subscribe to the chain to ensure real time data update.
By the end of this article, you should have a solid understanding of the concepts and tools needed to build your own decentralized NFT application on Ternoa. Let's get started! š„
TL;DR
Data lifecycle:
- Init: fetch data from the Ternoa Indexer
- (Optional) Populate data with off-chain metadata from IPFS
- Parse event data with the Ternoa SDK
- Update data subscribing to the chain
1. On-chain data fetching
A blockchain stores data in a very scattered manner, making it incredibly strenuous to query relevant data for a specific use case. There isn't a native feature to identify, sort and query linked data (spanning across multiple blocks). Ternoa Indexer resolves this issue scanning through each block and their events to see what happened on the Ternoa chain. It then parses all that data into custom entities and store them inside a database. Thus allowing one to leverage relevant data according to their specific search filters in a simplified manner.
Ternoa deploys its own indexer here on which you can submit GraphQL queries to retrieve on-chain data.
At some point during the development process you will need on-chain data. The good practice aims to use the Indexer to get a first set of data. Let's take an example: a query to fetch data for a specific NFT. You could use it to get the owner, ID, and other information for an NFT with a given id.
First we have to prepare the GraphQL query:
import { gql } from "graphql-request";
const prepareNFTQuery = (id) => gql`
{
nftEntity(id: "${id}") {
owner
nftId
offchainData
collectionId
royalty
isSoulbound
}
}
`;
The getNFT
function takes a single argument, id
, which is a number referring to the NFT id. It then returns a GraphQL query that requests data for a single NFT entity with a specific id. The fields being requested for the NFT entity include owner, nftId, offchainData, collectionId, royalty, and isSoulbound.
Then we can submit the query to the Indexer:
import request from "graphql-request";
const getNFT = async (id) => {
try {
const gql = prepareNFTQuery(id);
return (await request("https://indexer-mainnet.ternoa.dev", gql)).nftEntity;
} catch (err) {
throw new Error(
`NFT ${id} could not have been fetched - Detail: ${
err?.message ? err.message : JSON.stringify(err)
}`
);
}
};
The query is sent to the server using the request function from graphql-request
, passing in the URL of the Ternoa Indexer and the prepared query. The response from the server is destructured to obtain the nftEntity field, which is then returned as the resolved value of the promise containing the related on-chain data.
If an error occurs while making the request or parsing the response, the function will throw an error with a message that includes the id of the NFT and the error details.
2. Populate data with off-chain metadata
An NFT is a unique ID. On-chain data contains this ID couple with additional important information like ownership, royalties and more. However medias and metadata are stored off-chain for performance and flexibility. At Ternoa the media associated with NFTs, and other associated metadata, are stored off-chain in a decentralized storage network: IPFS, Interplanetary File Systems. We just learned how to fetch on-chain data for a specific NFT. Now let's populate the previous data with off-chain metadata retrieved from IPFS.
import { TernoaIPFS } from "ternoa-js";
const loadNftMetadata = async (offchainData) => {
const ipfsClient = new TernoaIPFS(new URL("https://ipfs-mainnet.trnnfr.com"));
try {
return await ipfsClient.getFile(offchainData);
} catch (error) {
console.log(error);
}
};
The function first creates an instance of TernoaIPFS
, passing in the URL of the Ternoa IPFS node. Then, it uses the getFile
method of the ipfs client instance to retrieve the file for the specified IPFS hash. The JSON file retrieved contains fields for the NFT's title, description, image, and properties.
We can now add this function to the previous getNFT
one to extend the data associated with an NFT:
import request from "graphql-request";
const getNFT = async (id) => {
try {
const gql = prepareNFTQuery(id);
const res = (await request("https://indexer-mainnet.ternoa.dev", gql)).nftEntity;
const ipfsRes = await loadNftMetadata(res.offchainData)
return {...res, ...ipfsRes};
} catch (err) {
throw new Error(
`NFT ${id} could not have been fetched - Detail: ${
err?.message ? err.message : JSON.stringify(err)
}`
);
}
};
3. Event data parsing
The Indexer remains the best tools to get on-chain data when the application is first loaded. However when you submit transactions from your dApp, data is not immediatly synchronized on the Indexer. You should expect a 18 seconds lag the time that this one parse the latest blocks. Event data parsing becomes essential to keep your data up to date.
Let's take as an example: the creation of a rental contract for our NFT. Using Ternoa's SDK this would result in the following code:
import {
createContractTx,
formatAcceptanceType,
formatCancellationFee,
formatDuration,
formatRentFee,
getRawApi,
query,
submitTxBlocking,
ContractCreatedEvent,
} from "ternoa-js";
/**
* Creates a contract and returns the resulting `ContractCreatedEvent`.
*/
const createContract = async (id: number, address: string) => {
const duration = formatDuration("fixed", 20);
const acceptanceType = formatAcceptanceType("auto");
const rentFee = formatRentFee("tokens", 1);
const cancellationFee = formatCancellationFee("none");
const tx = await createContractTx(
id,
duration,
acceptanceType,
false,
rentFee,
cancellationFee,
cancellationFee
);
const signedTx = await signTx(tx, address);
const { events } = await submitTxBlocking(signedTx, WaitUntil.BlockInclusion);
return events.findEventOrThrow(ContractCreatedEvent)
};
/**
* Signs a transaction using a nonce and a signer (depends of the wallet used).
*/
const signTx = async (txHex: `0x${string}`, address: string, signer: any) => {
const api = getRawApi();
const nonce = (
(await query("system", "account", [address])) as any
).nonce.toNumber();
return (await api.tx(txHex).signAsync(address, { nonce, signer })).toHex();
};
First the code creates a transaction to create the contract using the createContractTx
function, passing the NFT ID, a flag indicating whether the contract can be revoke by the renter and the required formatted arguments: duration, acceptance type, rent fee, and cancellation fee.
Then, it signs the transaction using the signTx
function that uses the getRawApi
function from ternoa-js
to get an API instance connected to the blockchain, and then uses this object to sign the transaction with a nonce (a number that is incremented for each new transaction made by an account) and a signer (an object that provides a way to sign the transaction).
Finally, the createContract
function submits the signed transaction using the submitTxBlocking
function from ternoa-js
, which submits the transaction and waits until it is included in a block. After the transaction is submitted, the function returns the ContractCreatedEvent event, which is an event that is emitted when a rental contract for an NFT is created.
The data contained inside the ContractCreatedEvent event can be parsed and used to update states or database tables directly without waiting for the Indexer to be fully synchronized. We can extract the duration
and the renter
to extend our NFT data like this:
...
const nftData = await getNFT(2000)
const { duration, renter } = await createContract(2000, SIGNER_ADDRESS)
const nft = {
...nftData,
rentalContract: {
duration: duration.fixed,
renter,
};
}
4. Current block subscription
Sometimes we need to track some on-chain data without reloading the application. This is particularly true for features having a defined block duration. For example, for fixed rental contracts the contract ending block can be known using the contract duration (retrieved on contract creation) and the contract starting block:
...
import {
rentTx,
submitTxBlocking,
ContractStartedEvent,
} from "ternoa-js";
export const rentNft = async (id: number, address: string) => {
const tx = await rentTx(id);
const signedTx = await signTx(tx, address);
const { blockInfo, events } = await submitTxBlocking(signedTx, WaitUntil.BlockInclusion);
const startBlock = blockInfo.block?.header.number.toNumber()
const rentEvent = events.findEventOrThrow(ContractStartedEvent)
return {...rentEvent, startBlock}
};
and then:
...
const contractDuration = nftData.rentalContract.duration
const { rentee, startBlock } = await rentNft(2000, SIGNER_ADDRESS)
const nft = {
...nftData,
rentalContract: {
...nftData.rentalContract,
endBlock: startBlock + contractDuration,
hasStarted: true,
rentee,
};
}
Since a new block is created each 6 seconds, we can fetch data from the Indexer periodically. However this approach is cumbersome to handle for simple updates. A better one can be to open a subscription on new blocks creation by getting the block numbers. This can be achieve with the following code:
import { getRawApi } from "ternoa-js";
export const subscribeCurrentBlockNumber = async (
setFunction: (n: number) => void
): Promise<any> => {
try {
const api = getRawApi();
const unsub = await api.rpc.chain.subscribeNewHeads((header) => {
setFunction(header.number.toNumber());
});
return unsub;
} catch (err) {
console.log(err);
}
};
This code creates a subscription to updates on the current block number in the blockchain, using the Ternoa SDK. The subscribeNewHeads
method is called with a callback function that will be executed every time a new block header is received. The callback function updates the state of some external component by calling the setFunction
passed as an argument to subscribeCurrentBlockNumber
with the block number. The unsub
constant holds a function that can be used to unsubscribe from the subscription.
Once the current block is greater or equal to the contract end block, the contract is finished and we can update our local data accordingly.
Conclusion
Data can be fetched, populated, parsed or updated. This article explained how to handle it from various inputs: blockchain, IPFS, Indexer & SDK.
š Congrats folks, you are now ready to build your dApp on Ternoa with all those tips š¦¾
Posted on December 28, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.