Web3 SQL Weekly #9: Sleuthing a Sudoswap Trade
A walkthrough of my flow to put together a view of sudoswap v2 trades
I’m going to try doing web3 SQL weekly without the video portion so I can save time and be more consistent about this. Bear with me 😅
Setting the Stage
Sudoswap is probably the most complex and composable you can get with NFT automated market maker pools (pool = pair). And they just released a version 2! 🙌
In v2 of their protocol, you can deposit tokens (ERC20/ETH), NFTs (ERC721/ERC1155), or both into your own pool. These three kinds of pools are called token, nft, and trade pools respectively. If you only deposit tokens, then you’re exclusively buying an NFT collection. If you only deposit NFTs, then you’re exclusively selling an NFT collection. Trade pools go both ways, and are the only ones that can then earn pool fees.
Each liquidity provider (LP) creates their own pool with a custom pricing curve, where they set the change in price per NFT purchase/sale. There are four pricing curves to choose from - linear, exponential, xy=k, and gradual dutch auction (gda).
📊 I’ve put together a dune dashboard covering their V2 release.
Anyways, the technical part aside: how do you even begin to figure out the swap price, NFT contract/ids, protocol fees, trade pool fees, and royalty fees?
Let’s look at how I do this exploration:
piece together what I can from a simple trade.
piece together the events and functions flow through the code
find the right tables and put together the query
💡 I did a deep dive of v1 last year, the main concepts are roughly the same.
Examining a simple trade
I choose to start by looking at the trades, and not the pools. If while sleuthing the trade data I realize there is some parameter related to token or fees, then I will go look at pools.
Looking at the sudoswap_v2 decoded namespace on Dune, I can see the following contracts:
Experience tells me that the LSSVMPair and LSSVMPairFactory probably contain all the functions that handle the swaps. I know that the factory likely only handle pair creation. So I’ll check out the pair contract first and search “swap” - I can tell that “swapNFTsForToken” implies selling an NFT and that the other function must mean buying an NFT.
I’m going to hover the “eye” icon on the sell function to preview the table and pull a random transaction hash. Let’s plug that hash into my favorite transaction explorer:
Okay okay - this looks scary but let’s start breaking it down. What are options for calculating trade details from here?
Option 1: You could try and use “ethereum.traces” and “erc20.evt_Transfer” tables to just piece together the ETH and token flows - without any use of the protocol specific functions and events tables.
I know that funds/tokens should flow between four addresses. The seller, the royalty address, the pool fee address, and the protocol fee address.
In this case, you’ll find that the 0.363 goes to the seller, 0.00186 goes to the pair factory (protocol fee), but there is no sign of the pool fee or royalty fee!
You could then try and check the pair or factory for the fee percentage settings, and find that the protocol takes a 0.5% fee so divide 0.00186 by 0.005 to get the price of 0.372, then multiply that by the pool fee percentage. Then take the asset recipient (fee recipient) from the pool creation data, and if there is an eth transfer to another address then assume that is the royalty fee.
But this option is messy, with a lot of conditional logic because sudoswap pools can be ERC20 or ETH denominated and some fee nuances. So let’s not do this!
Option 2: Let’s breakdown the three key steps I see in the execution trace from above. I’m keeping in mind that events are good indicators that some key logic has finished, and that staticcalls are indicators of key calculations (they don’t modify state, they exist solely to check values).
First there is an event “SpotPriceUpdate” which seems to imply the actual swap price. And there is a “ExponentialCurve.getSellInfo()” function called right before it with presumably the old SpotPrice and a bunch of other helpful variables related to protocol and trade fees.
Then “RoyaltyEngine.getRoyalty()” is called, and if I expanded it I would see that it then calls the Manifold Royalty registry to get the royalty for a specific contract and token id - but the return is null/reverted in this case so I’m assuming no royalty fee here because of the ([ ], [ ]) empty output.
Lastly there is a 0.363122532 is amountOut emitted in the “SwapNFTInPair” event, which matches the amount of ETH given to the seller. I know that this event is emitted at the end of the swap because the tokens are all transferred in calls already, so all fees (protocol, trade pool, royalty) should be accounted for.
From these, I can tell that all the datapoints I need are there - no need to use any general traces or transfers tables. The difficult part is now knowing the right order to calculate things in. This means I need to answer questions like:
How is “amountOut” in the swap event different from “outputValue” in the getSellInfo call?
Should I use “newSpotPrice” or “spotPrice” as my NFT price? Remember that “price” should always include all fees in it.
If there are multiple tokens swapped, does each token get it’s own royalty calculation? Because that would mean that any join I do will have duplicate rows.
Alright, that’s as far as I’m going to get without looking at any of the code. Let’s try and see how these functions and events actually tie together.
Demystifying the pool contract code
Don’t worry, you don’t need to know solidity for this. All you have to understand is how to navigate external, internal, and cross-contract functions.
Here is the code for the pool from the example swap, which is an ERC721-ETH pair. Now, while there are 25 files listed in the “code”, all the inherited logic is deployed together as one contract. And any file with “I” in front of it is just an interface, used for interacting with that contract in cross-contract calls later on - so you can ignore those.
For the remaining four pair contracts, let’s navigate the contract inheritance tree:
Given this context, I search for “function swapNFTsForToken(” because I want to find the place where the logic for it was defined, not where it may have been used/called. It appears 3 times in non-interface contracts:
once in LSSVMPair.sol (file 3)
twice in LSSVMPairERC721.sol (file 5).
This can take some getting used to, but the LSSVMPair.sol just defines the function input/return variables with no logic. It is inherited by the LSSVMPairERC721.sol contract which “overrides” the function to specify actual logic.
Okay, so we’ve found the function - and it is making an internal function call (highlighted above). Searching for “function _swapNFTsForToken(” finally gives us the exact logic we wanted:
function _swapNFTsForToken(
uint256[] calldata nftIds,
uint256 minExpectedTokenOutput,
address payable tokenRecipient,
bool isRouter,
address routerCaller
) internal virtual returns (uint256 outputAmount) {
//removed some security based code//
// Call bonding curve for pricing information
uint256 protocolFee;
(protocolFee, outputAmount) = _calculateSellInfoAndUpdatePoolParams(nftIds.length, bondingCurve(), _factory);
// Compute royalties
(address payable[] memory royaltyRecipients, uint256[] memory royaltyAmounts, uint256 royaltyTotal) =
_calculateRoyalties(nftIds[0], outputAmount);
// Deduct royalties from outputAmount
unchecked {
// Safe because we already require outputAmount >= royaltyTotal in calculateRoyalties()
outputAmount -= royaltyTotal;
}
//removed to token transfer code to shorten snippet//
emit SwapNFTInPair(outputAmount, nftIds);
}
I can immediately see that the “outputAmount” from calculating sell info is different from the “outputAmount” emitted at the event in the end - because royaltyTotal was subtracted in between!
If I search for “_calculateSellInfoAndUpdatePoolParams()”, I’ll find the function defined in the LSSVMPair.sol contract (file 3) with this line:
(error, newSpotPrice, newDelta, outputAmount, /*tradeFee*/, protocolFee) =
_bondingCurve.getSellInfo(currentSpotPrice, currentDelta, numNFTs, fee, _factory.protocolFeeMultiplier());
This is key, I know that “_bondingCurve.getSellInfo()” is making a cross-contract call to the bonding curve - that is the ExponentialCurve.getSellInfo() call that we saw at the very start! And yes, that means I should now go to the code for that bonding curve contract address and lookup that function.
Now is a good time to pull up the docs, which actually explain the logic behind curve prices (which is what this function does). That tells us the price of the swap is the currentSpotPrice and not the newSpotPrice, and all fee percentages are calculated as a percent of currentSpotPrice.
What about _calculateRoyalties()? If we follow the function inheritance hole again (the pair calls the RoyaltyEngine which calls Manifold Registry), we will find that it only calculates royalties for the first token id of an nft contract address. So in the case you have two token ids with different royalties on the same nft contract, the royalty for both tokens is calculated based on the first token id passed.
So, what did we learn overall?
A user (or router) will call swapNFTsForToken() to sell NFTs into a pool.
LSSVMPairERC721.sol defines the logic for swapNFTsForToken(). This makes another internal call to _calculateSellInfoAndUpdatePoolParams() - defined in LSSVMPair.sol.
This internal calculation function makes a cross-contract call to the bonding curve getSellInfo(), giving us price and pool/protocol fee data. We can use this data as our trade price source of truth.
Then, _calculateRoyalties() kicks off another cross-contract call is made to the RoyaltyEngine and then the Manifold Registry. This calculates the full royalty based on the first token id/nft contract address being swapped.
At the end, the amountOut in the emitted swap event is the actual value paid to the NFT seller.
I won’t go into the swapTokenForSpecificNFTs() function (NFT purchase), but you will find some differences because pool fees are only transferred to the asset recipient (fee reciever) on purchase and not on sales. This is to save gas - but we can trust the getBuyInfo() to give us the right internal pool values.
I validate all of this by manually putting in the values from the transaction earlier into this query here to see if things tie out or not - basically seeing if by subtracting all fees from current spot price gets me the amountOut emitted.
It works! 😊
Putting together the query
📊 I’ve put together a dune dashboard covering their V2 release - go check out the queries to see how I build insights on top of the base data.
To find tables, I like to plug in transactions into my table finder query.
This gives me the following tables:
sudoswap_v2_ethereum.LSSVMPair_call_swapNFTsForToken
sudoswap_v2_ethereum.ExponentialCurve_call_getSellInfo
sudoswap_v2_ethereum.RoyaltyEngine_call_getRoyalty
Notice that we just decode all the pairs as the same since the functions are the same, regardless of ERC721/ERC1155/ERC20/ETH combinations.
Okay, we are FINALLY nearing the end. I know that I want to take the swapNFTsForToken function table for a given pair, then join both the getSellInfo() call from the right bonding curve and getRoyalty() call from the RoyaltyEngine to get complete trade data.
The query is actually very straightforward, you can find it here for both buy and sell sides.
The tricky part is joining on call_trace_address. Basically in the case there are multiple swaps in one transaction I want to make sure to only tie in getSellInfo and getRoyalty from deeper traces than the top level swapNFTsForToken call. Hence the weird slice on call_trace_address joins. I do a “call_trace_address_filled” column because the top level trace is always an empty array [ ], and I need it be at least length of 1 to avoid an OR in the join logic. The lines for this are highlighted below:
I also created a pools query to help make sure I join the right bonding curve table to the swap. This also gives me the right ERC20 or ETH token used for payments.
Try to replicate just the “sell_nft_base” CTE logic yourself, to make it really click. You should have all the knowledge you need to do so.
I hope this was helpful! I definitely focused much more on the solidity/sleuthing side than the SQL logic this time, please let me know if you preferred that or not.