Staking: Profit Sharing Pool

In this guide, you will discover how to quickly deploy a new staking platform and run a staking pool for a real-world business use-case , that generates profit and distributes it to investors. Usually, investors must acquire a share of the business to be eligible for dividends.

The staking pool acts as a dividend distribution point, where investors stake ERC-20 fractions to
receive rewards in cryptocurrency. ERC-20 fractions act as shares, where each share is a percentage of the total asset, allowing multiple buyers to own a portion of the business proportional to their investment.

Before proceeding, you must first issue a digital twin NFT representing a real-world asset via the Issuance API. Then, fractionalize the NFT into ERC-20 fractions using the Fraction API. Issuance and fractionalization are beyond the scope of this material.

📘

Note

This guide relies on the viem library, but you are free to use any other Web3 provider such as web3.js or ethers.js.

Preface

There are three entities involved in the process:

  1. Admin - Deploys a staking platform.
  2. Issuer - Deploys a staking pool.
  3. Investor - Stakes, claims rewards and unstakes its position.

Below is a simplified diagram outlining the entire process, along with the key actions of each entity:

  1. Admin deploys a platform.
  2. Issuer sends a request to the staking platform to create a new staking pool.
  3. The staking platform processes the request and deploys a staking pool.
  4. Investor stakes the ERC-20 fractions.
  5. Issuer transfers rewards to the staking pool for further distribution.
  6. Investor claims the distributed rewards.

The end result of this guide is a deployed staking platform, a staking pool, and the interaction of an Investor with the staking pool, including staking, claiming rewards, and unstaking.

Imports

Create a new typescript project, use the following imports and install the required dependencies.

import { api, handleApiResponse, getEnv, ApiResponse, Transaction, ApiClient} from "@evergonlabs/tmi-protocol-api-client";  
import { Address, createWalletClient, Hash, http, writeContract} from "viem";  
import { privateKeyToAccount } from "viem/accounts";  
import { sepolia } from "viem/chains";
import { erc20Abi } from 'viem';
ImportDescription
"evergonlabs/tmi-protocol-api-client"This library is required to construct data objects that are passed as input when executing transactions via the viem.writeContract function and to handle the API responses.
"viem"The viem library is used to create the wallet client, which is then used to send transactions to deploy a platform, pool, distribute rewards, stake, claim rewards, and unstake.
"viem/accounts"The privateKeyToAccount import is used when defining a wallet client via the createWalletClient call.
"sepolia"This import is required because the platform will be deployed to the Sepolia testnet.
erc20AbiThe ERC-20 ABI interface is used to approve the Issuer balance spending for rewards distribution and to approve the Staker balance spending for staking, unstaking, claiming rewards.

Defining the API client

The @evergonlab/tmi-protocol-api-client library uses an API client to send transactions. The env property sets the environment to either "test" or "prod". Begin by defining the API client in your code:

  const apiClient = api.createClient({
    env: getEnv("test"),
  });

Deploying a staking platform

To begin the deployment you must first create the Admin client by using the createWalletClient function:

const adminClient = createWalletClient({
  account: privateKeyToAccount("0xffffffffffffffffffffffffffffffffffffffff"),
  chain: sepolia,
  transport: http("https://your-provider.com/api-key"),
});
PropertyDescription
accountSpecify your private key using the privateKeyToAccount function.
chainSet the chain property to sepolia.
transportPass your RPC provider URL to the transport property to interact with the EVM network (e.g., Alchemy ).

🚧

Important

The process of creating Admin, Issuer, and Investor wallet accounts is identical across this material. As a result, the descriptions of parameters and similar input values apply to all wallet instances.

Define the sendTransaction function that will be responsible for sending transactions using the writeContract function of the viem library.

The transaction argument expects a Transaction type which must be a data object containing transaction details. The viemClient argument expects a client instance created using the createWalletClient function.

The returned Hash is used throughout this guide to get the transaction response.

async function sendTransaction(viemClient:any, transaction: Transaction): Promise<Hash> {
  return await viemClient.writeContract({
    args: transaction.details.args,
    functionName: transaction.details.functionName,
    abi: transaction.details.abi,
    address: transaction.details.address as Address,
  });
}

Define the deployPlatform function that will:

  1. Create a data object for the transaction using the await api.stakingTemplates.rwa.createPlatform call.
  2. Pass the data object to the sendTransaction function to deploy the staking platform.
  3. Handle the returned transaction hash using the handleApiResponse function.
  4. Return the platformDetails object, which contains the address of the staking platform to be used in subsequent function calls.

