Part 3. Creating a PoolTogether Draw Auction bot

Tutorial duration: ~25 minutes

What is the Draw Auction bot?

The hyperstructure — PoolTogether’s latest protocol update introduces a new way to ensure the protocol lasts for generations to come. Built-in to the protocol is the ability for you to make profit by running core functions necessary for PoolTogether to operate day-to-day.

We’ve learned about the Arbitrage Liquidator bot in Part 1, and the Prize Claiming bot in Part 2. This tutorial is about the Draw Auction bot, which does two things:

  1. Requests the RNG (random number generator) to create a random number — used to determine the prizes — every day, and

  2. Bridges the provably fair and verifiable random number obtained in step 1 to the various chain’s prize pools.

Similar to the Prize Claiming in Part 2 of this tutorial series, the Draw Auctions use a gradual dutch auction to ramp up the rewards you can earn in exchange for assisting with the RNG flow over time. More info on how the auction works behind the scenes can be found in the docs here: https://dev.pooltogether.com/protocol/next/design/draw-auction

A gradual dutch auction determines the rewards you can earn from the draw auctions.
A gradual dutch auction determines the rewards you can earn from the draw auctions.

This tutorial will go over the steps required to create profitable RNG Request and Relay (bridge) transactions.

—

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/draw-auction

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

RNG Service

The RNG service used in Step 1 is currently set to Chainlink’s VRF, however any other random number source (such as RANDAO) could be plugged in at a later date.

Steps:

  1. Create a main() function and get setup

  2. Gather information about the state of the RNG and Relay Auctions

  3. Compare RNG service fee and gas costs against the current reward you could earn to determine profitability

  4. Execute transaction (when profitable) to start the RNG request or relay (bridge) the number to other chain’s prize pools

Caveat: The term relay in this tutorial is referencing the RngRelayAuction contract and function, not the OpenZeppelin Defender Relayer unless otherwise stated.

1. Create a main() and do setup

Working with the RNG and Relay contracts requires us to initialize a bunch of ethers.js Contract objects, as well as work across multiple chains.

During the first RngAuctionphase we will need to run startRngRequest to get a random number. The RNG currently uses Chainlink’s VRF on the Ethereum L1 chain. Therefore, the RngAuction, the RngAuctionRelayerRemoteOwnerand the ChainlinkVRFV2DirectRngAuctionHelpercontracts all live on Ethereum.

In the second phase RngRelayAuction, the random number needs to be broadcast from the L1 (Ethereum) to the prize pool on the L2 (currently Optimism, but could be a number of prize pools on various L2 chains). This is also initiated on the L1 using the RngAuctionRelayerRemoteOwner contract.

We will use the following contracts to query data from for finding out the state of the auctions: PrizePoolContract, RngAuctionContract, and RngRelayAuctionContract.

As well, we’ll need these two helper contracts for sending transactions: ChainlinkVRFV2DirectRngAuctionHelper and RngAuctionRelayerRemoteOwner.

These contract objects are all collected in the code below:

import { ethers, Contract } from 'ethers';
import { Provider } from '@ethersproject/providers';
import {
  ContractsBlob,
  downloadContractsBlob,
  getContract,
} from '@generationsoftware/pt-v5-utils-js';

export interface AuctionContracts {
  prizePoolContract: Contract;
  chainlinkVRFV2DirectRngAuctionHelperContract: Contract;
  remoteOwnerContract: Contract;
  rngAuctionContract: Contract;
  rngRelayAuctionContract: Contract;
  rngAuctionRelayerRemoteOwnerContract: Contract;
}

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

const ERC_5164_MESSAGE_DISPATCHER_ADDRESS = {
  1: '0xa8f85bab964d7e6be938b54bf4b29a247a88cd9d', // mainnet -> optimism
};

const RNG_CHAIN_ID = 1; // eth mainnet
const RELAY_CHAIN_ID = 10; // optimism
const RNG_JSON_RPC_URI = ''; // your Alchemy, Infura, etc. RPC URI for target RNG chain
const RELAY_JSON_RPC_URI = ''; // your Alchemy, Infura, etc. RPC URI for target RELAY chain

const REWARD_RECIPIENT = '' // address the rewards should be sent to

