Skip to main content

How to Create a Uniswap Bot with Streams & Filters

Updated on
Oct 31, 2024

25 min read

Overview

Building a trading bot covers multiple components, like fetching blockchain data, implementing the proper data parsing, and then creating your strategy. Luckily, you can use QuickNode's Streams and Filters tools to solve the common data retrieval and cleaning tasks, minimizing the time you need to spend to build and manage an accurate and efficient data pipeline. In this guide, we will show you how to use Streams and Streams Filters to build your own trading bot, which listens to ETH => USDC swaps on Uniswap V3.

Let's get started!

What You Will Need


DependencyVersion
node^18.18
dotenv^16.4.5
ethers^6.13.1
express^4.19.2

What You Will Do


  • Recap how Streams and Streams Filters by QuickNode works
  • Prepare a local Webhook to listen and parse incoming Streams data
  • Create a Stream + Streams Filter on QuickNode to pull in real-time blockchain data and parse for incoming Uniswap V3 swaps on the USDC/ETH pair

Streams

Streams is a blockchain data solution you can use to retrieve real-time and historical block data for several chains like Ethereum, Optimism, Base, and more. With Streams, you can send data to a number of destinations like Webhooks, S3 Buckets, PostgreSQL, Snowflake, and Functions. Streams allow you to select specific data schemas (e.g., Blocks, Transactions, Receipts, Logs, Traces) to cater to different data needs and help manage the RPC infrastructure for you. Learn more on the Streams - Documentation page.

Streams & Filters

To make Streams even more powerful, you can use Streams Filters to parse blockchain data before it is sent to you. Currently, Streams Filters support JavaScript (ECMAScript) code and can be set two ways, within the QuickNode dashboard or through the Streams REST API.

Implementing Streams Filters has several advantages, such as:


  • Cost Efficiency: Incur charges only for the data that is filtered and sent to your destination, which minimizes the cost of transmitting unwanted data
  • Payload Customization: Tailer the payload from your Stream before it is sent to your destination

Now that we have a better understanding of Streams and Filters let's get into the coding.

Project Prerequisite: Create an Ethereum Node Endpoint

While we don't need access to a node endpoint to power our Streams, we do need it when sending a swap transaction on Uniswap V3. While we could run our own node, here at QuickNode, we make it quick and easy to fire up blockchain nodes. You can register for an account here. Once you create an Ethereum Sepolia endpoint, retrieve the HTTP URL. It should look like this:

Screenshot of Quicknode mainnet endpoint

Project Prerequisite: Get ETH from QuickNode Multi-Chain Faucet

In order to swap tokens on-chain, you'll need ETH to pay for gas fees. Since we're using the Sepolia testnet, we can get some test ETH from the Multi-Chain QuickNode Faucet.

Navigate to the Multi-Chain QuickNode Faucet and connect your wallet (e.g., MetaMask, Coinbase Wallet) or paste in your wallet address to retrieve test ETH. Note that there is a mainnet balance requirement of 0.001 ETH on Ethereum Mainnet to use the EVM faucets. You can also tweet or log in with your QuickNode account to get a bonus!

Screenshot of Multi-Chain Faucet

Prepare the Webhook

For the purpose of creating a trading bot, a Webhook destination may be more fit than using S3 and PostgreSQL. We will be using an Express.js server to retrieve the Stream data, analyze it, and then make a decision to buy. This is how the logic looks step by step:


  • Streams will be listening to incoming Block with Receipts from each new block generated on Ethereum Sepolia
  • The Streams Filters code will parse through transaction receipt data, filtering for ETH purchases via the ETH/USDC pool. It will do this specifically by:
  1. Filtering for the exactInputSingle swap method, which has a function signature of 0x04e45aaf. \
  2. Check the token being swapped for; in this case, it is the tokenOut field. We add a condition that it should be the WETH ADDRESS. We also verify the swap occured by checking that the Swap topic event hash exists in the logs of the Receipts data object.
  3. If the size of the trade is above some threshold (e.g., $100) the data is added to the array, then sent to the Webhook.
  4. Recieve the Stream notification on the Express.js server, verify that it is indeed over the threshold, and generate a Uniswap swap transaction to buy ETH via the ETH/USDC pool.

Note: The USDC threshold for triggering a swap is configurable. You can adjust the THRESHOLD_IN_USDC value in the filter code to set a smaller or greater amount based on your trading strategy.

danger

