Part 2. Creating a PoolTogether Prize Claiming bot

Tutorial duration: ~20 minutes

What is a prize claiming bot for?

The upcoming PoolTogether Hyperstructure automates prize claiming using incentives. You can earn fees by claiming prizes for winners. You earn money, and they get their prizes automatically.

New prizes are released in daily draws. Winners must first be computed, then the prizes can be claimed. The reward for claiming a prize is priced according to a variable rate gradual dutch auction (VRGDA for short). In a nutshell, this means that the reward increases if prizes aren’t claimed fast enough.

In this tutorial we will create a bot which will run every few minutes and check if there are prizes to claim on behalf of winners. If there are prizes to claim and the reward they will earn for claiming is worth the cost, the bot will send a transaction to claim.

—

Note: We have created a bot (which runs on OpenZeppelin Defender, but you can use whichever cron/relay service you like) which you can use as a reference here: https://github.com/GenerationSoftware/pt-v5-autotasks-monorepo/tree/main/packages/prize-claimer

These tutorials expect you have intermediate knowledge of programming concepts and JavaScript/TypeScript. We’ll use ethers.js, but you could write these bots using web3.js, Web3.py, etc.

Also, there is a JavaScript utils library for interacting with the PrizePool and associated contracts here: https://github.com/GenerationSoftware/pt-v5-utils-js

Rewards & Prize Expiry

Prizes will expire every day to incentivize claiming. The rewards for claiming increase exponentially during the course of the auction. Rewards are taken from the prize itself. A basic example of this is:

  • a depositor wins 10 POOL

  • 15 hours later (if the prize hasn’t been claimed yet) the reward for claiming would be 2 POOL

  • the winner would receive 8 POOL

  • the bot who claims the prize would earn the 2 POOL reward

Thankfully the system has been designed so that a bot can send one transaction to claim multiple prizes in one go. Being able to claim multiple prizes in one transaction ensures it will be profitable to claim prizes soon after a draw, reducing the amount of claim fees taken from the prize itself.

The first prize claim transaction fee is about 220,000 wei worth of gas, and each subsequent prize claim is about 120,000 wei gas. During the time this article was written, on Optimism gas for 1 prize claimed was $0.08, and gas for 7 prizes claimed was $0.20.

Claim Prizes Subgraph

There is a subgraph live at https://api.studio.thegraph.com/query/41211/pt-v5-optimism-prize-pool/v0.0.1 which you can use for querying which prizes have previously been claimed for which draws. The pt-v5-utils-js library also has getSubgraphClaimedPrizes function for easily fetching claimed prizes.

And with that, let’s get into it:

Steps to claiming prizes:

  1. Write main() function

  2. Find all active depositor accounts and compute prizes won for each account

  3. Check the optimal number of prizes to claim per tier to maximize rewards (called fees)

  4. If the fees are sufficiently profitable, execute prize claims

  5. Periodically withdraw the fees you have earned from the Prize Pool

1. main() Function

Let’s start by importing the various library functions and types (if using TypeScript) we will need to use, then setting up an async function that we will call right away:

import { Contract, BigNumber } from 'ethers';
import { JsonRpcProvider } from '@ethersproject/providers'
import { formatUnits } from '@ethersproject/units';
import {
  computeDrawWinners,
  downloadContractsBlob,
  flagClaimedRpc,
  Claim,
  ContractsBlob,
} from '@generationsoftware/pt-v5-utils-js';
import groupBy from 'lodash.groupby';

// The params required by the `claimPrizes()` contract
// function on the Claimer.sol contract
interface ClaimPrizesParams {
  vault: string;
  tier: number;
  winners: string[];
  prizeIndices: number[][];
  feeRecipient: string;
  minVrgdaFeePerClaim: string;
}

const CONTRACTS_VERSION = {
  major: 1,
  minor: 0,
  patch: 0,
};

// the address you want earned fees to accrue to
const FEE_RECIPIENT = '0x0000000000000000000000000000000000000000'
const CHAIN_ID = 10; // Optimism

const readProvider = new JsonRpcProvider(YOUR_JSON_RPC_PROVIDER_URL);

const main = async () => {
  const claimerContract = getClaimerContract(chainId, readProvider, contracts)
  
  const claims: Claim[] = await getClaims();
  
  const unclaimedClaimsGrouped = await findAndGroupUnclaimed(claims)
  
  await loopUnclaimedGroups(unclaimedClaimsGrouped, claimerContract)
}
main()

const getClaimerContract = (chainId: number, readProvider: Provider, contracts: ContractsBlob): Contract => {
  const claimerContract = getContract(
    'Claimer',
    chainId,
    readProvider,
    contracts,
    CONTRACTS_VERSION,
  );
};
  

2. Find Winners

To collect a list of all active depositor addresses we can use the TwabController subgraph. We can then determine if they won in the previous draw by using RPC calls to the PrizePool contract’sisWinner().

You can use the handy computeDrawWinners()in the pt-v5-utils-jslibrary to find depositors, determine who won and if their prizes have been claimed already:

const getClaims = async (): Promise<Claim[]> { => {
  const contracts: ContractsBlob = await downloadContractsBlob(CHAIN_ID);

  // We have found this to be too heavy to use in our OpenZeppelin Defender autotasks.
  // Depending on the number of winners you may need to cache the results of this for
  // every draw in flat files that your bot then fetches later
  const claims: Claim[] = await computeDrawWinners(readProvider, contracts, CHAIN_ID);
  
  return claims;
}

// computeDrawWinners() returns:
[
  {
    vault: '0x06b36307e4da41f0c42efb7d7abc02df0c8b5c49',
    winner: '0x725e613f1816395dd453dc6394b1794a73152813',
    tier: 1,
    prizeIndex: 2,
    claimed: false
  },
  ...
]

Note: There is also thept-v5-clihelper library available which can be used to find depositors and cache data about their winnings to JSON flat files here: https://github.com/generationSoftware/pt-v5-cli

We get back an array containing info about each prize. One account can win multiple prizes in multiple vaults, tiers and indices.

Vaults — 4626-compatible prize vaults, hold user’s deposits, and in return gives the user a interest-bearing token as collateral.

Tiers — the various prize tiers which someone can win from. For instance, tier 0 prizes may be $1,000, while tier 1 prizes are $500, tier 2 $100, etc.

Indices — a method of allowing one account to win multiple prizes from the same tier. This allows for fairer winning odds based on the pooler’s deposit size.

Continuing on we check live data with RPC calls for prizes that have already been claimed (removing them with filter) and group the unclaimed claims by Vault and Tier:

const findAndGroupUnclaimed = async (claims: Claim[]) => {
  // Cross-reference claimed prizes to flag if a prize has been claimed or not
  claims = await flagClaimedRpc(readProvider, contracts, claims);

  let unclaimedClaims = claims.filter((claim) => !claim.claimed);
  console.log(`${unclaimedClaims.length} prizes remaining to be claimed...`);

  if (unclaimedClaims.length === 0) {
    console.log(`No prizes left to claim. Exiting ...`);
    return;
  }
  
  // Sort unclaimed claims by tier so largest prizes (with the largest rewards) are first
  unclaimedClaims = unclaimedClaims.sort((a, b) => a.tier - b.tier);

  // Group claims by vault & tier
  const unclaimedClaimsGrouped = groupBy(unclaimedClaims, (item) => [item.vault, item.tier]);
  
  return unclaimedClaimsGrouped;
};

3. Check For Profitable Claims

Now that we have a list of prizes grouped and sorted by Vault & Tier (stored in the unclaimedClaimsGrouped variable) let’s iterate through each to check how many we should claim (if any) to make us profit:

const loopUnclaimedGroups = async (unclaimedClaimsGrouped, claimerContract: Contract) => {
  for (let vaultTier of Object.entries(unclaimedClaimsGrouped)) {
    const [key, value] = vaultTier;
    const [vault, tier] = key.split(',');
    const groupedClaims: any = value;

    console.log(`Vault:       ${vault}`);
    console.log(`Tier Index:  #${tier}`);
    console.log(`# prizes:    ${groupedClaims.length}`);

    const claimPrizesParams = await calculateProfit(
      vault,
      Number(tier),
      claimerContract,
      groupedClaims,
    );

    // It's profitable if there is at least 1 claim to claim
    if (claimPrizesParams.winners.length > 0) {
      await executeTransaction(claimPrizesParams, claimerContract)
    }
};

—

Note: We’ll leave out how to get the gas cost in USD for the chain you’re working on. You can view the reference implementation to see how this is done using eth_gasPrice, gasEstimate on contract calls from ethers.js and the USD value of the native gas token (ie. ETH on Optimism) from a price feed such as Coingecko or Covalent, etc.

calculateProfit() will pass the claim data to getClaimInfo(), and in return receive information about how many claims in the array should be processed. It will then slice up the array to only include that number of claims, and build the params list that we will send to the contract function in executeTransaction():

const calculateProfit = async (
  vault: string,
  tierIndex: number,
  claimerContract: Contract,
  groupedClaims: any,
): Promise<ClaimPrizesParams> => {
  const { claimCount, claimFeesUsd, totalCostUsd, minVrgdaFeePerClaim } = 
    await getClaimInfo(
      claimerContract,
      tierIndex,
      groupedClaims,
    );

  const claimsSlice = groupedClaims.slice(0, claimCount);
  const claimPrizesParams = buildParams(
    vault,
    tierIndex,
    claimsSlice,
    minVrgdaFeePerClaim,
  );

  return claimPrizesParams;
};

const buildParams = (
  vault: string,
  tier: number,
  claims: Claim[],
  minVrgdaFeePerClaim: string,
): ClaimPrizesParams => {
  let winners: string[] = [];
  let prizeIndices: number[][] = [];

  claims.forEach((claim) => {
    winners.push(claim.winner);
    prizeIndices.push([claim.prizeIndex]);
  });

  return {
    vault,
    tier,
    winners,
    prizeIndices,
    feeRecipient: FEE_RECIPIENT,
    minVrgdaFeePerClaim,
  };
};

In getClaimInfo()below we go through each claim and call the Claimer contract’s computeTotalFees(uint8,uint256) function to get the amount we can earn.

On each loop, we will compare the amount we would earn against the previous fees, gas costs and MIN_PROFIT_USD. If we’re continuing to earn profit we’ll keep going, and if not we will break out of the loop.

getClaimInfo() will return an object of the ClaimInfo type. ClaimInfo includes the # of claims (which we’ll use to slice the original claims array) and the minVrgdaFeePerClaim— necessary to pass as an argument to the Claimer contract’s claimPrizes() function later:

const MIN_PROFIT_USD = 0.1;           // $0.10 per prize claimed
const FEE_TOKEN_ASSET_RATE_USD = 0.6; // $0.60 for prize token (likely POOL)
const FEE_TOKEN_DECIMALS = 18;

const GAS_ONE_CLAIM_USD = 0.08;            // $0.08 gas cost for one claim
const GAS_EACH_FOLLOWING_CLAIM_USD = 0.02; // $0.02 extra gas cost for each following claim

interface ClaimInfo {
  claimCount: number;
  minVrgdaFeePerClaim: string;
}

const getClaimInfo = async (
  claimerContract: Contract,
  tierIndex: number,
  claims: Claim[],
): Promise<ClaimInfo> => {
  let claimCount = 0;
  let claimFeesUsd = 0;
  let totalCostUsd = 0;
  let prevNetFeesUsd = 0;
  let claimFees = BigNumber.from(0);
  let minVrgdaFees: BigNumber[] = [];
  for (let numClaims = 1; numClaims <= claims.length; numClaims++) {
    const nextClaimFees = await claimerContract.functions['computeTotalFees(uint8,uint256)'](
      tierIndex,
      numClaims,
    );

    const totalCostUsd =
      numClaims === 1
        ? GAS_ONE_CLAIM_USD
        : GAS_ONE_CLAIM_USD + GAS_EACH_FOLLOWING_CLAIM_USD * numClaims;

    const claimFeesFormatted = formatUnits(claimFees.toString(), FEE_TOKEN_DECIMALS);
    claimFeesUsd = parseFloat(claimFeesFormatted) * FEE_TOKEN_ASSET_RATE_USD;

    const nextClaimFeesFormatted = formatUnits(claimFees.toString(), FEE_TOKEN_DECIMALS);
    const nextClaimFeesUsd = parseFloat(nextClaimFeesFormatted) * FEE_TOKEN_ASSET_RATE_USD;

    const netFeesUsd = nextClaimFeesUsd - totalCostUsd;

    if (netFeesUsd > prevNetFeesUsd && netFeesUsd > MIN_PROFIT_USD) {
      prevNetFeesUsd = netFeesUsd;
      claimCount = numClaims;
      claimFees = nextClaimFees;
      claimFeesUsd = nextClaimFeesUsd;

      minVrgdaFees.push(nextClaimFees);
    } else {
      break;
    }
  }
  
  // Sort array of BigNumbers by comparing them using basic JS built-in sort()
  minVrgdaFees.sort((a: BigNumber, b: BigNumber) => (a.gt(b) ? 1 : -1));

  // Take the lowest BigNumber as the lowest fee we will accept
  const minVrgdaFeePerClaim = Boolean(minVrgdaFees[0]) ? minVrgdaFees[0].toString() : '0';
  
  console.log(`$${netProfitUsd} = ($${claimFeesUsd} - $${totalCostUsd})`);

  return { claimCount, minVrgdaFeePerClaim };
};

Whew!

On to the last step.

4. Execute Profitable Claims

Finally, we need to determine if there are any profitable claims based on the params list returned to us by the calculateProfit() function, and if there are use the OpenZeppelin relayer to send a transaction to the blockchain.

Of course, here you can swap out the OpenZeppelin relayer for whichever method you prefer for sending transactions:

const executeTransaction = async (claimPrizesParams, claimerContract: Contract) => {
  // It's profitable if there is at least 1 claim to claim
  if (claimPrizesParams.winners.length > 0) {
    const populatedTx = await claimerContract.populateTransaction.claimPrizes(
      ...Object.values(claimPrizesParams),
    );

    const tx = await relayer.sendTransaction({
      data: populatedTx.data,
      to: populatedTx.to,
      gasLimit: 8000000,
    });
    console.log('Transaction hash:', tx.hash);
  } else {
    console.log(`Not profitable to claim Draw #${context.drawId}, Tier Index: #${tier}`);
  }
}
  

5. Withdrawing Claim Rewards

Last but not least, every now and then you will need to withdraw the rewards you have earned from the PrizePool. There is an ancillary bot written to take care of that as well, which you can find a reference implementation of here.

Finishing Up

And with that, we have a bot that is capable of profiting from claiming prizes on behalf of PoolTogether v5 depositors. We’re impressed you made it this far! 👏

In Part 1, we looked at making liquidations with our bots. In Part 3 we will look at how to earn by helping with the RNG (random number generation) and relay (bridging the random number to other chains) Draw Auction bot.

If you have any questions feel free to reach out to anyone in the PoolTogether Discord community. The #developers channel is your best bet!

Subscribe to Chuck Bergeron - Generation Software
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.