Tutorial duration: ~20 minutes
UPDATE: This is the old method of running bots. There is a lot of good information in these blog posts but they may differ from the new bot code. See the Cabana docs for the latest on G9’s PoolTogether bots:
The new version of PoolTogether (called the hyperstructure) features incentivization in place of governance. This ensures that the protocol can exist and function for many generations to come without the need for any voters or admin staff.
In order for this to work, mechanism design was baked into the protocol so that those with technical know-how (or those interested in learning) of running bots are incentivized to do so.
In this tutorial we will be walking through creating and running a successful Arbitrage Swap bot — this bot will run every few minutes, find profitable trades, and autonomously submit those transactions.
Where this differs from other arbitrage bots is that it does not run transactions on a typical DEX (such as Uniswap), but instead only submits transactions to run on the new PoolTogether Liquidation contracts. The Liquidator exists simply to convert yield that a Prize Vault has accrued into Prize Tokens.
—
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/arb-liquidator
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
Depositors in PoolTogether can deposit any ERC-20 crypto tokens so long as they have an EIP-4626 Vault to receive them. While the tokens are stored in the vault, they accrue yield (ie. when you deposit USDC to an Aave vault you receive Staked Aave DAI in return, an interest-bearing asset). This yield needs to be continually liquidated into prize tokens (currently the prize token is POOL), so that depositors who win prizes will be compensated in POOL instead of the interest-bearing asset.
The Liquidator has been designed so that anyone can run these swaps and profit from them. The bot sends POOL prize tokens and receives prize asset tokens in return.
Liquidation Pairs are the mechanism by which yield is liquidated. Each PoolTogether Vault will have one or more associated Liquidation Pairs. A Liquidation Pair is like a Uniswap pair, but it only supports swaps in a single direction.
Find Liquidation Pairs you would like to arb
Calculate most profitable swap
If profitable, execute swap
All of the Liquidation Pairs are created by the Liquidation Pair Factory. We can use that factory to list and iterate through each Liquidation Pair:
const liquidationPairFactoryContract = new ethers.Contract(
liquidationPairFactoryAddress,
LiquidationPairFactoryAbi,
readProvider
);
let liquidationPairContracts: Contract[] = [];
const numPairs = await liquidationPairFactoryContract.totalPairs();
for (let i = 0; i < numPairs; i++) {
const liquidationPairAddress =
await liquidationPairFactoryContract.allPairs(i);
const liquidationPairContract = new ethers.Contract(
liquidationPairAddress,
LiquidationPairAbi,
readProvider
);
liquidationPairContracts.push(liquidationPairContract);
}
Note: When this was written everything was being run against the Beta contracts. The list of Beta contract addresses and ABIs can be found here: https://github.com/GenerationSoftware/pt-v5-beta/tree/main/deployments
In order to make the decision if we should execute the swap or not, and for how much of the input token we will need to find out the current available liquidity, the cost of the POOL we will be sending vs. the value of interest-bearing tokens we will receive in return, and the gas fee for the transaction.
To compare with Uniswap’s language, POOL (the prize token) is called tokenIn
. The prize asset token (such as PTUSDC, PTWETH) is tokenOut
. And the underlyingAssetToken
is the original token deposited into the PoolTogether vault (the unwrapped interest-bearing token: eg. USDC, wETH, etc.).
—
Note: We’ll leave out how to get the gas cost and token values 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 tokens from a price feed such as Coingecko or Covalent, etc.
The liquidator uses a CGDA (continuous gradual dutch auction) to auction off yield.
Let’s start by figuring out how much POOL we will need to supply. We can do this by making callStatic
calls (callStatic
simulates an external
write function instead of actually sending a transaction) to the LiquidationPair’s maxAmountOut()
(the maximum amount available to receive in return for POOL), andcomputeExactAmountIn()
(how much POOL we need to supply):
const maxAmountOut = await liquidationPairContract.callStatic.maxAmountOut();
const exactAmountIn = await liquidationPairContract.callStatic.computeExactAmountIn(
wantedAmountOut,
);
However, it’s not efficient or most profitable to always swap the maximum amount out for the maximum amount in, so we will want a way to calculate a more profitable swap. The easiest way to compute the best swap is to first get the maxAmountOut
that we can receive right now, then split that up into many data points (we use 100 below) to find the optimal amountOut
:
const { originalMaxAmountOut, wantedAmountsOut } = await calculateAmountOut(
liquidationPairContract,
context,
);
// Calculates necessary input parameters for the swap call based on current state
// of the contracts
const calculateAmountOut = async (
liquidationPair: Contract,
context: ArbLiquidatorContext,
): Promise<{
originalMaxAmountOut: BigNumber;
wantedAmountsOut: BigNumber[];
}> => {
const wantedAmountsOut = [];
const amountOut = await liquidationPair.callStatic.maxAmountOut();
// Get multiple data points across the auction function to determine
// the most amount of profitability (most amount out for least amount
// of token in depending on the state of the gradual auction)
for (let i = 1; i <= 100; i++) {
const amountToSendPercent = i;
const wantedAmountOut = amountOut
.mul(ethers.BigNumber.from(amountToSendPercent))
.div(100);
wantedAmountsOut.push(wantedAmountOut);
}
return {
originalMaxAmountOut: amountOut,
wantedAmountsOut,
};
};
We can then use the output from these 100 data points (stored in wantedAmountsOut
) to find their corresponding wantedAmountsIn
:
import { BigNumber, ethers } from 'ethers';
import { Contract } from 'ethers';
import { Provider } from '@ethersproject/providers';
import { getLiquidationPairComputeExactAmountInMulticall }
from '@generationsoftware/pt-v5-autotasks-library';
const { amountIn, amountInMin, wantedAmountsIn } = await calculateAmountIn(
readProvider,
liquidationPairContract,
originalMaxAmountOut,
wantedAmountsOut,
);
// Calculates optimal input parameters for the swap call based on current
// state of the auction
const calculateAmountIn = async (
readProvider: Provider,
liquidationPairContract: Contract,
originalMaxAmountOut: BigNumber,
wantedAmountsOut: BigNumber[],
): Promise<{
amountIn: BigNumber;
amountInMin: BigNumber;
wantedAmountsIn: BigNumber[];
}> => {
let wantedAmountsIn = [];
const amountIn: BigNumber = await liquidationPairContract
.callStatic.computeExactAmountIn(originalMaxAmountOut);
const amountInMin = ethers.constants.MaxInt256;
wantedAmountsIn = await getLiquidationPairComputeExactAmountInMulticall(
liquidationPairContract,
wantedAmountsOut,
readProvider,
);
return {
amountIn,
amountInMin,
wantedAmountsIn,
};
};
—
Note: This is using multicall reads to speed up network calls. Instead of doing 100 RPC calls, we can do 1 RPC call with 100 queries. See the implementation here.
Let’s now pass the arrays of amounts in and amounts out to the a calculateProfit
helper function. This will create a new array with the gross profit of all 100 data points, which we can then compare (with Math.max()
) to find the maximum profit:
import { ethers, BigNumber } from 'ethers';
const { profitable, selectedIndex } = await calculateProfit(wantedAmountsIn, wantedAmountsOut);
// Calculates the amount of profit the bot will make on this swap
// and if it's profitable or not
const calculateProfit = async (
wantedAmountsIn: BigNumber[],
wantedAmountsOut: BigNumber[],
): Promise<{ profitable: boolean; selectedIndex: number }> => {
console.log('Gross profit = tokenOut - tokenIn');
const grossProfitsUsd = [];
for (let i = 0; i < wantedAmountsIn.length; i++) {
const amountOut = wantedAmountsOut[i];
const amountIn = wantedAmountsIn[i];
const underlyingAssetTokenUsd =
parseFloat(ethers.utils.formatUnits(amountOut, tokenOut.decimals)) *
underlyingAssetToken.assetRateUsd;
const tokenInUsd =
parseFloat(ethers.utils.formatUnits(amountIn, tokenIn.decimals)) * tokenIn.assetRateUsd;
const grossProfitUsd = underlyingAssetTokenUsd - tokenInUsd;
console.log(`Index ${i}: $${grossProfitUsd} = $${underlyingAssetTokenUsd} - $${tokenInUsd}`);
grossProfitsUsd.push(grossProfitUsd);
}
const getMaxGrossProfit = (grossProfitsUsd: number[]) => {
const max = grossProfitsUsd.reduce((a, b) => Math.max(a, b), -Infinity);
return { maxGrossProfit: max, selectedIndex: grossProfitsUsd.indexOf(max) };
};
const { selectedIndex, maxGrossProfit } = getMaxGrossProfit(grossProfitsUsd);
console.log(`Selected Index ${selectedIndex} - $${maxGrossProfit}`);
// Compare the profit with the gas costs to learn if transaction will be profitable
const estimatedProfitUsd = maxGrossProfit - gasFeeUsd;
const profitable = estimatedProfitUsd > 0;
return { profitable, selectedIndex };
};
This will output something like the following:
Example console.log output of all the data point comparisons for finding the optimal profit.
In this case data point 21 (with index 20) was the most profitable. If we wanted to run it to earn the $0.11 of profit we could, but it would likely be best to wait until we are further along in the auction when the gross profit will be higher.
When we find a profitable swap, we can execute the swap using the Liquidation Router. The Router provides one function to do so: swapExactAmountOut()
allows the caller to define the expected number of output tokens.
We have been using OpenZeppelin Defender to run our bots every n minutes, which is where our transactionrelayer
instance comes from. However you could do this using ethers.js providers, a web3.js provider, Gelato, Chainlink Keepers, etc.
Next let’s check the relayer account’s POOL balance:
// Your Relayer (the EOA or "externally owned account") will be swapping POOL
// tokens, so it will need to have a POOL balance.
const relayerAddress = '0x49ca801A80e31B1ef929eAB13Ab3FBbAe7A55e8F';
// Check if tokenIn balance for relayer (bot) account is sufficient
const tokenInContract = new ethers.Contract(tokenInAddress, ERC20Abi, writeProvider);
const tokenInBalance = await tokenInContract.balanceOf(relayerAddress);
const sufficientBalance = tokenInBalance.gt(exactAmountIn);
if (!sufficientBalance) {
console.warn('Insufficient POOL balance.')
}
If the allowance of POOL for the relayer to the LiquidationRouter hasn’t been set previously, we will need to set that as well:
// Get allowance approval
const allowance = await tokenInContract.allowance(
relayerAddress,
liquidationRouterContract.address
);
// Note that we're setting it to the max allowance as we trust the security
// audits of the LiquidationRouter contract
if (allowance.lt(exactAmountIn)) {
const tx = await tokenInContract.approve(
liquidationRouterContract.address,
ethers.constants.MaxInt256
);
await tx.wait();
const newAllowanceResult = await tokenInContract.allowance(
relayerAddress,
liquidationRouterContract.address,
);
console.log('New allowance:', newAllowanceResult[0].toString());
} else {
console.log('Sufficient allowance âś”');
}
With the allowance taken care of, we can create and send the transaction:
if (profitable) {
const transactionPopulated = await
liquidationRouterContract.populateTransaction.swapExactAmountOut(
liquidationPair.address,
swapRecipient,
wantedAmountsOut[selectedIndex],
amountInMin,
Math.floor(Date.now() / 1000) + 100 // deadline
);
const transactionSentToNetwork = await relayer.sendTransaction({
data: transactionPopulated.data,
to: transactionPopulated.to,
gasLimit: 600000,
});
console.log('Transaction hash:', transactionSentToNetwork.hash);
}
Note: swapExactAmountOut()
exists on both the LiquidationPair contracts and the LiquidationRouter, however for your swaps to be successful you will need to run it on the LiquidationRouter.
Hopefully that gives you a good rundown of how to build an Arbitrage Liquidations swap bot with the new PoolTogether (hyperstructure) protocol.
In Part 2 we will look at Prize Claiming bots - another way to pocket profit while automating the claiming of prizes every day on behalf of winners.
In Part 3 we break down the Draw Auction bot, yet another way to profit by helping with the RNG (random number creation and bridging) process.
If you have any questions feel free to reach out to anyone in the PoolTogether Discord community. The #developers
channel is your best bet!
Happy arbing!
* Profit is not guaranteed. Make sure to monitor and tweak your bot code and settings to nail your profit margins!