This trading bot is not meant to be used in production and is only for demonstration purposes to showcase some use cases with the power of Streams + Filters.

Now on to the code.

Project Set Up

Navigate to the location you want your project to live in, then run the following commands:

mkdir streams-filters-uniswap-bot
cd streams-filters-uniswap-bot
npm init es6 -y
npm i dotenv ethers express
echo > index.mjs

Next, you'll need to configure the .env file with your private key and endpoint URL, use the following format:

RPC_URL=https://ethereum-sepolia.quiknode.pro/AUTHTOKEN/
PRIVATE_KEY=YOUR_PRIVATE_KEY

Remember to save the file.

You'll also need to download the ABIs from Etherscan by referencing the addresses below:


Alternatively, you can look at this repository and move the abis folder into your project folder. Remember to save the files.

With our project set up with our dependencies, .env configuration and ABIs, let's set up the script.

Prepare the JavaScript Script

Open the index.mjs file and copy-paste the following code:

import express from 'express';
import { ethers } from 'ethers';
import QUOTER_ABI from './abis/quoter.json' assert { type: 'json' };
import SWAP_ROUTER_ABI from './abis/swaprouter.json' assert { type: 'json' };
import TOKEN_IN_ABI from './abis/weth.json' assert { type: 'json' };
import dotenv from 'dotenv';

dotenv.config();

const app = express();
app.use(express.json({ limit: '2mb' }));
const port = 8000;

// Deployment Addresses
const SWAP_ROUTER_ADDRESS = ethers.getAddress('0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E');
const QUOTER_ADDRESS = ethers.getAddress('0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3');
const WETH_ADDRESS = ethers.getAddress('0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14');
const USDC_ADDRESS = ethers.getAddress('0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238');

// Provider, Contract & Signer Instances
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const quoterContract = new ethers.Contract(QUOTER_ADDRESS, QUOTER_ABI, provider);
const signer = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
let hasSwappedToday = false;

// Token Configuration
const WETH = {
chainId: 11155111,
address: WETH_ADDRESS,
decimals: 18,
symbol: 'WETH',
name: 'Wrapped Ether'
};

const USDC = {
chainId: 11155111,
address: USDC_ADDRESS,
decimals: 6,
symbol: 'USDC',
name: 'USD//C'
};

app.use(express.json());

async function quoteAndLogSwap(quoterContract, fee, signer, amountIn) {
try {
const quotedAmountOut = await quoterContract.quoteExactInputSingle.staticCall({
tokenIn: WETH_ADDRESS,
tokenOut: USDC_ADDRESS,
fee: fee,
amountIn: amountIn,
sqrtPriceLimitX96: 0,
});

const amountOut = quotedAmountOut[0];

console.log(`-------------------------------`);
console.log(`Token Swap will result in: ${ethers.formatUnits(amountOut, 6)} USDC for ${ethers.formatEther(amountIn)} WETH`);
return amountOut;
} catch (error) {
console.error('Error in quoteAndLogSwap:', error);
throw error;
}
}

async function checkAndApproveToken(tokenAddress, spenderAddress, amount, signer) {
const tokenContract = new ethers.Contract(tokenAddress, TOKEN_IN_ABI, signer);
const currentAllowance = await tokenContract.allowance(signer.address, spenderAddress);

if (currentAllowance < amount) {
console.log('Insufficient allowance. Approving tokens...');
console.log(`-------------------------------`);
const approvalTx = await tokenContract.approve(spenderAddress, amount);
await approvalTx.wait();
console.log('Approval transaction confirmed');
console.log(`-------------------------------`);
} else {
console.log('Sufficient allowance already exists');
console.log(`-------------------------------`);
}
}