async function deployPlatform(apiClient: ApiClient) {
    const transactionData = handleApiResponse(
      await api.stakingTemplates.rwa.createPlatform({
        client: apiClient,
        body: {
          // pay attention: we are using string literals here
          chainId: `${sepolia.id}`,
          isSoulbound: true,
          adminAddress: adminClient.account.address,
          erc721: {
            symbol: "TT",
            name: "Test",
            baseUri: "ipfs://QmYwAPJzv5CZsnAzt8QvV6pMMN4HgfMNXYxa9avYo5bFwK",
          },
        },
      }),
    );
    const platformDetails = handleApiResponse(
      await api.stakingTemplates.rwa.getPlatformDeployEvent({
        query: {
          chainId: `${sepolia.id}`,
          hash: await sendTransaction(adminClient, transactionData),
        },
      }),
    );
    return platformDetails;
  }
PropertyDescription
clientExpects an instance of the api.createClient to make API calls.
chainIdSpecify the chain id using the imported sepolia.
adminAddressRepresents an account address that must be granted the 'Admin' role for the staking platform.
erc721Initializes the staking position token collection, including the collection name, symbol, and base URL. Each time someone stakes, the corresponding NFT will be issued to the user’s account as confirmation of the staking position.
isSoulboundDetermines if the minted NFT will be non-transferable and tied to one address.

Deploy the platform by passing the apiClient to the deployPlatform function call and store the result in the platformReceipt variable. This will provide the address of the staking platform for use in subsequent calls.

const platformReceipt = await deployPlatform(apiClient)

📘

Roles

When deploying, the Admin entity becomes the owner of the platform, with the 'Admin' role assigned automatically. Only one 'Admin' is allowed per platform.

The Admin entity can then grant the following roles to other entities: : 'Creator', 'Open', and 'Staker'.

'Creator'pool - Grants permission to deploy a staking pool.
'Open' - Provides access to all accounts interacting with the platform.
'Staker' - Enables staking capabilities for an account.

At this stage, we have successfully deployed the staking platform. The address of the platform can be obtained via the diamondAddress property:

const diamondAddress = platformReceipt.diamondAddress

Deploying a staking pool

First, to deploy a staking pool, you must create the wallet client for the Issuer entity:

const issuerClient = createWalletClient({
  account: privateKeyToAccount("0xffffffffffffffffffffffffffffffffffffffff"),
  chain: sepolia,
  transport: http("https://your-provider.com/api-key"),
});

To allow Issuer deploying a new staking pool, the Admin entity must grant the 'Creator' role.

Define the grantCreatorRole function, which generates a new transaction object using the grantRole function, and then sends it via the sendTransaction invocation.

async function grantCreatorRole(viemClient: any, 
                                apiClient: ApiClient,
                                diamondAddress: string): Promise<Hash>{
    const transactionData = handleApiResponse(
        await api.staking.roles.grantRole({
            client: apiClient,
            body: {
                platform: diamondAddress,
                addresses: [issuerClient.account.address],
                role: 'Creator'
            }
        })
    )

    const transactionResult = await sendTransaction(viemClient, transactionData)

    return transactionResult
}
PropertyDescription
clientExpects an instance of the api.createClient to make API calls.
platformSet the address of the platform by specifying the diamondAddress variable.
addressesSpecify the Issuer address in the array that must be granted with the 'Creator' role.
roleSelect the role to grant. For this code example, set it to 'Creator'.

Pass the issuerClient, apiClient and diamondAddress arguments to the grantCreatorRole function call:

const roleReceipt = await grantCreatorRole(issuerClient, apiClient, diamondAddress)

After granting the 'Creator' role to the Issuer you can begin deploying the staking pool.

Define the deployPool function with viemClient, apiClient, and diamondAddress as arguments to create a new data object for a transaction and send it using the sendTransaction function:

async function deployPool(viemClient: any, apiClient: ApiClient, diamondAddress: string ): Promise<Hash> {
    const transactionData = handleApiResponse(
        await api.stakingTemplates.rwa.createPool({
        client: apiClient,
        body: {
            platform: diamondAddress,
            startDate: new Date(),
            hardcapAmount: "100000",
            distribution: {
            duration: "3600",
            distributor: issuerClient.account.address,
            },
            erc20Input: {
            contract: "0xffffffffffffffffffffffffffffffffffffffff",
            amountOfTokensPerPacket: "20",
            },
            erc20Reward: {
            contract: "0xffffffffffffffffffffffffffffffffffffffff",
            amountOfTokensPerPacket: "5",
            },
        },
        })
    );

    const transactionResult = await sendTransaction(viemClient, transactionData)

    return transactionResult
}
PropertyDescription
platformSpecifies the address of the platform, represented by the diamondAddress variable.
startDateDefines the time when a pool begins. The pool can start at a specified future time, if provided. Must be of the Date type.
hardcapAmountSpecifies the maximum number of packets a pool can accept from users.
durationDefines the duration in seconds after which rewards are distributed.
distributionAn object containing the details of reward distribution.
distributorAn account address responsible for distributing rewards.
erc20InputAn object containing details about the input ERC-20 fractions that users must stake in the pool.
contractSpecifies the smart contract address of the ERC-20 asset used as input for the pool.
amountOfTokensPerPacketDefines the number of tokens required to form one input packet.
erc20RewardAn object containing details about the ERC-20 reward asset that users will receive.
contractSpecifies the smart contract address whose tokens are used as rewards in the pool.
amountOfTokensPerPacketDefines the number of reward tokens required to form one reward packet.

Pass the issuerClient, apiClient and diamondAddress arguments to the deployPool function call to create a new staking pool.

const poolHash = await deployPool(issuerClient, apiClient, diamondAddress)

🚧

Important

The result of this operation is a transaction hash, which is assigned to the poolHash variable. This hash is necessary to obtain the unique pool identifier, poolId, and is required for all entities that will interact with the staking pool later in this tutorial.

To obtain the poolId, parse the transaction hash using the getCreatePoolEvent function. The result will then be properly parsed for further use with the handleApiResponse function.

const poolDetails = handleApiResponse(
    await api.stakingTemplates.rwa.getCreatePoolEvent({
      query: {
        chainId: `${sepolia.id}`,
        hash: poolHash
      },
    }),
  );

For ease of use, we create a new poolId variable and assign to it the value obtained from the poolDetails:

const poolId = poolDetails.poolId

At this point, the staking pool has been successfully deployed, and the next steps will involve the Investor interacting with it.

Staking

To begin staking ERC-20 fractions, you must create the wallet client for the Investor entity:

const investorClient = createWalletClient({
    account: privateKeyToAccount("0xffffffffffffffffffffffffffffffffffffffff"),
    chain: sepolia,
    transport: http("https://your-provider.com/api-key"),
  });

To enable Investor interacting with the staking platform, Issuer must grant the 'Staker' role to it.

Define the grantStakerRole function whose invocation will rely on the investorClient wallet data, apiClient and the address(diamondAddress), of the staking platform.

The result of this operation is optional:

async function grantStakerRole(viemClient: any, apiClient: ApiClient, diamondAddress: string): Promise<Hash>{
    const transactionData = handleApiResponse(
        await api.staking.roles.grantRole({
            client: apiClient,
            body: {
                platform: diamondAddress,
                addresses: [investorClient.account.address],
                role: 'Staker'
            }
        })
    )

    const transactionResult = await sendTransaction(viemClient, transactionData)

    return transactionResult
}

Pass the investorClient, apiClient and diamondAddress arguments to the grantCreatorRole function call. The successful invocation grants the Investor the 'Staker' role.

const stakerRoleReceipt = await grantStakerRole(investorClient, apiClient, diamondAddress)

After successfully granting the role, the Investor must approve the staking platform to allow spending a specific amount of ERC-20 fractions intended for staking.

Define the approveERC20Fractions function that will execute the approval by using the writeContract function which was imported from the viem library:

async function approveERC20Fractions(viemClient: any, spender:any, 
                                     amount: number, tokenAddress: string): Promise<Hash> {
      
  const txHash = await viemClient.writeContract({
        address: tokenAddress,
        abi: erc20Abi,
        functionName: 'approve',
        args: [spender, amount],
      });

      return txHash
}
PropertyDescription
addressThe address of the smart contract whose ERC-20 tokens are used as input in the staking pool.
abiRefers to a predefined set of Application Binary Interfaces (ABIs) for interacting with ERC-20 tokens. This function depends on the erc20Abi import from the viem library.
functionNameThe name of the function to call on the smart contract; in this case, we call the 'approve' function on the smart contract defined in the address property.
argsData used in the invocation of the 'approve' function. It expects the spender and amount arguments.
spenderSpecifies the address of the staking platform that will spend the amount of ERC-20 tokens.
amountDetermines the amount of ERC-20 tokens to be approved for spending on the staking operation.

