Gas-less way to purchase ETH for USDC
Part 6 — Gas provider bot
Previous parts
- Part 1- the system design
- Part 2 — Gas Broker contract
- Part 3 — Testing
- Part 4 — User interface for customer
- Part 5 — Backend server
Source code
Gas Provider helper smart-contract:
Gas Provider bot:
The most efficient way to take profit from customer commissions is to set up a bot that will monitor orders pool and execute transactions. The bot should be autonomous and require minimum maintenance. That’s why it should automatically perform reverse swaps USDC -> ETH — this way hot wallet will never run out of ETH and ETH balance will continuously increasing due to commissions receiving form customers.
The parts of system
Gas provider bot consists of several components:
- Backend script
- Hot wallet
- Helper smart-contract
First two parts are obvious — but why do we need yet another smart contract? Let’s imagine how would we do the reverse swap USDC to ETH. First — we need to approve USDC tokens for Uniswap router, then we need to execute trade USDC to WETH and then “unwrap” WETH to get native ETH. Those are 3 separate transactions. We can do some optimization and approve unlimited spending of USDC — so we can do it once instead of repeating this operation before each trade but we still have another 2 transactions those needs to be executed one-by-one. This will slow down the swap as we have to wait until first transaction is settled before starting second one. In some scenarios first transaction could settle and second could fail and we’ll need a complex logic to handle this situation.
Implementing reverse swaps USDC -> ETH
Would be much better to combine these 2 steps into single transaction and the only way to do in EVM is to include them into smart-contract function. The first function of helper contract is swapTokensForGas
function swapTokensForGas(address token) external {
uint256 tokenBalance = IERC20(token).balanceOf(msg.sender);
SafeERC20.safeTransferFrom(IERC20(token), msg.sender, address(this), tokenBalance);
IERC20(token).approve(address(swapRouter), tokenBalance);
uint256 wethBalance = swapExactInputSingle(token, tokenBalance);
weth.withdraw(wethBalance);
payable(msg.sender).sendValue(wethBalance);
}
This function will swap all tokens form sender to WETH and then “unswap” WETH and send equal amount of ETH to sender. swapExactInputSingle
is single swap method using Uniswap V3
function swapExactInputSingle(address tokenIn, uint256 amountIn) private returns (uint256 amountOut) {
ISwapRouter.ExactInputSingleParams memory params =
ISwapRouter.ExactInputSingleParams({
tokenIn: tokenIn,
tokenOut: address(weth),
fee: 500,
recipient: address(this),
deadline: block.timestamp,
amountIn: amountIn,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
});
amountOut = swapRouter.exactInputSingle(params);
}
Gas provide bot will have swapBack
endpoint that should be triggered periodically — it will check hot wallet balance and trigger calls to swapTokensForGas
if USDC balance exceeds threshold:
async function handler(_, res) {
const logger = getLogger({ msgPrefix: `[swapBack] ` })
const tokenBalance = await getTokenBalance(USDC_ADDRESS, account.address)
logger.info(`Token balance is: ${tokenBalance}`)
if (tokenBalance > USDC_TRESHOLD * USDC_DECIMALS_RATIO) {
await swapTokensToGas(tokenBalance)
logger.info('SwapBack is completed')
}
const gasBalance = await viemClient.getBalance({
address: account.address
})
logger.info(`Gas balance is: ${gasBalance}`)
if (gasBalance > GAS_TRESHOLD) {
const hash = await sendSurplusToColdWallet(gasBalance)
logger.info(`Gas surplas been sent to cold wallet. Tx id: ${hash}`)
}
res.send(200)
}
Helper contract has one more important function — see next paragraph.
Executing large orders while having low liquidity
Hot wallets are vulnerable for attacks that’s why it’s better not to keep there high balance. That’s why gas-provider bot will periodically send the surplus of gas tokens to cold wallet and only keep minimal operational amount on balance. But there is a downside — what if customer wants to exchange 10K of USDC to ETH and willing to pay 100 USDC commission for this trade? Since our bot always have low balance in hot wallet does it mean that we are missing out on this deal? No — we still can execute this swap and take a profit even having no more than $15 worth of ETH in hot wallet.
How could it be possible? The answer is — FlashLoans. We can borrow the value that needs to be sent to customer and then use customer’s tokens to re-pay FlashLoan. Here is are the steps:
- Borrow required amount of WETH from Uniswap pool using Flash Loan
- “Unwrap” WETH — now helper smart contract has enough balance to perform exchange
- Execute swap function from Gas Broker contract providing borrowed liquidity as a value
- Receive USDC tokens and swap them to WETH using Uniswap pool
- Repay Flash Loan + fee from WETH balance
- “Unwrap” remaining WETH and send ETH back to Gas provider
Following this strategy Gas provider could execute million-dollar trade and collect heavy commission while initially having balance enough to pay for only one transaction. Let’s implement this logic in second function of helper contract:
function swapWithFlashloan(
address pool,
address token,
uint256 weiToBorrow,
bytes memory swapCalldata
) external payable returns (uint256 balanceAfter) {
// borrow WMATIC
bytes memory data = abi.encode(
msg.sender,
msg.value,
token,
weiToBorrow,
swapCalldata
);
IUniswapV3Pool(pool).flash(address(this), weiToBorrow, 0, data);
// needs for profit evaluation during simulation
balanceAfter = msg.sender.balance;
}
This function takes Uniswap pool address, token contract address, amount of ETH to borrow and swapCalldata
as arguments. swapCalldata
contains encoded call to swap
function from Gas Broker contract — swap function has 11 arguments and if we’ll try to pass them without encoding we’ll run into “stack too deep” error. More details:
flash
function will call method in Uniswap pool and this method will provide tokens to caller and call callback function uniswapV3FlashCallback
uniswapV3FlashCallback
function will be called from pool, this means that sender and parameters would be different. The only way to identify original sender and parameters is to get it from data
argument — that’s why we’ve encoded those values in swapWithFlashloan
function. More details about Uniswap V3 flash loans:
Here is the code of uniswapV3FlashCallback:
function uniswapV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external {
(
address gasProvider,
uint256 extraValue,
address token,
uint256 weiToBorrow,
bytes memory swapCalldata
) = abi.decode(
data,
(address,uint256,address,uint256,bytes)
);
weth.withdraw(weiToBorrow);
(bool success, bytes memory data) = gasBroker.call{ value: extraValue + weiToBorrow }(swapCalldata);
require(success, "Swap failed");
swapExactInputSingle(token, IERC20(token).balanceOf(address(this)));
weth.deposit{value: address(this).balance}();
uint256 amountToRepay = weiToBorrow + fee0;
require(weth.balanceOf(address(this)) >= amountToRepay, "Cannot repay flashloan");
weth.transfer(msg.sender, amountToRepay);
uint256 wethBalance = weth.balanceOf(address(this));
if (wethBalance > 0) {
weth.withdraw(wethBalance);
payable(gasProvider).sendValue(wethBalance);
}
}
uniswapV3FlashCallback called when borrowed amount of WETH is already transferred to helper smart contract, the fees those needs to be payed in addition to borrowed amount are passed in parameters. We using low-level call to gasBroker because function name and all parameters are encoded in swapCalldata
Evaluation of profitability
In order to stay profitable Gas provider bot have to make sure that commission from customer exceeds the cost of gas to perform swap transaction. Cost of transaction depends on the amount of gas required for execution, cost of gas and market price of ETH. Here is the formula:
transactionCostUSD = gasRequired*gasPrice*gasTokenPrice
But before evaluating gasRequired
bot needs to decide whether Flash Loan is needed or if he can perform swap using his own balance and without paying fees to Uniswap pool. This logic is implemented in middlewares:
server.post(
'/order',
evaluateOrderSize,
getPricesData,
validateWithFlashloan,
validateWithoutFlashloan,
processOrder
);
There are 4 middlewares — first runs initial validation and makes a decision whether FlashLoan is needed:
async function handler(req, res) {
const response = schema.safeParse(req.body);
if (!response.success) {
res.send(400, response.error.errors)
throw new Error('invalid order')
}
req.order = response.data
const { value, reward, token, permitSignature } = response.data
const permitHash = keccak256(permitSignature)
req.permitHash = permitHash
const logger = getLogger({ msgPrefix: `[perValidation][${permitHash.slice(0,10)}] ` })
logger.info(response.data)
logger.info('Order format is correct')
const tokenAmount = BigInt(value) - BigInt(reward)
if (tokenAmount < 0 ) {
logger.error('Reward exceeds value')
throw new Error('invalid order')
}
const gasProviderBalance = await viemClient.getBalance({
address: account.address
})
req.gasProviderBalance = gasProviderBalance
const valueToSend = await getEthAmount(tokenAmount, token)
req.valueToSend = valueToSend
logger.info(`Account balance is ${gasProviderBalance}`)
logger.info(`Estimated anount of wei required for swap: ${valueToSend}`)
if (valueToSend > (gasProviderBalance + gasProviderBalance / 2n) ) {
req.useFlashLoan = true
logger.info('Flashloan is required')
} else {
req.useFlashLoan = false
logger.info('Can swap without flashloan')
}
}
Value that needs to be sent to customer in exchange for tokens is taken form getEthAmount
function of Gas Broker contract
Then this value is multiplied by 1.5 to prevent any price slippage at the time of transaction execution — gas price and ETH price could change by the moment transaction is included into block and this could cause slippage — that’s why value sent to swap function needs to exceed initial estimation — the change will be sent back anyway.
If hot wallet doesn’t have enough balance to perform swap then useFlashLoan
parameter set to true and the logic passed to the next middleware.
getPricesData
middleware prepares arguments for swap
function and fetches gas price and ETH price:
async function handler(req) {
const { signer, token, value, deadline, reward, permitSignature, rewardSignature } = req.order
const [permitV, permitR, permitS] = splitSignature(permitSignature)
const [rewardV, rewardR, rewardS] = splitSignature(rewardSignature)
const swapArgs = [
signer,
token,
value,
deadline,
reward,
permitV,
permitR,
permitS,
rewardV,
rewardR,
rewardS
]
const [maticPrice, gasPrice] = await Promise.all([
getMaticPrice(),
viemClient.getGasPrice()
])
const logger = getLogger({ msgPrefix: `[getPriceData][${req.permitHash.slice(0,10)}] ` })
logger.info(`Matic price: ${maticPrice}`)
logger.info(`Gas price: ${gasPrice}`)
req.swapArgs = swapArgs
req.maticPrice = maticPrice
req.gasPrice = gasPrice
}
Then depending in useFlashLoan
parameter one of the last 2 middlewares will skip it’s logic. Price evaluation is different in case of flash loan that’s why the logic is separated by 2 middlewares.
Price evaluation for the swap without flash loan:
if (req.useFlashLoan) {
return;
}
const logger = getLogger({ msgPrefix: `[validate][noFlashLoan][${req.permitHash.slice(0,10)}] ` })
try {
const maticRequired = await evaluate(logger)
logger.info(`MATIC price estimation: ${maticRequired}`)
logger.info(`req.maticPrice: ${req.maticPrice}`)
const usdCost = Number(maticRequired / 10n**13n) * req.maticPrice / 100000
const { reward } = req.order
logger.info(`Comission: $${reward / 10**6} Transaction cost: $${usdCost} Profit: $${reward / 10**6 - usdCost}`)
if (usdCost * PROFIT_FACTOR > reward / 10**6) {
logger.info('Order is not profitable and will be skipped')
throw new Error('Not profitable')
}
} catch (error) {
logger.error(error)
res.send(400, error)
throw new Error('invalid order')
}
async function simulate(valueParam) {
const result = await viemClient.simulateContract({
address: GAS_BROKER_ADDRESS,
abi: gasBrokerABI,
functionName: 'swap',
account,
args: req.swapArgs,
value: valueParam,
gas: GAS_LIMIT
})
return result.request
}
async function estimateGas(valueParam) {
return viemClient.estimateContractGas({
address: GAS_BROKER_ADDRESS,
abi: gasBrokerABI,
functionName: 'swap',
account,
value: valueParam,
args: req.swapArgs
})
}
async function evaluate(logger) {
const valueParam = req.valueToSend + req.valueToSend / 3n
logger.info(`Value param: ${valueParam}`)
req.transaction = await simulate(valueParam)
logger.info('Order is valid')
const gasRequired = await estimateGas(valueParam)
logger.info(`Gas estimation: ${gasRequired}`)
return req.gasPrice * gasRequired
}
In this case we evaluate gas needed for call swap
method in Gas Broker contract. Then gas cost multiplied to gas price and ETH price (MATIC in this case) and we have USD cost of transaction. If commission is less than double of transaction cost than this trade is considered not profitable and will be skipped.
Here is price evaluation for flash-loan swap:
async function evaluate(logger, valueToSend, swapArgs) {
const valueParam = valueToSend + valueToSend / 3n
logger.info(`Value param: ${valueParam}`)
const swapCalldata = encodeFunctionData({
abi: gasBrokerABI,
functionName: 'swap',
args: swapArgs
})
const contractCallParams = {
address: GAS_PROVIDER_HELPER_ADDRESS,
abi: gasProviderHelperAbi,
functionName: 'swapWithFlashloan',
account,
args: [
UNISWAP_USDC_WMATIC_POOL_ADDRESS,
USDC_ADDRESS,
valueParam,
swapCalldata
],
value: valueParam,
gas: GAS_LIMIT
}
const { request, result } = await viemClient.simulateContract(contractCallParams)
logger.info('Order is valid')
const gasRequired = await viemClient.estimateContractGas(contractCallParams)
logger.info(`Gas estimation: ${gasRequired}`)
return {
gasRequired,
transaction: request,
balanceAfter: result
}
}
async function handler(req, res) {
if (!req.useFlashLoan) {
return;
}
const logger = getLogger({ msgPrefix: `[validate][withFlashLoan][${req.permitHash.slice(0,10)}] ` })
try {
const { transaction, gasRequired, balanceAfter } = await evaluate(logger, req.valueToSend, req.swapArgs)
const transactionCostInWei = gasRequired * req.gasPrice
const profitInWei = balanceAfter - req.gasProviderBalance
req.transaction = transaction
logger.info(`WEI price for transaction: ${transactionCostInWei}`)
logger.info(`WEI returned after transaction: ${profitInWei}`)
if (profitInWei < transactionCostInWei * BigInt(PROFIT_FACTOR)) {
logger.info('Order is not profitable and will be skipped')
throw new Error('Not profitable')
}
const profitInUsd = Number((profitInWei - transactionCostInWei) / 10n**13n) * req.maticPrice / 100000
logger.info(`Estimated profit: $${profitInUsd}`)
} catch (error) {
logger.error(error)
res.send(400, error)
throw new Error('invalid order')
}
}
That’s why swapWithFlashloan
function needs to return sender’s balance at the end of transaction — it allows us to run simulation and estimate how much ETH will be returned to Gas provider. If gas provider balance increment after swap is less than double of transaction cost in ETH than trade considered unprofitable and will be skipped.
Here is real transaction in Polygon explorer:
You can see that it was initiated by account 0xd733dE10b28D6AEe6C54B452D1C6856AC34234e4
— this account never had a balance more than $17.63 (at the moment of transaction it was even lower)— this is a hot wallet of Gas Provider bot
And yet this account was able to perform swap of 31.62 USDC to 38.46456628 MATIC using Uniswap V3 flash loan — you can see all steps in polygon explorer:
The last middleware is executes and order and replies with transaction hash:
async function handler(req, res) {
const logger = getLogger({ msgPrefix: `[executor][${req.permitHash.slice(0,10)}] ` })
logger.info('Executing transaction...')
try {
const hash = await walletClient.writeContract(req.transaction)
logger.info(`Transaction hash: ${hash}`)
const balance = await viemClient.getBalance({
address: account.address
})
logger.info(`Gas provider balance: ${balance}`)
res.send(200, { hash })
} catch (error) {
logger.error('Transaction failed', error)
logger.error(error)
res.send(400, { error })
}
next();
}