DeFi arbitrage bots secret technique. How to fit everything into single transaction

Alexander Koval
Coinmonks
5 min readMay 19, 2023

--

Arbitrage bots are complex and effective instruments that can generate you passive income. With growing of DeFi space they became increasingly popular. But in order to be profitable you should follow best practices and avoid typical mistakes.

Usually DeFi trading bot consist of these components :

  • off-chain script that monitors liquidity pools and founds arbitrage opportunities
  • on-chain contract — smart contract that contains arbitrage logic
  • hot wallet — keeps assets for arbitrage and takes profit

Imagine the off-chain script found a profitable arbitrage opportunity somewhere in DeFi space and you need to take advantage of it ahead of your competitors. The arbitrage have to be done within single transaction otherwise you will waste a lot of time and likely lose battle to other bots.

Typical arbitrage involves operations with ETH and ERC-20 tokens, so the first idea that comes to mind is to have smart-contract on-duty pre-deployed and funded with tokens and whenever the arbitrage opportunity found, the script will trigger the transaction with specific parameters

This approach is naive and has serious disadvantages:

  • You have to pre-deploy your contract and pay gas fees — but what if opportunity will never be found — than you lose the gas fees you payed for deployment — that could be a lot in Ehtereum main net
  • You have to fund your contracts with ERC-20 tokens in advance and let is hold them for a while — this presents a security risk as you have to audit your contract carefully to make sure no-one can hack it and steal your assets — much easier to take care only of your private key if your funds are locked in a hot wallet, not in a contract
  • Once deployed, anyone can read your contract and steal your logic to re-use it for they own arbitrage bots those will compete with yours

Here is better strategy

  • Ideally you want to keep ETH and ERC-20 tokens on your hot wallet and keep your smart-contract code off-chain until arbitrage opportunity is found
  • Once arbitrage opportunity found your bot have to deploy smart contract, fund it with ETH and ERC-20 tokens, do the arbitrage and transfer profit back to hot wallet
  • All those actions have to be done within single transaction

Remember that deployment causing a transaction which means that all logic has to be done inside constructor. We can fund contract with ETH during deployment, but transferring ERC-20 tokens is a tricky part.
There are two ways to transfer ERC-20s: transfer and approve both those methods required additional transaction to token contract where sender is token holder. But we have only one transaction that is already used for deployment which means that token transfer have to be done inside constructor in order to fit our arbitrage into single transaction. So how to make it possible?

The solution is new EIP-2616 token standard— it contains permit function that uses digital signature and allows gas-less tokens transfer. Lot’s of tokens already supports this standard. First time I came across it while doing swap on 1Inch exchange — I was surprised to see signature request instead of approve transaction and started to dig further to discover the new method of spending approval. Here is one more article to explain how it works: https://www.quicknode.com/guides/ethereum-development/transactions/how-to-use-erc20-permit-approval/

EIP-2616 let us use the new strategy for token transfer and arbitrage within single transaction:

  • hot wallet prepares signature for permit function — this is done off-chain and doesn’t creates transaction
  • signature is passed to to constructor as an argument
  • inside constructor smart-contract calls permit function, than transferFrom function and gets ownership over the tokens
  • logic for arbitrage is executed
  • profit is transferred back to hot wallet

So hot wallet have only submit a single transaction — contract deployment with proper parameters, and once it mined the profit is taken.

However there is one problem — let’s take a look at permit function interface:

/**
* @dev Sets `value` as the allowance of `spender` over ``owner``'s tokens,
* given ``owner``'s signed approval.
*
* IMPORTANT: The same issues {IERC20-approve} has related to transaction
* ordering also apply here.
*
* Emits an {Approval} event.
*
* Requirements:
*
* - `spender` cannot be the zero address.
* - `deadline` must be a timestamp in the future.
* - `v`, `r` and `s` must be a valid `secp256k1` signature from `owner`
* over the EIP712-formatted function arguments.
* - the signature must use ``owner``'s current nonce (see {nonces}).
*
* For more information on the signature format, see the
* https://eips.ethereum.org/EIPS/eip-2612#specification[relevant EIP
* section].
*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;

As we can see we need to provide a spender’s address — but in our case the spender is a contract that yet have to be deployed! How we suppose to know what address will have the contract after deployment?

Fortunately contract addresses are predetermened based on deployer’s address and deployer’s nonce. Here is a thread on stackexchange that explains how contract addresses are computed in EVM: https://ethereum.stackexchange.com/questions/760/how-is-the-address-of-an-ethereum-contract-computed/761#761

Here is JS code that predicts contract address before deployment based on deployer’s address and nonce:

const rlp = require("rlp");
const keccak = require("keccak");

async function predictContractAddress(deployer) {

const nonce = await ethers.provider.getTransactionCount(deployer.address);
const sender = deployer.address;

const input_arr = [sender, nonce];
const rlp_encoded = rlp.encode(input_arr);

const contract_address_long = keccak("keccak256")
.update(rlp_encoded)
.digest("hex");

const contract_address = contract_address_long.substring(24); //Trim the first 24 characters.
return contract_address;
}

So now we have all data to prepare the signature for spending approval and we can pass it to constructor. Here is full code for script running on Hardhat:

async function doArbitrage(deployer, value, tokenBalance, ...params) {
const chainId = (await ethers.provider.getNetwork()).chainId;
// set the domain parameters
const domain = {
name: await token.name(),
version: "1",
chainId: chainId,
verifyingContract: token.address
};

// set the Permit type parameters
const types = {
Permit: [{
name: "owner",
type: "address"
},
{
name: "spender",
type: "address"
},
{
name: "value",
type: "uint256"
},
{
name: "nonce",
type: "uint256"
},
{
name: "deadline",
type: "uint256"
},
],
};

const predictedAddress = await predictContractAddress(deployer);
console.log("Predicted address: " + predictedAddress);

const deadline = (await time.latest()) + 9999999;
const values = {
owner: deployer.address,
spender: predictedAddress,
value: tokenBalance,
nonce: await token.nonces(deployer.address),
deadline: deadline,
};
const signature = await deployer._signTypedData(domain, types, values);
const sig = ethers.utils.splitSignature(signature);

const arbitrageContract = await (await ethers.getContractFactory('Arbitrage', deployer)).deploy(...params, sig.v, sig.r, sig.s, { value });

console.log(`Actual address: ${arbitrageContract.address}`);
}

And the code of smart-contract constructor:

import "@openzeppelin/contracts/interfaces/IERC2612.sol";
import "@openzeppelin/contracts/utils/Address.sol";

contract Arbitrage {
using Address for address payable;
constructor (/* parameters for arbitrage */, address _token, uint256 tokenBalance, uint256 _deadline, uint8 v, bytes32 r, bytes32 s) payable {
IERC2612 token = IERC2612(_token);
token.permit(
msg.sender,
address(this),
tokenBalance,
_deadline,
v,
r,
s
);
token.transferFrom(msg.sender, address(this), tokenBalance);
// now contract has tokens and ETH and you can do your arbitrage here
// ...
token.transfer(msg.sender, token.balanceOf(address(this))); // send tokens back to hot wallet
payable(msg.sender).sendValue(address(this).balance); // send ETH back to hot wallet
}
}

And don’t forget to use Flashbots RPC instead of Infura to protect your arbitrage transactions from front-running

Inspired by “Puppet” challenge on Damn Vulnerable DeFi

--

--

Alexander Koval
Coinmonks

I'm full-stack web3.0 developer, dedicated to bring more justice in this world by adopting blockchain technologies