async function swapEthToUsdc(ethAmount) {
const swapRouter = new ethers.Contract(SWAP_ROUTER_ADDRESS, SWAP_ROUTER_ABI, signer);

// Convert ETH amount to Wei
const amountIn = ethers.parseEther(ethAmount.toString());

// Check ETH balance
const balance = await provider.getBalance(signer.address);
console.log(`-------------------------------`);
console.log(`ETH Balance: ${ethers.formatEther(balance)} ETH`);
console.log(`-------------------------------`);

if (balance < amountIn) {
console.error('Insufficient ETH balance');
return;
}

try {
console.log(`Swapping ${ethAmount} ETH for USDC...`);

// Get quote
let quotedAmountOut;
try {
quotedAmountOut = await quoteAndLogSwap(quoterContract, 3000, signer, amountIn);
console.log(`-------------------------------`);
console.log(`Quoted amount: ${ethers.formatUnits(quotedAmountOut, 6)} USDC`);
console.log(`-------------------------------`);
} catch (quoteError) {
console.error('Error getting quote:', quoteError);
return;
}

// Check and approve WETH if necessary
await checkAndApproveToken(WETH_ADDRESS, SWAP_ROUTER_ADDRESS, amountIn, signer);

// Calculate minimum amount out with 5% slippage
const minAmountOut = quotedAmountOut * 95n / 100n;

// Set up the parameters for the swap
const params = {
tokenIn: WETH_ADDRESS,
tokenOut: USDC_ADDRESS,
fee: 3000, // 0.3% fee tier
recipient: await signer.getAddress(),
deadline: Math.floor(Date.now() / 1000) + 60 * 20, // 20 minutes from now
amountIn: amountIn,
amountOutMinimum: minAmountOut,
sqrtPriceLimitX96: 0
};

// Estimate gas
const gasEstimate = await swapRouter.exactInputSingle.estimateGas(params, { value: amountIn });
console.log('Estimated gas:', gasEstimate.toString());
console.log(`-------------------------------`);

// Get current gas price
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice;
console.log('Current gas price:', ethers.formatUnits(gasPrice, 'gwei'), 'gwei');
console.log(`-------------------------------`);
// Execute the swap
console.log('Sending transaction...');
const tx = await swapRouter.exactInputSingle(params, {
nonce: await signer.getNonce(),
value: amountIn,
gasLimit: gasEstimate * 12n / 10n, // Add 20% buffer to gas estimate
gasPrice: gasPrice
});

console.log(`Transaction sent. Hash: ${tx.hash}`);
console.log(`-------------------------------`);
console.log('Waiting for transaction confirmation...');
console.log(`-------------------------------`);

const receipt = await tx.wait();
console.log(`Transaction confirmed in block ${receipt.blockNumber}`);
console.log(`-------------------------------`);

if (receipt.status === 1) {
console.log('Transaction successful');
// Find the Transfer event for USDC
const usdcTransferEvent = receipt.logs.find(log =>
log.address.toLowerCase() === USDC_ADDRESS.toLowerCase() &&
log.topics[0] === ethers.id("Transfer(address,address,uint256)")
);

if (usdcTransferEvent) {
console.log(`-------------------------------`);
const amountOut = ethers.dataSlice(usdcTransferEvent.data, 0);
console.log(`Received ${ethers.formatUnits(amountOut, 6)} USDC`);
console.log(`-------------------------------`);
return ethers.formatUnits(amountOut, 6);
} else {
console.log('USDC transfer event not found in logs');
return '0';
}
} else {
console.log('Transaction failed');
return '0';
}

} catch (error) {
console.error('Error during swap:', error);
if (error.transaction) {
console.error('Transaction details:', error.transaction);
}
if (error.receipt) {
console.error('Transaction receipt:', error.receipt);
}
throw error;
}
}

app.post('/swap', async (req, res) => {
try {
// Check if a swap has already been executed
if (hasSwappedToday) {
console.log('A swap has already been executed');
return res.status(200).json({
success: false,
message: 'Swap limit reached'
});
}

const { result, message } = req.body;

// Check for invalid request body
if (!result && !message) {
console.log('Invalid request body');
return res.status(200).json({
success: false,
message: 'Invalid request body'
});
}

// Handle "Condition met" case
if (result && Array.isArray(result) && result.length > 0) {
const transaction = result[0]; // Assuming we're interested in the first matching transaction
console.log(`Transaction detected in block ${transaction.block}, amount: ${transaction.amount0USDC} USDC, hash: ${transaction.transactionHash}`);

const THRESHOLD_IN_USDC = 0.50;
const validSwap = result.find(item => Math.abs(parseFloat(item.amount0USDC)) >= THRESHOLD_IN_USDC);

if (validSwap) {
const swappedAmount = await swapEthToUsdc(0.01); // Custom buy value

if (swappedAmount !== '0') {
hasSwappedToday = true; // Set the flag to true after a successful swap
console.log('Swap executed successfully. No more swaps will be executed.');
}

return res.status(200).json({
success: true,
message: 'Swap executed successfully',
swappedAmount: swappedAmount
});
} else {
console.log('No swap amount over the threshold');
return res.status(200).json({
success: false,
message: 'No swap amount over the threshold'
});
}
}

// Handle "Not met" case
if (message === "No transactions met the criteria") {
const blockNumber = req.body.block;
console.log(`No transactions met criteria in block ${blockNumber}`);
return res.status(200).json({
success: false,
message: 'No transactions met the criteria',
block: blockNumber
});
}

} catch (error) {
console.error('Error executing swap:', error);
return res.status(200).json({
success: false,
error: 'Failed to execute swap',
details: error.message,
transaction: error.transaction,
receipt: error.receipt
});
}
});

