Developping a profitable NFT trading bot
08 May 2023This blog post outlines how I developped a profitable NFT trading bot in early 2023 after attending Le Wagon web development bootcamp in Nantes, France. I already had prior experience coding but after the bootcamp, I wanted to put into practice the new things I’d learned, notably the Ruby programming language and MVC architecture. To develop this project, I used the Sinatra micro-framework coupled with a PostgreSQL database and a dockerized Node.js instance for the transaction signing logic.
The plan
First a quick recap of my prior experience with NFT trading bots:
- In 2021, I developped an OpenSea mass offer bot which made over $50k in a 6 months period.
- The bot exploited an asymetry between buy and sell side orders.
- Sellers might be short on time and wanting to sell their NFT right now, without waiting for a buyer to show up.
- My bot sent offers to buy each individual NFT in a collection at a discount compared to the cheapest NFT listed for that collection.
For example, if the cheapest NFT in the Bing Bong collection was listed for 1 ETH, I would send an offer to each holder of a Bing Bong NFT to buy it from them for something like 0.85 ETH. Some of them accepted and I would then list the NFT just under the cheapest listed NFT in that collection, in this example something like 0.99 ETH, pocketing 0.14 ETH when I sold it. With the current value of ETH being what it is, this is a nice profit for running a program and clicking a few buttons.
Collection offers
It is 2023 now however and most NFT marketplaces have realized this asymetry and implemented a new feature: the collection offer. The concept is still the same but now you can place offers on entire collections instead of just single NFTs. Here’s an illustration I made to represent the strategy visually:
The yellow zone in the middle is where we make profit, in this example the max profit (difference between highest buy offer and floor price) is 1 ETH.
It is now also much harder to get approved for an OpenSea API key. So I decided to try my luck with another marketplace for which the API would be easier to access. I came upon the MagicEden marketplace for the Solana blockchain which also had the collection offer feature.
Designing the bot
I wanted to improve upon my bot from 2021 and make this one fully automated. I wanted to be able to just run a program on my laptop and have it go make money for me. So from the start I knew I would need the following features:
- Get data for a set of NFT collections I was interested in trading - things like trading volume, current floor price, value of the current highest collection offer. This is the data my bot would use to know how much to buy and sell NFTs for.
- Create collection offers for collections that have potential for profit and adjust these offers when outbid by the competition or when the collection stops being profitable.
- Have a sense of NFTs currently held in the account.
- List these NFTs at a profit and adjust the price if other users list NFTs for cheaper than us. It’s important to sell fast to stay liquid and avoid hanging on to any single NFT for too long. Otherwise, we risk a big loss if the value of the NFT we are holding drops.
- Provide an interface for tracking the actions taken and the profit realized by the bot (letting a program manage your crypto without supervision is a terrible idea!).
Digging into the API
First, I went and checked out the MagicEden API docs. Before wasting time developping my features, I wanted to see if the API provided some useful endpoints that would save me time trying to scrape data from the site. Upon inspection of the docs, I found these endpoints which provided some of the functionality needed for my bot:
api-mainnet.magiceden.dev/v2/collections/:symbol
- for getting important data for a collection including the floor price. It’s not explicitly listed in the docs but it’s easy to deduce as three of its sub-endpoints are listed in the docs.api-mainnet.magiceden.dev/v2/wallets/:wallet_address/tokens
- for getting the tokens currently held in my account, useful for knowing when someone sells an NFT to my bot.api-mainnet.magiceden.dev/v2/instructions/sell
- for generating Solana transaction data to list an NFT to sell on MagicEden. This one however requires an API key to use (which I don’t have).api-mainnet.magiceden.dev/v2/instructions/sell_cancel
- for generating Solana transaction data to delist a previously listed NFT. Once again this one requires an API key.
There’s some more endpoints I found in those docs and ended up using but I won’t mention them here as they’re beyond the scope of what I’ll discuss in this writeup. I was still missing some of the data my bot would need to work so I tried finding some private API endpoints not listed in the docs!
So I went to the MagicEden site, navigated to the pages that contained the data, opened Chrome Dev Tools on the Network tab, refreshed the site and inspected the requests made by my browser. This method allowed me to discover these additional endpoints:
https://api-mainnet.magiceden.dev/v2/mmm/pools?collectionSymbol=:symbol
- for getting collection offers for a given collection. Great for getting the current highest collection offer. Additionally this endpoint was completely accessible when I tried a request from Insomnia!https://api-mainnet.magiceden.io/v2/instructions/mmm/create-pool
- for generating Solana transaction data to place a collection offer. This endpoint is on themagiceden.io
domain as opposed to the previous endpoints which are all on themagiceden.dev
domain. When I tried requesting this endpoint in Insomnia, I got a 403 Forbidden error. I will discuss how I got around this limitation later in the post.https://api-mainnet.magiceden.io/v2/instructions/mmm/sol-withdraw-buy
- for generating Solana transaction data to cancel a collection offer. As with the previous endpoint, this one was also protected.
With these endpoints in hand I was ready to get started.
Implementing the API in Ruby
The first thing I did was create a Ruby class called MagicEdenApi
. Then I added class methods to it for each API endpoint I planned to call. This would make it easier to query the API in my program. Here’s an example for the best offer endpoint:
Then now in the rest of my program I could use:
Now you may be wondering how I went about implementing the API endpoints that weren’t accessible outside of the magiceden.io
domain. For these, I wrote a special method in my MagicEdenApi
class which I called bypass_api
. I figured that when using the site in my browser I was able to access these endpoints so I decided to reproduce the same configuration programatically. Selenium is a framework for automating your browser, so I wrote the following method using Selenium:
Put simply here’s what this method does:
- It opens Firefox with Selenium
- It visits the
https://magiceden.io
URL - It executes a JavaScript script on the page
- That script fetches the endpoint we want to access and inserts the response on the page
- After 5 seconds we ask Selenium to find the element that should have been inserted, if it has we return the API response contained in that element
The reason that this method works is that from the perspective of the server, we are using the site in a normal way: browsing it then making normal calls to the API within the same browser. It’s not 100% effective but it’s good enough for our use. Now we can use all the endpoints we found earlier.
Sending transactions to the Solana blockchain
With the features of the API now fully implemented as a Ruby class, we are still missing one key aspect before we go and start buying and selling NFTs. We still need a way to take the transactions we generate with the MagicEden API, sign them, and send them to the Solana blockchain. Remember there are four types of transactions we might want to do:
- Create a collection offer
- Delete a collection offer
- Create a listing for an NFT
- Delete a listing
For signing and sending Solana transactions, the go to is the @solana/web3.js
package in Javascript. Since I’d decided to write most of my app in Ruby, for this small utility layer I decided to go with a dockerized instance of Node.js with an Express web server for communication with my main app. Here’s the process I settled on:
- Use the API implementation in Ruby to generate the transaction
- Store the unsigned transaction in the database
- Request the signing and sending of the transaction by passing the ID of the newly created transaction to the Express web server
- Let the docker container take care of handling the transaction and store relevant outputs (like the transaction signature) in the database under the same ID
This is what it looks like for creating a collection offer:
And here’s the method that ends up getting called in the docker container:
The other three transaction types we listed above are implemented in a similar way.
The trading logic
Now with all the tooling in place required to interact with the MagicEden marketplace, we can put it all together to define how our bot trades. I settled on having two main loops that would run continuously. The first one we’ll call the data fetcher and is responsible for constantly refreshing the data of each of the ~100 collections I want to trade on. The second we’ll call the main bot and handles all other tasks we want to do.
The data fetcher is self-explanatory, it updates the data (volume, floor price, best offer) for the first collection, then the second and so on. Once it has updated all collections, it starts over updating the first collection.
The main bot on the other hand does a few things. Here’s what the code looks like for that loop.
I’ll now go over what each line in that loop does, skipping over the boring details to give you an overview of how the bot is trading.
- The
get_tokens
method queries the MagicEden API to get the current tokens held in our account. Some of these tokens we may have been holding for a while and already exist in our database, for these tokens we do nothing. For the new tokens that have appeared in our account however, we create a corresponding instance in our database with all the relevant information (the collection it belongs to, the image URL of the token…) - The
get_transactions
method checks for tokens we may have recently sold. If there are any, that method figures out how much we bought the token for, how much we sold it for, and stores that information in the database. This is what we use to calculate our profit. - The
all_held.each
loop iterates over each NFT we are currently holding and refreshes the data of the collections those NFTs belong to. This part is important because when we list our NFTs for sale, we want to make sure we are choosing our price based on the latest data available without having to wait for the data fetcher to update that specific collection. - The
sell_nfts
does a couple things. First it takes all NFTs that are not currently listed and lists them just under the current floor price for that collection (good thing we just updated the data!). Then for NFTs that are already listed, it checks whether the floor price in that collection has fallen below what we listed ours at and if so, it deletes that listing and creates a new one just under the new floor price. This ensures we sell our NFTs quickly by always offering the cheapest one in the collection. - The
update_collection_offer_prices
I’ll skip over, it’s a method I added after running into bugs sometimes when trying to cancel collection offers. - The
refresh_active
method is where it starts to get interesting. This method looks at all the collection offers we have placed, then it refreshes the data of the corresponding collections. Finally, if there is an offer higher than ours, it deletes our current offer and places another one just above the competitor who outbid us. - Lastly, the
place_profitable_offers
method does a few things:- It takes the list of all the collections we are tracking and filters them according to the following criteria
- A minimum collection volume, under which we consider collections not liquid enough to trade (I set this to 50k SOL)
- A minimum and maximum floor price, this ensures we are targetting NFTs that will both give us a good return but not lock up all our funds in a single big offer (I have this set from 5 SOL to 30 SOL for my bot which is trading with around 100 SOL)
- A minimum and maximum spread, the spread is the relative size of the gap between the highest buy offer and the floor price. For a collection with a best offer of 9 SOL and a floor price of 10 SOL, the value of the best offer relative to the floor price is 90% so the spread is 10% or 0.1. The higher the spread, the more profit we can make, so why bother setting a max spread? I did this to remove “too good to be true” collections. I set the max spread to 0.5 because if a given collection meets my other criteria and has a 50% spread, my bot has probably made a mistake somewhere along the way and I shouldn’t trade it.
- Once it has identified a list of “profitable collections” with favorable spreads, it proceeds with placing collection offers on each of those at a value just above the current best offer.
- The last two lines I added for redundancy as sometimes the bot would not behave as expected, adding these helped to make the bot run more smoothly
The dashboard
An important part of the development process was to have access to visual feedback of what the bot was doing. For this purpose I implemented a simple Sinatra view displaying all the details I needed. Here’s what that ended up looking like:
The main elements of the interface are:
- Some basic stats like profit and sales
- A list of recent trades detailing how much each of them gained or lost
- A list of NFTs currently held by my bot
- A list of all the active collection offers placed by my bot