Invoke the approveERC20Fractions function passing all required arguments:

const amount = 50000

const tokenAddress = '0xffffffffffffffffffffffffffffffffffffffff'

const investorApproveHash = approveERC20Fractions(investorClient, diamondAddress, 
                                         amount, tokenAddress)

After the successful approval, you can begin staking the ERC-20 fractions.

Define the stakeFractions function, which will utilize the stake function from the @evergonlabs library. This function will create the transaction data object and send it via sendTransaction.

async function stakeFractions(viemClient: any, apiClient: ApiClient, 
                                diamondAddress: string, poolId: string,
                                amount: string): Promise<Hash>{
    
        const transactionData = handleApiResponse(
        await api.stakingTemplates.rwa.stake({
            client: apiClient,
            body: {
                platform: diamondAddress,
                poolId: poolId,
                amount: amount
            }
        })
    )

    const transactionResult = await sendTransaction(viemClient, transactionData)

    return transactionResult
}
PropertyDescription
platformThe address of the staking platform.
poolIdThe unique identifier of the pool where tokens are staked.
amountThe amount of tokens to be staked.

Note that when executing the stakeFractions function transaction we pass the amountPackets variable specifying the number of packets to stake.

The amountPackets value ('2500') differs from amount ('50000') because, when deploying the staking pool, we set the amountOfTokensPerPacket variable to "20". This means that to stake '50000' tokens, we need to create 2500 packets. Therefore, we specify 2500 as the input value to indicate how many packets should be staked.

const amountPackets = '2500'

const stakeReceipt = await stakeFractions(investorClient, apiClient,
																				  diamondAddress, poolId, amountPackets)

After invoking the function, the Investor successfully stakes 2500 packets, or 50000 tokens, in other words. This action triggers the staking platform to issue a staking position NFT, which is used by an account to confirm its ownership of the stake in the pool.

🚧

Important

The staking position NFT data is mandatory to unstake, restake, and claim rewards. You need to obtain it by parsing the hash of the stake operation

Parse the hash assigned to the stakeReceipt variable by calling the getStakeEvent function:

const stakeData = await api.stakingTemplates.rwa.getStakeEvent({
    query: {
        chainId: `${sepolia.id}`,
        hash: stakeReceipt
    }
})

Then assign the unique identifier of the staking position to the stakeId variable:

const stakeId = stakeData.data!.stakeId

Now you can use the stakeId as an input for the unstake, restake, and claimRewards functions to manage your stake.

Distributing rewards

To begin distributing rewards for the Investor, the Issuer must first approve the staking platform to spend the specified amount of tokens.

Define the approveERC20Rewards function that will execute the approval by using the writeContract function which was imported from the viem library:

async function approveERC20Rewards(viemClient: any, spender:any, 
                                     amount: number, tokenAddress: string): Promise<Hash> {
      
  const txHash = await viemClient.writeContract({
        address: tokenAddress,
        abi: erc20Abi,
        functionName: 'approve',
        args: [spender, amount],
      });

      return txHash
}
PropertyDescription
addressThe address of the smart contract whose ERC-20 tokens are used as reward in the staking pool.
abiRefers to a predefined set of Application Binary Interfaces (ABIs) for interacting with ERC-20 tokens. This function depends on the erc20Abi import from the viem library.
functionNameThe name of the function to call on the smart contract; in this case, we call the 'approve' function on the smart contract defined in the address property.
argsData used in the invocation of the 'approve' function. It expects the spender and amount arguments.
spenderSpecifies the address of the staking platform that will spend the amount of ERC-20 tokens.
amountDetermines the amount of ERC-20 reward tokens to be approved for spending.

Then pass the required arguments to the approveERC20Rewards function to invoke it:

const amountRewards = 500000

const rewardsSCAddress = '0xffffffffffffffffffffffffffffffffffffffff'

const issuerApproveHash = approveERC20Rewards(investorClient, diamondAddress, 
                                         amount, tokenAddress)

Now you can begin distributing rewards as an Issuer entity.

Define the distributeRewards function, which will construct the data object via the notifyRewards invocation and send a transaction using this object in the sendTransaction call:

async function distributeRewards(viemClient: any, apiClient: ApiClient, 
                                diamondAddress: string, poolId: string,
                                amount: string): Promise<Hash>{
    
        const transactionData = handleApiResponse(
        await api.stakingTemplates.rwa.notifyRewards({
            client: apiClient,
            body: {
                platform: diamondAddress,
                poolId: poolId,
                amount: amount
            }
        })
    )

    const transactionResult = await sendTransaction(viemClient, transactionData)

    return transactionResult
}
PropertyDescription
platformThe address of the staking platform.
poolIdThe unique identifier of the pool where rewards must be distributed.
amountThe amount of tokens to be distributed as rewards.

Then execute the distributeRewards function by passing all required arguments. The result of this operation is optional:

const rewardsHash = distributeRewards(issuerClient, apiClient, diamondAddress, poolId, `50000`)

After executing the function, the staking pool has received rewards for distribution, and the Investor can now begin claiming rewards.

Claiming rewards

Define the claimRewards function for the Investor to receive their distributed amount:

async function claimRewards(viemClient: any, apiClient: ApiClient, 
                                diamondAddress: string, stakeId: string,): Promise<Hash>{
    
        const transactionData = handleApiResponse(
        await api.stakingTemplates.rwa.getReward({
            client: apiClient,
            body: {
                platform: diamondAddress,
                stakeId: stakeId
            }
        })
    )

    const transactionResult = await sendTransaction(viemClient, transactionData)

    return transactionResult
}
PropertyDescription
platformSpecifies the address of the staking platform. The diamondAddress variable will be used as an input when invoking the claimRewards function.
stakeIdThe unique identifier of the staking position.

Invoke the claimRewards function with the required arguments. The result of this operation is optional:

const claimHash = claimRewards(investorClient, apiClient, diamondAddress, stakeId)

The Investor has successfully claimed their rewards. The following sections of this material cover partial unstaking and unstaking operations.

Partial unstaking

Partial unstaking refers to the process of withdrawing a portion of the staked tokens while leaving the remaining tokens staked.

Define the pUnstake function which will construct the data object via the partialUnstake invocation and send a transaction using this object in the sendTransaction call:

async function pUnstake(viemClient: any, apiClient: ApiClient, 
                                diamondAddress: string, stakeId: string,
                                amount: string): Promise<Hash>{
    
        const transactionData = handleApiResponse(
        await api.stakingTemplates.rwa.partialUnstake({
            client: apiClient,
            body: {
                platform: diamondAddress,
                stakeId: stakeId,
                amount: amount
            }
        })
    )

    const transactionResult = await sendTransaction(viemClient, transactionData)

    return transactionResult
}
PropertyDescription
platformSpecifies the address of the staking platform. The diamondAddress variable will be used as an input when invoking the pUnstake operation.
stakeIdThe unique identifier of the staking position. The stakeId variable will be used as an input when invoking the pUnstake operation.
amountThe number of input packets to withdraw from the position, reducing its size.

Invoke the pUnstake operation by passing the required arguments. The result of this operation is optional:

const pUnstakeHash = pUnstake(investorClient, apiClient, diamondAddress, stakeId, '1000')

As you can see, the invocation withdraws '1000' packets from the current staking position.

Unstaking

Unstaking means that an account withdraws all input packets from the current position, effectively closing it.

Define the closePosition function which will construct the data object via the unstake invocation and send a transaction using this object in the sendTransaction call:

async function closePosition(viemClient: any, apiClient: ApiClient, 
                                diamondAddress: string, stakeId: string): Promise<Hash>{
    
        const transactionData = handleApiResponse(
        await api.stakingTemplates.rwa.unstake({
            client: apiClient,
            body: {
                platform: diamondAddress,
                stakeId: stakeId,
            }
        })
    )

    const transactionResult = await sendTransaction(viemClient, transactionData)

    return transactionResult
}
PropertyDescription
platformSpecifies the address of the staking platform. The diamondAddress variable will be used as an input when invoking the closePosition operation.
stakeIdThe unique identifier of the staking position. The stakeId variable will be used as an input when invoking the closePosition operation.

Invoke the closePosition function with the required arguments. The result of this operation is optional:

const closePositionHash = closePosition(investorClient, apiClient, diamondAddress, stakeId)

The Investor has withdrawn all input packets from their position.

Closing remarks

Congratulations! You have successfully mastered the key concepts of using the Staking API and we have covered all methods provided in the RWA template. It also means that you can take one step further and modify existing code examples to fit your needs better.

By now, you should have a solid understanding of how each process works and how to navigate them effectively. With this expertise, you're now equipped to implement, customize, and optimize staking solutions to suit your specific goals.