const main = async () => {
  const helpers = await getHelpers();
  const context = await getDrawAuctionContext(helpers);

  if (!context.state) {
    console.warn(`Currently no Rng or RngRelay auctions to complete. Exiting ...`);
    return;
  }

  if (context.state === DrawAuctionState.RngStartVrfHelper) {
    await checkBalance(context);
    await increaseRngFeeAllowance(signer, context, helpers.auctionContracts);
  }

  // Get estimate for gas cost ...

  const profitable = await calculateProfit(gasCostUsd, context);

  if (profitable) {
    // We can simply pass the rngRelayer and rngReadProvider here since
    // both transactions we create will be on the L1
    await sendTransaction(rngRelayer, auctionContracts, context);
  }
};
main();

const getHelpers = async () => {
  const rngReadProvider = new ethers.providers.JsonRpcProvider(RNG_JSON_RPC_URI, RNG_CHAIN_ID);
  const relayReadProvider = new ethers.providers.JsonRpcProvider(
    RELAY_JSON_RPC_URI,
    RELAY_CHAIN_ID,
  );

  const rngContracts = await downloadContractsBlob(RNG_CHAIN_ID);
  const relayContracts = await downloadContractsBlob(RELAY_CHAIN_ID);

  const auctionContracts = getAuctionContracts(
    rngReadProvider,
    relayReadProvider,
    rngContracts,
    relayContracts,
  );

  return { auctionContracts, rngReadProvider, relayReadProvider };
};

const getAuctionContracts = (
  rngReadProvider: Provider,
  relayReadProvider: Provider,
  rngContractsBlob: ContractsBlob,
  relayContractsBlob: ContractsBlob,
): AuctionContracts => {
  const rngContracts = getRngContracts(rngReadProvider, rngContractsBlob);
  const relayContracts = getRelayContracts(relayReadProvider, relayContractsBlob);

  return {
    ...rngContracts,
    ...relayContracts,
  };
};

const getRngContracts = (provider: Provider, contracts: ContractsBlob) => {
  const chainId = RNG_CHAIN_ID;
  const version = CONTRACTS_VERSION;

  const rngAuctionContract = getContract('RngAuction', chainId, provider, contracts, version);
  const chainlinkVRFV2DirectRngAuctionHelperContract = getContract(
    'ChainlinkVRFV2DirectRngAuctionHelper',
    chainId,
    provider,
    contracts,
    version,
  );
  const rngAuctionRelayerRemoteOwnerContract = getContract(
    'RngAuctionRelayerRemoteOwner',
    chainId,
    provider,
    contracts,
    version,
  );

  return {
    chainlinkVRFV2DirectRngAuctionHelperContract,
    rngAuctionContract,
    rngAuctionRelayerRemoteOwnerContract,
  };
};

const getRelayContracts = (provider: Provider, contracts: ContractsBlob) => {
  const chainId = RELAY_CHAIN_ID;
  const version = CONTRACTS_VERSION;

  const prizePoolContract = getContract('PrizePool', chainId, provider, contracts, version);
  const rngRelayAuctionContract = getContract(
    'RngRelayAuction',
    chainId,
    provider,
    contracts,
    version,
  );
  const remoteOwnerContract = getContract('RemoteOwner', chainId, provider, contracts, version);

  return {
    prizePoolContract,
    remoteOwnerContract,
    rngRelayAuctionContract,
  };
};

2. Gather Contract State

Once we have our main function set up, and the various AuctionContracts collected we can gain intel on the current auction state.

We start with the basic get function getDrawAuctionContext, this will call both getRng and getRelay as well, then combine that data into the contextobject:

import { BigNumber, ethers } from 'ethers';
import { formatUnits } from '@ethersproject/units';
import { Provider } from '@ethersproject/providers';
import {
  AuctionContracts,
  DrawAuctionContext,
  DrawAuctionRelayerContext,
  RelayDrawAuctionContext,
  RngDrawAuctionContext,
  TokenWithRate,
} from '@generationsoftware/pt-v5-autotasks-library';

export enum DrawAuctionState {
  RngStartVrfHelper = 'RngVrfHelper',
  RngRelayBridge = 'RngRelayBridge',
}

import { ERC20Abi } from '../abis/ERC20Abi';
import { VrfRngAbi } from '../abis/VrfRngAbi';

const RELAYER_ADDRESS = ''; // account address of your signer / relayer
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';

const RNG_FEE_TOKEN_MARKET_RATE_USD = 6.14
const REWARD_TOKEN_MARKET_RATE_USD = 0.62
const ETH_MARKET_RATE_USD = 1636.27