app.listen(port, () => {
console.log(`-------------------------------`);
console.log(`Server running on port ${port}`);
console.log(`-------------------------------`);
});

Here's a brief overview of the main functionality of the Express.js server code above:

We define a /swap POST endpoint that:

  • Receives filtered swap data from Streams
  • It checks if the fitlered data meets a certain criteria (e.g., a minimum USDC amount).
  • If a valid swap is found, it asks for user confirmation via the console.
  • If confirmed, it executes a swap of 0.01 WETH for USDC.

The swap process involves:

  • Approving the token spend.
  • Getting pool information.
  • Quoting the swap.
  • Preparing swap parameters.
  • Executing the swap transaction (sending the transaction)

**Note: This bot is configured to execute only one swap. After a successful swap, it will not perform any more trades until the server is restarted. This can be modified to be on a per time interval basis (e.g., per day).

The code includes error handling and logging throughout the process. Next, we'll run our Express.js server locally, then set it up to work remotely via ngrok.

Start the Express Server

To start your local express server, run the command below:

node index.mjs

You'll see an output such as: Server running on port 8000.

Next, we'll set up the Webhook API which we'll build with Express.js and ngrok.

Set Up ngrok

To make our Express server live on a remote server, we'll use ngrok. First, you'll need to ensure that ngrok is installed and your ngrok account is authenticated with your authtoken.

You can set up authentication with the following ngrok command:

ngrok authtoken [your_authtoken_here]

Once authenticated, start the remote server within the same project directory:

ngrok http 8000

You'll see an output similar to this:

ngrok image

The URL shown forwards API calls to your localhost. Now that our Express server is accessible publicly, we can move on to the next step, which is creating our Stream and Filters on QuickNode.

Set Up the Streams + Filters

Navigate to QuickNode Streams page and click Create Stream button.

On the Stream settings page, align the Stream to the following configuration:


  • Chain: Ethereum
  • Network: Sepolia
  • Dataset: Block with Receipts
  • Stream start: Latest block
  • Stream end: Input a block number end value here / leave as-is if you want your Stream to persist indefinitely
  • Stream payload: Modify the Stream before streaming

Then, utilize the following code to Filter the Stream. The code filters transaction receipts containing Uniswap V3 swaps from the USDC/ETH pair. Later in our Express.js script, we will further parse this data to get the exact swap amount and make decisions based off that.

function main(stream) {
try {
var block = stream[0].block;
var receipts = stream[0].receipts;
var transactions = stream[0].block.transactions;

const USDC_ADDRESS = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'.toLowerCase();
const FUNCTION_SIGNATURE = '0x04e45aaf';
const THRESHOLD_IN_USDC = 0.50; // Set your threshold here, e.g., 0.50 USDC

// Filter transactions
var filteredList = transactions.filter(tx => {
if (tx.input && tx.input.startsWith(FUNCTION_SIGNATURE)) {
let tokenOutAddress = '0x' + tx.input.slice(98, 138).toLowerCase();
return tokenOutAddress === USDC_ADDRESS;
}
return false;
});

// Match filtered transactions with receipts, check logs, and extract data
var result = filteredList.map(tx => {
var matchedReceipt = receipts.find(receipt => receipt.transactionHash === tx.hash);
if (matchedReceipt) {
var relevantLog = matchedReceipt.logs.find(log =>
log.topics &&
log.topics[0] === "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"
);

if (relevantLog) {
// Decode amount0 from logData for USDC
let amount0Wei = decodeAmount0(relevantLog.data);
let amount0USDC = weiToUSDC(amount0Wei);

// Only include in result if amount is over the threshold
if (Math.abs(amount0USDC) >= THRESHOLD_IN_USDC) {
return {
block: Number(block.number),
transactionHash: tx.hash,
amount0Wei: amount0Wei,
amount0USDC: amount0USDC
};
}
}
}
return null;
}).filter(Boolean); // Remove any null entries

// Check if the result array is empty
if (result.length === 0) {
return {
block: Number(block.number),
message: "No transactions met the criteria"
};
}

return {
result
};
} catch (e) {
return {
error: e.message
};
}
}

