Staking: Reputation Score Rewards

To increase the engagement of each participant within a blockchain ecosystem, you can introduce a reputation score that motivates users to act in specific ways. This creates an environment where the reputation score serves as a tool to
incentivize positive actions, build trust, and promote accountability among participants.

The reputation score will be represented by ERC-20 tokens, which are minted when users stake ERC-20 tokens. To implement this, developers need to deploy a separate ERC-20 smart contract that mints new reputation tokens for stakers who become eligible for rewards by participating in a staking pool.

📘

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

You will use Evergonlabs' proprietary tmi-protocol-api-client and the underlying reputation template to deploy both a staking platform and the staking pool. The deployment of the Reputation SC contract is beyond the scope of this material, but you will find helpful instructions on how to implement it and ensure it works in tandem with the deployed staking pool.

When a staking pool is deployed using the reputation template, the rewardAssetHandler contract is also deployed to route claim rewards requests to the Reputation SC contract. Ensure the rewardAssetHandler has permission to call the mint function on the Reputation SC contract.

There are three entities involved in the entire 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 all the key actions of each entity:

  1. Admin deploys the 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 the staking pool.
  4. Investor stakes the ERC-20 tokens.
  5. Investor requests a reward claim.
  6. The staking pool forwards the mint request to the assetRewardHandler.
  7. The assetRewardHandler forwards the mint request to the Reputation SC contract.
  8. The Reputation SC contract mints reputation tokens and sends them to the Investor's account.

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, partial unstaking and unstaking.

ERC-20 Reputation SC

We recommend deploying a smart contract for the ERC-20 reputation score tokens based on the OpenZeppelin standard, which provides the access control layer.

    // contracts/MyToken.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;

    import "@openzeppelin/contracts/access/AccessControl.sol";
    import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

    contract MyToken is ERC20, AccessControl {
        // Create a new role identifier for the minter role
        bytes32 public constant MINTER_ROLE = keccak256("MINTER");

        constructor(address minter) ERC20("MyToken", "TKN") {
            // Grant the minter role to a specified account
            _grantRole(MINTER_ROLE, minter);
        }

        function mint(address to, uint256 amount) public {
            // Check that the calling account has the minter role
            require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter");
            _mint(to, amount);
        }
    }

When using the reputation template, rewards are not minted upfront for the staking pool; instead, they are minted on-demand when users reach a specific time threshold and become eligible to claim rewards.

To ensure this process works correctly, you must grant the MINTER role from the Reputation SC to the rewardAssetHandlerof the pool.

Rate-Based Open Reward Distribution

The reputation template relies on the Rate-Based Open reward distribution mechanism, and uses the interval-based multipliers, where the number of staked packets determines the multiplier applied to the stake.

Consider the following example array of arrays, which sets the corresponding thresholds for each multiplier. The value at the zero-based index determines the required number of packets to be staked in order to receive the multiplier specified at the first index position.

const packetsAndMultipliers = [[100, 2], [300, 4]]

The packetsAndMultipliers sets the following multiplier rules for a staking pool:

  • A position with fewer than 100 staked packets will not have an amount multiplier applied.
  • A position with 100 to 299 staked packets will have an amount multiplier of 2 applied.
  • A position with 300 or more staked packets will have an amount multiplier of 4 applied.

❗️

Important

All values specified for amount multipliers must be strictly in increasing order:

Correct: [[100, 2], [300, 4]]
Incorrect: [[300, 4], [100, 2]]

The rate-based open reward distribution algorithm applies the following formulas to staking, restaking, partial unstaking, and rewards claiming actions:

ActionFormula
Unstakingrate * (packetBalance * amountMultiplier) * activePeriod_unix
Partial Unstakingrate * (packetBalance * amountMultiplier) * activePeriod_unix
Restakingrate * (beforeRestakePacketBalance * amountMultiplier) * activePeriod_unix
Claiming Rewardsrate * (packetBalance * amountMultipier) * activePeriod_unix
PropertyDescription
rateThe number of reward packets an account earns per second for each input packet staked.
packetBalanceThe current packet balance an account has staked.
amountMultiplierThe multiplier that gets selected depending on the staked amount.
beforeRestakePacketBalanceThe current packet balance an account has staked prior to performing the restake operation.
activePeriod_unixThe total time elapsed since the last reward provision occurred for an account.

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, parseEther} 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.

Defining the API client

The @evergonlabs/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.reputation.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.reputation.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.reputation.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 the account address that will be granted the 'Admin' role for the staking platform.
erc721Initializes the platform's staking position token collection, specifying the collection's name, symbol, and base URL. Each time someone stakes in a campaign, they receive a corresponding NFT that represents their staking position.
isSoulboundDetermines whether the staking position NFT collection is non-transferable, ensuring that each issued NFT remains tied to a single 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 accounts: : 'Creator', and 'Staker'.

'Creator' - Grants permission to deploy a staking pool.
'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.
platformSpecify the address of the platform represented by 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.reputation.createPool({
        client: apiClient,
        body: {
            platform: diamondAddress,
            // when Staking Pool starts
            startDate: new Date("2025-02-10T11:07:07.541Z"),
            // min and max amounts per stake
            amount: {
              minAmount: parseEther("1").toString(), // min 1 packet
              maxAmount: parseEther("100").toString(), // max 100 packets
            },
            // Specify the ERC-20 contract address whose tokens are used as input for pool
            stakingErc20: {
              contract: "0xffffffffffffffffffffffffffffffffffffffff", // contract address
              amountOfTokensPerPacket: "20", // The number of tokens to stake per packet
            },
            // Specify the Reputation SC contract details for reward distribution
            rewardErc20: {
              contract: "0xffffffffffffffffffffffffffffffffffffffff", // contract address
              amountOfTokensPerPacket: "1", // The number of tokens to yield per packet
            },
            // Specify how many packets allocated to a staker each second as a Reward
            rate: parseEther("0.00000001").toString(), // ~0.02592 packets per month
            // Specify what amount multiplier should be applied:
            amountMult: [
              ["5", "2"], // for 5 staked packets your reward rate will be multiplied by 2
              ["20", "4"], // for 20 staked packets your reward rate will be multiplied by 4
            ],
          },
        })
    );

    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 will start at the specified future time. Must be of the Date type.
amountAn object that specifies the minimum and maximum input packet amounts allowed for one staking position.
minAmountDetermines the minimum input packet amount allowed for a staking position.
maxAmountDetermines the maximum input packet amount allowed for a staking position.
stakingERC20An object that specifies the ERC-20 contract address whose tokens are used as input for pool.
contractThe address of the smart contract.
amountOfTokensPerPacketDefines the number of tokens required to form one input packet.
rewardErc20An object that specifies the ERC-20 contract address whose tokens are used as a reward for pool.
contractThe address of the smart contract.
amountOfTokensPerPacketThe number of tokens contained in a single reward packet.
rateSpecify the number of packets allocated to a staker per second as a reward.
amountMultAn array of arrays that specifies the interval-based amount multipliers.

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.reputation.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 tokens, 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, Admin must grant the 'Staker' role to it.

Define the grantStakerRole function whose invocation will rely on the adminClient 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 adminClient, apiClient and diamondAddress arguments to the grantCreatorRole function call. The successful invocation grants the Investor the 'Staker' role.

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

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

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

async function approveERC20Tokens(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 approveERC20Tokens function passing all required arguments:

const amount = 50000

const tokenAddress = '0xffffffffffffffffffffffffffffffffffffffff'

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

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

Define the stakeTokens 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 stakeTokens(viemClient: any, apiClient: ApiClient, 
                                diamondAddress: string, poolId: string,
                                amount: string): Promise<Hash>{
    
        const transactionData = handleApiResponse(
        await api.stakingTemplates.reputation.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 packets to be staked.

Note that when executing the stakeTokens 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 stakeTokens(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.reputation.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.

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.reputation.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.reputation.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' (i.e., 20.000 ERC20 input tokens) 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.reputation.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

Great job! You’ve successfully deployed a series of smart contracts and launched a staking pool, enabling users to earn reward reputation tokens. With a solid understanding of the Staking API, the methods in the reputation template, and the reward distribution strategy, you’re now equipped to fine-tune staking solutions to achieve your objectives.