const getDrawAuctionContext = async (
  rngReadProvider: Provider,
  relayReadProvider: Provider,
  auctionContracts: AuctionContracts,
): Promise<DrawAuctionContext> => {
  const prizePoolReserve = await auctionContracts.prizePoolContract.reserve();
  const prizePoolReserveForOpenDraw = await auctionContracts.prizePoolContract.reserveForOpenDraw();
  const reserve = prizePoolReserve.add(prizePoolReserveForOpenDraw);

  const rngContext = await getRng(rngReadProvider, auctionContracts, reserve);
  const relayContext = await getRelay(relayReadProvider, auctionContracts, rngContext);

  const rngNativeTokenMarketRateUsd = await getNativeTokenMarketRateUsd(RNG_CHAIN_ID);
  const relayNativeTokenMarketRateUsd = await getNativeTokenMarketRateUsd(RELAY_CHAIN_ID);

  const rngExpectedRewardUsd = rngContext.rngExpectedReward * relayContext.rewardToken.assetRateUsd;

  const context = {
    ...rngContext,
    ...relayContext,
    rngNativeTokenMarketRateUsd,
    relayNativeTokenMarketRateUsd,
    rngExpectedRewardUsd,
  };

  return {
    ...context,
    state: getState(context),
  };
};

const getState = (context: DrawAuctionContext): DrawAuctionState => {
  if (context.rngIsAuctionOpen && context.rngFeeTokenIsSet && context.rngFeeUsd > 0) {
    return DrawAuctionState.RngStartVrfHelper;
  } else if (context.rngRelayIsAuctionOpen) {
    return DrawAuctionState.RngRelayBridge;
  }
};

getRng will collect all of the info necessary to determine if the startRngRequest function can be run, the fee we need to pay when requesting a random number, and the reward we could earn by requesting it:

export const getRng = async (
  rngReadProvider: Provider,
  auctionContracts: AuctionContracts,
  reserve: BigNumber,
): Promise<RngDrawAuctionContext> => {
  // RNG Auction Service Info
  const rngService = await auctionContracts.rngAuctionContract.getNextRngService();
  const rngServiceContract = new ethers.Contract(rngService, VrfRngAbi, rngReadProvider);
  const rngServiceRequestFee = await rngServiceContract.getRequestFee();

  const rngFeeTokenAddress = rngServiceRequestFee[0];

  // RNG Estimated Fee from VrfHelper
  const gasPrice = await rngReadProvider.getGasPrice();
  const requestGasPriceWei = gasPrice;

  const chainlinkVRFV2DirectRngAuctionHelperContract =
    await auctionContracts.chainlinkVRFV2DirectRngAuctionHelperContract;
  const vrfHelperRequestFee = await chainlinkVRFV2DirectRngAuctionHelperContract.estimateRequestFee(
    requestGasPriceWei,
  );
  const rngFeeAmount = vrfHelperRequestFee._requestFee;

  const rngFeeTokenIsSet = rngFeeTokenAddress !== ZERO_ADDRESS;

  let rngFeeTokenBalance = BigNumber.from(0);
  let rngFeeTokenAllowance = BigNumber.from(0);
  let rngFeeTokenContract;

  if (rngFeeTokenIsSet) {
    rngFeeTokenContract = new ethers.Contract(rngFeeTokenAddress, ERC20Abi, rngReadProvider);

    rngFeeTokenBalance = await rngFeeTokenContract.balanceOf(RELAYER_ADDRESS);
    rngFeeTokenAllowance = await rngFeeTokenContract.allowance(
      RELAYER_ADDRESS,
      auctionContracts.chainlinkVRFV2DirectRngAuctionHelperContract.address,
    );
  }

  // RNG Auction info
  const rngIsAuctionOpen = await auctionContracts.rngAuctionContract.isAuctionOpen();
  const rngIsRngComplete = await auctionContracts.rngAuctionContract.isRngComplete();
  const rngCurrentFractionalReward =
    await auctionContracts.rngAuctionContract.currentFractionalReward();

  // RNG Auction Service
  let rngFeeToken: TokenWithRate;
  if (rngFeeTokenIsSet) {
    const rngFeeTokenSymbol = await rngFeeTokenContract.symbol();
    rngFeeTokenContract = new ethers.Contract(rngFeeTokenAddress, ERC20Abi, rngReadProvider);

    rngFeeToken = {
      address: rngFeeTokenAddress,
      decimals: await rngFeeTokenContract.decimals(),
      name: await rngFeeTokenContract.name(),
      symbol: rngFeeTokenSymbol,
      assetRateUsd: RNG_FEE_TOKEN_MARKET_RATE_USD,
    };
  }

  const rngCurrentFractionalRewardString = ethers.utils.formatEther(rngCurrentFractionalReward);

  // TODO: Assuming 18 decimals. May need to format using rewardToken's decimals instead
  const reserveStr = ethers.utils.formatEther(reserve);
  const rngExpectedReward = Number(reserveStr) * Number(rngCurrentFractionalRewardString);

  // RNG Fee
  let relayer: DrawAuctionRelayerContext;
  if (rngFeeTokenIsSet) {
    relayer = {
      rngFeeTokenBalance,
      rngFeeTokenAllowance,
    };
  }

  let rngFeeUsd = 0;
  if (rngFeeTokenIsSet) {
    rngFeeUsd =
      parseFloat(formatUnits(rngFeeAmount, rngFeeToken.decimals)) * rngFeeToken.assetRateUsd;
  }

  return {
    rngFeeTokenIsSet,
    rngFeeToken,
    rngFeeAmount,
    rngFeeUsd,
    rngIsAuctionOpen,
    rngIsRngComplete,
    rngExpectedReward,
    relayer,
  };
};

getRelay takes information from getRng and determines if relay()(the bridging of the random number) can be run and how much we can earn by running that function:

export const getRelay = async (
  readProvider: Provider,
  auctionContracts: AuctionContracts,
  rngContext: RngDrawAuctionContext,
): Promise<RelayDrawAuctionContext> => {
  // Prize Pool Info
  const prizePoolOpenDrawEndsAt = await auctionContracts.prizePoolContract.openDrawEndsAt();

  const rewardTokenAddress = await auctionContracts.prizePoolContract.prizeToken();
  const rewardTokenContract = new ethers.Contract(rewardTokenAddress, ERC20Abi, readProvider);

  // Reward Token
  const rewardTokenSymbol = await rewardTokenContract.symbol();

  const rewardToken: TokenWithRate = {
    address: rewardTokenAddress,
    decimals: await rewardTokenContract.decimals(),
    name: await rewardTokenContract.name(),
    symbol: rewardTokenSymbol,
    assetRateUsd: REWARD_TOKEN_MARKET_RATE_USD,
  };

  // Relay Auction Info
  const rngRelayLastSequenceId = auctionContracts.rngAuctionContract.lastSequenceId();

  const lastSequenceCompleted = await auctionContracts.rngRelayAuctionContract.isSequenceCompleted(
    rngRelayLastSequenceId,
  );

  const rngRelayIsAuctionOpen =
    rngRelayLastSequenceId > 0 && rngContext.rngIsRngComplete && !lastSequenceCompleted;

  // Relayer Reward
  let rngRelayExpectedReward, rngRelayExpectedRewardUsd;
  if (rngRelayIsAuctionOpen) {
    const [randomNumber, completedAt] =
      await auctionContracts.rngAuctionContract.callStatic.getRngResults();
    const rngLastAuctionResult = await auctionContracts.rngAuctionContract.getLastAuctionResult();
    const elapsedTime = Math.floor(Date.now() / 1000) - Number(completedAt.toString());

    const rngRelayRewardFraction =
      await auctionContracts.rngRelayAuctionContract.computeRewardFraction(elapsedTime);

    const auctionResult = {
      rewardFraction: rngRelayRewardFraction,
      recipient: RELAYER_ADDRESS,
    };

    const auctionResults = [];
    auctionResults[0] = rngLastAuctionResult;
    auctionResults[1] = auctionResult;

    const rngRelayExpectedRewardResult =
      await auctionContracts.rngRelayAuctionContract.callStatic.computeRewards(auctionResults);
    rngRelayExpectedReward = rngRelayExpectedRewardResult[1];

    rngRelayExpectedRewardUsd =
      parseFloat(formatUnits(rngRelayExpectedReward.toString(), rewardToken.decimals)) *
      rewardToken.assetRateUsd;
  }

  return {
    prizePoolOpenDrawEndsAt,
    rewardToken,
    rngRelayIsAuctionOpen,
    rngRelayExpectedReward,
    rngRelayExpectedRewardUsd,
    rngRelayLastSequenceId,
  };
};

3. Calculating Profitability

Now that we have all of that information about the state of the auctions and the cost of the RNG fee we can figure out how much could be earned by starting and completing the RNG process.

If the first phase RngAuction#startRngRequest is open, we will need to check if we can afford the RNG Fee (denominated in LINK), and if we have provided enough allowance to the contract to spend our relayer’s LINK. If the allowance is not high enough it will set it to maximum (as we trust the security of this contract. You may not want to set yours to max):

const checkBalance = (context: DrawAuctionContext) => {
  const cannotAffordRngFee = context.relayer.rngFeeTokenBalance.lt(context.rngFeeAmount);
  if (cannotAffordRngFee) {
    console.warn(
      `Need to increase relayer/bot's balance of '${context.rngFeeToken.symbol}' to pay RNG fee.`,
    );
  } else {
    console.log('Sufficient balance âś”');
  }
};

const increaseRngFeeAllowance = async (
  signer,
  context: DrawAuctionContext,
  auctionContracts: AuctionContracts,
) => {
  const rngFeeTokenContract = new ethers.Contract(context.rngFeeToken.address, ERC20Abi, signer);

  const allowance = context.relayer.rngFeeTokenAllowance;

  if (allowance.lt(context.rngFeeAmount)) {
    const tx = await rngFeeTokenContract.approve(
      auctionContracts.chainlinkVRFV2DirectRngAuctionHelperContract.address,
      ethers.constants.MaxInt256,
    );
    await tx.wait();
  }
};

—

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.

Now we can find out if we will make profit on this transaction:

const calculateProfit = async (
  gasCostUsd: number,
  context: DrawAuctionContext,
): Promise<boolean> => {
  const grossProfitUsd =
    context.state === DrawAuctionState.RngStartVrfHelper
      ? context.rngExpectedRewardUsd
      : context.rngRelayExpectedRewardUsd;

  let netProfitUsd;
  if (context.state === DrawAuctionState.RngStartVrfHelper) {
    netProfitUsd = grossProfitUsd - gasCostUsd - context.rngFeeUsd;
  } else {
    netProfitUsd = grossProfitUsd - gasCostUsd;
  }

  const profitable = netProfitUsd > MIN_PROFIT_THRESHOLD_USD;

  return profitable;
};

4. Executing Transactions

Last but not least, we need to determine which contract we want to transact with using the DrawAuctionState, and then send the transaction.

The reason we use the ChainlinkVRFV2DirectRngAuctionHelper is that it takes care of transferring the ERC20 RNG fee token, making it easier for bots to interact with the RngAuctioncontract.

The RngAuctionRelayerRemoteOwnerContract is used when the RNG is on one chain and needs to be relayed to a prize pool on another chain. Otherwise we could use the RngAuctionRelayerDirect#relay function directly.

const sendTransaction = async (
  relayer: Relayer,
  auctionContracts: AuctionContracts,
  context: DrawAuctionContext,
) => {
  let populatedTx: PopulatedTransaction;
  if (context.drawAuctionState === DrawAuctionState.RngStartVrfHelper) {
    const chainlinkRngAuctionHelper = auctionContracts.chainlinkVRFV2DirectRngAuctionHelperContract;
    populatedTx = await chainlinkRngAuctionHelper.populateTransaction.transferFeeAndStartRngRequest(
      REWARD_RECIPIENT,
    );
  } else {
    populatedTx =
      await auctionContracts.rngAuctionRelayerRemoteOwnerContract.populateTransaction.relay(
        ERC_5164_MESSAGE_DISPATCHER_ADDRESS[RNG_CHAIN_ID],
        RELAY_CHAIN_ID,
        auctionContracts.remoteOwnerContract.address,
        auctionContracts.rngRelayAuctionContract.address,
        REWARD_RECIPIENT,
      );
  }

  const tx = await relayer.sendTransaction({
    data: populatedTx.data,
    to: populatedTx.to,
    gasLimit: 8000000,
  });

  console.log('Transaction hash:', tx.hash);
};

Finishing Up

That wraps up Part 3 and this series of tutorials on the new PoolTogether hyperstructure bots.

Just in case you missed it: in Part 1 we looked at making profit with Liquidation bots — whose job is to liquidate interest-bearing (yield) tokens for POOL to supply the prize pool with.

In Part 2 we looked at Prize Claiming bots — a way to earn POOL while automating the claiming of prizes every day on behalf of winners.

If you have any questions don’t hesitate to ask 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.