function decodeAmount0(logData) {
// Remove '0x' prefix if present
logData = logData.startsWith('0x') ? logData.slice(2) : logData;
// amount0 is the first parameter, so we start at index 0
let amount0Hex = logData.slice(0, 64);
// Convert hex to BigInt and then to string
let amount0 = BigInt('0x' + amount0Hex).toString();
// If the number is negative (first bit is 1), we need to convert it
if (amount0Hex[0] >= '8') {
amount0 = (BigInt(2) ** BigInt(256) - BigInt(amount0)).toString();
amount0 = '-' + amount0;
}
return amount0;
}

function weiToUSDC(weiAmount) {
// Convert wei to USDC (6 decimal places)
return Math.abs(parseFloat(weiAmount) / 1e6);
}

You can click the Run Test button to ensure your filter is syntactically correct. You can also test on a historical block that meets your threshold to ensure your Filter logic works.

The test output should look like this on both cases:

Condition met

{
"result": [
{
"block": 6226105,
"amount0USDC": 0.960396,
"amount0Wei": "-960396",
"transactionHash": "0x743a9dd612a7b9e6a4e0493c1b4655bbcde1e27eb5056072df0c8a7dbce0f809"
}
]
}

Not met

{
"block": 6227555,
"message": "No transactions met the criteria"
}

For the other Stream settings, we don't need to set anything else, however, if you want to detect re-orgs in real-time, you can enable the Latest block delay or Restream on reorg feature to handle re-orgs. You can learn more about re-orgs here. Once the page is finished, click Next and you'll need to configure your Stream destination.

Then, on the Stream destination page, set up the destination as Webhook and configure it to align with the following details below. If a field is not mentioned, you can leave it as-is.


After filling in the details, click the Test Destination button to verify the setup. This will send a sample of real data from Streams (raw or filtered, depending on your configuration) along with any custom headers you've defined.

Important: If you pause your stream and later reactivate it, make sure to set the 'Stream start' to 'Latest block'. Otherwise, the stream will process all blocks between when you paused it and the current block, which may result in unexpected behavior.

Finally, click the Create a Stream button. Once the Stream has started, monitor your terminal window running ngrok and the local Express API. It may take a while for a trade to occur, so you can create one yourself to test.

Here's how a trade log should look when an event that meets filter conditions:

-------------------------------
Server running on port 8000
-------------------------------
No transactions met criteria in block 6227658
No transactions met criteria in block 6227659
No transactions met criteria in block 6227660
No transactions met criteria in block 6227661
No transactions met criteria in block 6227662
No transactions met criteria in block 6227663
No transactions met criteria in block 6227664
No transactions met criteria in block 6227665
Transaction detected in block 6227666, amount: 1.377949 USDC, hash: 0x9c5999952976b4a252560eadc935f3cf8599963e180c28a3feec3507a76bb892
-------------------------------
ETH Balance: 1.043311552476760578 ETH
-------------------------------
Swapping 0.01 ETH for USDC...
-------------------------------
Token Swap will result in: 0.915917 USDC for 0.01 WETH
-------------------------------
Quoted amount: 0.915917 USDC
-------------------------------
Sufficient allowance already exists
-------------------------------
Estimated gas: 141702
-------------------------------
Current gas price: 10.575560558 gwei
-------------------------------
Sending transaction...
Transaction sent. Hash: 0x7096ebfce2f3a46111f072bd3b2956590ca4e7dd5bacd80c385b913a4b7c24bd
-------------------------------
Waiting for transaction confirmation...
-------------------------------
Transaction confirmed in block 6227667
-------------------------------
Transaction successful
-------------------------------
Received 0.915917 USDC
-------------------------------
Swap executed successfully. No more swaps will be executed.
A swap has already been executed
A swap has already been executed
...

Congrats! You just created a trading bot with Streams and Streams Filters. To learn more, check out the following resources below.

Additional Ideas & Resources

Check out the list of resources below to strengthen your understanding of Streams, Streams Filters, and Uniswap.


Final Thoughts

Subscribe to our newsletter for more articles and guides on Ethereum. Feel free to reach out to us via Twitter if you have any feedback. You can always chat with us on our Discord community server, featuring some of the coolest developers you'll ever meet :)

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide