How to Read Smart Contracts
Welcome Avatar! Today we have a guest post from BowTiedPickle on how to read smart contracts. Part one goes through a walkthrough of Uniswap v2’s structure and an introduction to basic smart contract terminology. In part 2, Pickle will take you through the code line by line and explain what each part means.
Simplifying Smart Contracts: Pt 1
A gentle walkthrough of Uniswap V2 for non-coders.
Crypto has a literacy problem. No, not the degens who can barely read well enough to ape their net worth into dog coins. The crypto ecosystem has a fundamental issue - the laws of the ecosystem are illegible to the layman.
Instead of documents written in legalese, we have immutable smart contracts written in code. Most reasonably intelligent non-lawyers can puzzle through a legal document with a little headscratching, but a far smaller proportion of non-coders can interpret code.
If you can’t read code, then a smart contract is like the Bible written in Latin to a medieval peasant. The laws governing your existence are absolute and unknowable. You have no choice but to trust an intermediary to interpret - or to learn an entire profession’s worth of technical skills yourself.
This problem is precisely why I decided to learn to read and write smart contracts. As I got into crypto, I realized I was playing a game where I didn’t know the rules. Without being able to read the binding documents I was signing with my Metamask, I was at the mercy of others. Getting rugged was all but inevitable.
The educational material available doesn’t really help the layman, either. “Learn Solidity in 6 Months!” or “Become a Web3 Developer!” is useless to someone who doesn’t have 6 months to give, or is not interested in becoming a developer. Faced with the choice between remaining ignorant or learning smart contracts at a deeper level, I drew on my technical background and picked up Solidity.
Even after learning to write smart contracts, I never forgot the frustration of not finding the resources I needed.
I feel strongly that education designed for non-coders or tools to turn smart contracts into plain language will be required for mass crypto adoption. To help address the lack of non-technical educational content on smart contracts, I’ve written two posts on The BowTied Island (5 Hazards to Watch For In DeFi, How to Read Smart Contracts Without Learning Solidity).
I’m very excited to bring this content to the DeFi Education audience, so without any further ado let’s dive in! In Part 1, we’re going to look at high-level concepts like the on-chain layout of the smart contracts, and do a quick 101 lesson on reading code. Part 2 will feature a line-by-line guided tour of a real Uniswap smart contract, that you’ve probably sent value through yourself!
The Anatomy Of a DEX
Hopefully, you know what a DEX (decentralized exchange) is. If not, the DeFi Ed team has done detailed coverage on DEXs, including a series on Uniswap (pt. 1, pt. 2). We’re covering Uniswap today, specifically the Uniswap V2 smart contracts. Why UniV2? Efficiency.
Uniswap V2 is the most widely forked DEX architecture. SushiSwap, Trader Joe, you name it, it’s probably a UniV2 fork. As such, mastering this will make you 90% familiar with most DEX protocols.
I’m not going to spend time in the overview explaining what a liquidity pool is or how y*x = k works. Instead we’ll cover some important high level and architectural properties of the smart contracts.
To qualify as a decentralized exchange, we need a few important properties:
If users can be blacklisted from using it, or if operation is controlled exclusively by whitelisted accounts, the protocol is not really that decentralized. That an exchange needs to be able to exchange tokens should be self-evident. Okay, great.
What does that mean from an architectural standpoint?
- Any user should be able to interact with any pool
- Any user should be able to perform liquidity management operations on pools
- Pools should operate indefinitely without requiring centralized inputs
- Any user should be able to trustlessly create new pools
- All pools should behave exactly the same way
The first two points can be simply attained by just not requiring permissioned access for those functions. The last three points are more complex. To solve those problems, we introduce the contract factory.
Design Pattern: Contract Factory
Smart contracts are immutable by design. It’s part of what makes them trustless. Contract A’s code can’t turn into Contract B’s code. Contract A can’t even change to act like Contract B unless it deliberately includes code that allows it to change.
This means you can reliably trust a smart contract to do exactly what its code tells it to do. Combine this with the fact that smart contracts can deploy other smart contracts, and you get an important decentralization primitive: the contract factory.
A contract factory is a specialist smart contract that has exactly one job: deploy copies of its code payload as new smart contracts. In the case of Uniswap V2, the factory’s job is to deploy shiny new Uniswap V2 liquidity pools!
Using a factory pattern ensures that:
- The source code is the same for all pools
- Each pool behaves exactly the same as any other
- Each pool can be interacted with in exactly the same way
- One and only one pool exists for each pair of tokens
And these properties enable decentralization! A well-designed factory can’t be made to deploy bad contracts, so anyone can be trusted to call it, for any input tokens, without compromising any other part of the ecosystem. We don’t have to rely on a centralized operator, like waiting on Coinbase to list a token.
The on-chain ecosystem of our DEX now looks something like this. “Uniswap” is not a monolith, it’s actually thousands of identical smart contracts, all deployed by a single factory. These make up the core contracts, where the main functionality lies.
Design Pattern: Router
The problem with all these separate contracts is that usability suffers. You can manually swap on any one of these pools with low-level function calls- they are permissionless.
However, you have to go search out the right pool out of thousands, calculate the token amounts and token price, deploy a custom smart contract to interact with them - and if you do it wrong, it all reverts. You also have to manually execute all the chained asset swaps if there’s not a direct pool for your swap pair. To counteract this pain, Uniswap uses a design pattern called a router.
The router contract acts as an on-chain user interface for the core contracts. This is wonderful news for the user, because it lets you swap tokens from a simple function call to the router, instead of implementing everything yourself.
The router communicates with the factory to retrieve the Uniswap pool for the relevant token pair. It also contains the logic that turns a high level call like “swap 100 DAI for ETH” into the low level swap calls to the individual pools required. Thus, the smart contract ecosystem really looks like this:
Tying the DEX Together
If we add in some other stuff like a governance layer and fee flow, you get the whole Uniswap V2 smart contract ecosystem. We aren’t going to deal with governance in this guide, so blackbox that for now.
Congratulations! You now know more about the Uniswap V2 ecosystem structure than probably 90% of users.
Reading Smart Contracts
Now that we’ve gotten an overview of the contract ecosystem, we’re going to walk through some foundational material in preparation for part 2. This walkthrough is written for people who don’t program, so don’t close the page! If you can handle basic Excel formulas, I hope you will be able to handle this.
We require only two things to interpret a smart contract: basic code literacy, and a plan. I’ll share my plan and a basic code primer with you below, which will give us the tools we need.
A word of encouragement: If you’re not used to it, reading code can be slightly overwhelming. Re-read sections as many times as you need. Ask questions, Google it, or get up and come back to it later. It is fully within your powers to understand this!
Remember, there is nothing magical going on, just explaining to a computer in exhaustive detail what it needs to do.
In my post How to Read Smart Contracts, I demonstrated how to walk through a simple, practical, smart contract. That post is a gentle introduction to how to read code, so please use that as an additional resource.
What Is a Smart Contract?
Smart contracts are pieces of code that live on the Ethereum blockchain. At a very high level, smart contracts are black boxes that contain a series of instructions on how to change the state of the blockchain, given a particular input. This has a few implications.
- Any given contract has a specific set of allowable ways to interact with it.
- The output of each interaction is deterministic (IE. always produces the same output given the same input).
- Said contract will only have a specific set of allowed interactions with other contracts (if any).
This means that each contract has a finite, clearly defined scope to what it is capable of doing. Since the scope is defined, we can logically walk through each function, and determine what effects it will have.
Fundamental Code Literacy
This overview is neither exhaustive nor technical, and is intended only to give you enough basics to understand what you’re looking at when diving into some code. Should you require additional information, there is more information available here.
Variables and Objects
Variables store a value… which can vary. Crazy right? Hopefully you will be conceptually familiar with the idea of a variable from high school algebra.
In programming, this concept gets extended slightly with the idea of an object. An object just means that rather than being limited to numbers, strings, or other things you might expect to find in Excel cells, I can use a variable name to represent arbitrarily complex things.
For example, I can make an object which represents an ERC-20 token, and use that to give my smart contract the ability to interact with the token’s functions. I can make an object to represent my NFT’s listing on a marketplace, and have it contain information about price, expiry, etc. The sky is the limit.
Functions
A function is a package of actions, defined in the code. When you want to use a function, you call the function. It may or may not accept some inputs, which are called arguments. No need to break out the flashcard, there’s not a quiz on these terms. I may use them interchangeably with more plain-language terms, so I want to define them now for your familiarity.
As a conceptual exercise, let’s think about a daily activity as a function. Suppose we have the function makeSandwich
. This function might accept an argument typeOfSandwich
, or an argument for quantityOfSandwich
. Within the function, we would have operations to grab plate, grab a knife, open the refrigerator, etc. You could also call other functions from within this function – like say cutSandwich
.
More concretely, you can have a function like stake(amount)
which will stake a given amount of your tokens. Every interaction you make in DeFi that isn’t simply sending Ether is a function call!
Brackets and Braces and Semicolons, Oh My!
Solidity is a curly-bracket language. For the non-nerds, that just means it follows a particular set of conventions about the structure of the code.
- It uses curly braces { } to enclose pieces of code
- Each line terminates with a semicolon ;
- Parentheses ( ) are used to enclose function arguments, and for order of operations purposes.
- Brackets [ ] are used for lists of items, or to specify an element
By “specify an element” I mean “tell the computer that I mean X item in this list of things it’s keeping track of”. This is done by putting brackets and the item we want after the variable name. For example, we could specify the 5th seat on a bus by writing bus[5]
.
You can safely ignore most of this syntactic detail 90% of the time. I mention it so you don’t freak out when you lay eyes on the forest of brackets, and also so you know how the pieces of code are grouped together.
Data Types
Solidity requires variables to be assigned a type. Simply, the type determines what operations are permissible with that variable, and how the computer should treat it. It doesn’t make much sense to divide a string by 2, or attempt to transfer USDC to the number 531. Common data types and the layman’s definition are as follows.
- uint256: 256-byte unsigned integer. Basically a big f****** integer that can only represent positive numbers.
- address: a public address on the blockchain.
- bool: a Boolean value. Either TRUE or FALSE.
- bytes32: a sequence of 32 bytes. These are generally used for storing hashes, Chainlink API keys, and other such data.
- bytes: an arbitrary length sequence of bytes.
Visibility
Solidity requires you to set a modifier called visibility on each function and variable, which determines who can call it. They are, as follows,
- Public – anyone can call this function
- External – anyone who is not this contract can call this function
- Internal – may only be called by this contract or a contract that inherits it
- Private – may only be called by this contract
and you can see them in the first line of the function, like the public
below.
function doSomething() public { doStuff(); }
For the purposes of basic analysis, you mainly need to care about the groupings of Public+External (exposed functions) and Internal+Private (unexposed).
Any function which is exposed can be called at will by external actors. Depending on the function there may be further checks that prevent unauthorized access, but there is nothing stopping anyone on this planet from poking that function with a stick. Each exposed function is an entry point that can become an attack vector if there is a bug in the underlying code.
Unexposed functions can only be called by the smart contract itself, ie. they will be triggered by calls within the code of exposed functions. For example, a call to an external function which deposits assets into an LP pool will trigger several bits of code, including an internal function call to mint LP tokens to your address.
Comments
Text in comments is provided for informational purposes by the developer, and does not affect the code in any way.
// This is a basic comment /// This is a special comment type called NatSpec. /// It is used to automatically generate documentation. /** * This is a multiline NatSpec comment * Everything in here is commented */
How To Read a Smart Contract
Reading code is not like reading books. You may have to jump around, skip ahead, or look something up, to understand what you’re reading at the moment.
When trying to parse a contract for the first time, you want to follow a strategy instead of aimlessly diving into the code. This is especially important if you want to learn how to read smart contracts without learning Solidity! If you just begin reading line-by-line from start to finish, you can start to drown in the unfamiliar syntax.
Below is the process I use when trying to understand a new contract:
- Read the comments and any high level documentation available from the protocol
- Examine the state variables
- Quick glance at the constructor
- Identify pieces of code that can be temporarily ignored
- Identify any functions which take in funds or interact with other contracts
- Identify any functions which transfer funds out
- Work through the exposed functions, tracing their logic through internal calls and prioritizing functions which take in or transfer out assets
- Revisit the constructor if anything was unclear earlier
We’ll follow this procedure to dissect the Uniswap V2 Pair smart contract!
Wrapping Up
You shouldn’t have to be a smart contract auditor or a web3 developer to understand the binding transactions you’re signing. I sincerely hope that this walkthrough helps you gain the confidence to do your own due diligence on the protocols you invest in.
Don’t forget to check back for Part 2, where we will dissect the Uniswap V2 Pair, one of the most commonly used pieces of code in DeFi. Alpha leak - I’ll also show you the piece of code that makes flash loans possible…
I’ve tried to make this as friendly as possible, but code is complex. If there’s anything you’d like clarification on, I’ll take questions in the comments of this article, or you can shoot me a DM on Twitter. Special thanks to the DeFi Education team for the opportunity to share this with you!
DeFi Ed Note: Fair warning, part 2 will be technical. We think part 1 is a great introduction for everyone. Part 2 will be challenging unless you already have some prior coding knowledge.
Deep Dive Into the Pool
In part one, I covered the overarching structure of the Uniswap V2 smart contracts, and some basic code literacy material. You are recommended to visit that post for context and prerequisites.
Now, we’re actually gonna get into the code of the liquidity pools. The smart contract for this is the Uniswap V2 Pair. This smart contract contains all the code to handle the pair of tokens in a liquidity pool (e.g. WETH-USDC), perform swaps and liquidity operations, as well as implement the LP token which you receive when adding liquidity to the pool.
The code is available on Github here. I strongly recommend following along on Github or copying the code into a code editor which supports Solidity, as Substack doesn’t support syntax highlighting.
Contract Declaration
pragma solidity =0.5.16; import './interfaces/IUniswapV2Pair.sol'; import './UniswapV2ERC20.sol'; import './libraries/Math.sol'; import './libraries/UQ112x112.sol'; import './interfaces/IERC20.sol'; import './interfaces/IUniswapV2Factory.sol'; import './interfaces/IUniswapV2Callee.sol'; contract UniswapV2Pair is IUniswapV2Pair, UniswapV2ERC20 { using SafeMath for uint; using UQ112x112 for uint224;
This defines part of the properties of the paired tokens in a liquidity pool, and how the pair can interact with other contracts.
Let’s go line by line and explain.
pragma solidity =0.5.16;
The solidity pragma is just the version of solidity we’re using; highly relevant to developers or auditors but not usually relevant to high level overviews.
import './interfaces/IUniswapV2Pair.sol';
The import statements need a little bit of attention. Let’s introduce the idea of an interface. An interface is a stripped down variant of a smart contract which doesn’t contain any implementation code. Interfaces allow you to define ways to interact with other smart contracts, without caring about how they work under the hood.
In this case, the imported interfaces tell the Uniswap V2 pool how to interact with ERC-20 tokens, the UniV2 contract factory, and UniV2 flash loan callers.
As an example, say you’re implementing a smart contract that needs to stake tokens on a farm. You know that the farm has a stake function that accepts the amount of input token as argument. You can define an interface like stake(uint256 amount)
and your smart contract will be able to call that function on the external contract!
The Uniswap pool imports four interfaces. The IUniswapV2Pair.sol
is an interface for the pair itself. The practice of importing your own contract’s interface is a design pattern employed by some developers, and can be ignored in this case.
import './interfaces/IERC20.sol'; import './interfaces/IUniswapV2Factory.sol'; import './interfaces/IUniswapV2Callee.sol';
The IERC20.sol
, IUniswapV2Factory.sol
, and IUniswapV2Callee.sol
are interfaces for the contracts the pair has to interact with.
contract UniswapV2Pair is IUniswapV2Pair, UniswapV2ERC20 {
The remaining import UniswapV2ERC20
is included by the actual contract itself, and becomes part of the source code. We see that inclusion in the contract UniswapV2Pair is IUniswapV2PAir, UniswapV2ERC20 {
line.
This is called inheritance - which just means that all the functions, state variables, and code defined in the ‘parent’ UniswapV2ERC20
and IUniswapV2Pair
contracts will be available in the ‘child’ UniswapV2Pair
contract.
Ordinarily you would have to look through each parent contract to understand what is inherited. I’ll spoil the surprise and let you know that the UniswapV2ERC20
contract contains familiar functions like transfer
and approve
. It’s an implementation of the ERC-20 token standard!
DeFi Ed Note: An ERC-20 token contract keeps track of fungible tokens. One token is exactly equal to another token. No special rights. This makes ERC-20 tokens useful for things like a medium of exchange currency, voting rights, staking, and more. The ERC-20 standard is what allows Uniswap to operate. Without a standard for tokens, you could not conduct permissionless swaps.
import './libraries/Math.sol'; import './libraries/UQ112x112.sol';
Math.sol
and UQ112x112.sol
are both libraries which implement some mathematical operations.
using SafeMath for uint; using UQ112x112 for uint224;
Within the contract itself, we see the using statements, which includes these libraries in the specific limited context of the uint
and uint224
types. When these types are used, the library’s code will be used.
Quick TL;DR of imports and inheritance. The contract:
- Inherits from an ERC-20 implementation, which means that the pair will be an ERC-20 compliant token.
- Imports two math libraries and makes them available in the
uint
anduint224
types. - Defines three interfaces which let it interact with external smart contracts.
State Variables
uint public constant MINIMUM_LIQUIDITY = 10**3; bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)'))); address public factory; address public token0; address public token1; uint112 private reserve0; // uses single storage slot, accessible via getReserves uint112 private reserve1; // uses single storage slot, accessible via getReserves uint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves uint public price0CumulativeLast; uint public price1CumulativeLast; uint public kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event
State variables are the variables what will be stored permanently as part of the blockchain state. This costs gas, so rational actors will only include the variables absolutely required for the business logic.
uint public constant MINIMUM_LIQUIDITY = 10**3; bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)')));
The SELECTOR
constant is being used later for a low-level transfer call. No need to parse it.
address public factory; address public token0; address public token1;
The three address variables store the locations of the factory which deployed the contract, and the two tokens which make up the DEX pool.
uint112 private reserve0; // uses single storage slot, accessible via getReserves uint112 private reserve1; // uses single storage slot, accessible via getReserves uint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves uint public price0CumulativeLast; uint public price1CumulativeLast; uint public kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event
The six uint state variables store the token reserves, the last block time stamp, the token prices, and the reserve product. The use of uint112 and uint32 is a bit of code optimization which reduces the storage costs of the contract. The product is also stored as a state variable to optimize gas instead of calculating it each time it is needed.
The cumulative price variables are not used in this contract. They are consumed elsewhere, and are how Uniswap generates its Time Weighted Average Price oracles.
Using our knowledge about a DEX pool, we now know how the pool keeps track of the amount of tokens it has on hand for the y*x = k formula, and how it tracks the relevant addresses!
Non-Autist Note: Remember that the AMM formula for Uniswap v2 is x*y = k. This part of the code is storing the quantity of each token (x and y), the product of the quantity of each token (k), the price of each token, the time stamp of the last Ethereum block.
These values don’t update per block unless something happens that changes the state such as users calling functions on the pair by adding/removing liquidity, swaps, etc.
Code To Be Ignored For Now
uint private unlocked = 1; modifier lock() { require(unlocked == 1, 'UniswapV2: LOCKED'); unlocked = 0; _; unlocked = 1; } function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) { _reserve0 = reserve0; _reserve1 = reserve1; _blockTimestampLast = blockTimestampLast; } function _safeTransfer(address token, address to, uint value) private { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(SELECTOR, to, value)); require(success && (data.length == 0 || abi.decode(data, (bool))), 'UniswapV2: TRANSFER_FAILED'); } event Mint(address indexed sender, uint amount0, uint amount1); event Burn(address indexed sender, uint amount0, uint amount1, address indexed to); event Swap( address indexed sender, uint amount0In, uint amount1In, uint amount0Out, uint amount1Out, address indexed to ); event Sync(uint112 reserve0, uint112 reserve1);
Not all the code in a contract requires a deep dive. Some can be skimmed or ignored altogether.
As I skimmed the contract, I spotted this code that can be ignored for now - a modifier, a view function, an internal function, and event definition.
A modifier is a piece of code that will be run before a function is executed. They can be used for cleanly implementing reusable checks and requirements, such as a modifier that only allows the contract owner to call a protected function. In this case, the modifier is a reentrancy guard.
A reentrancy guard is a pattern used to protect critical functions against reentrancy. Reentrancy occurs when a function can be made to call itself in the process of its execution, which often bypasses important checks, balances, and effects that would otherwise happen. Bad things can happen, including withdrawing more than your allowance or other undesirable effects. For our purposes, we’ll assume the reentrancy guard is correctly implemented.
The function getReserves()
is a view only function that retrieves the value of some state variables. It’s pretty self-explanatory, so we don’t need to allot any brainpower to it now.
The _safeTransfer
function is for handling the low level transfer of the tokens and can be glossed over. Determining whether this function is safe and effective is more of an auditor’s scope, so we will assume that it’s correctly implemented.
Lastly, we have event definitions. Events are used to notify users outside the blockchain that Something Important Just Happened (TM). The exact details of the events can be skimmed over, but we should note the four events are mint
, burn
, swap
, and sync
and we should look for functions similar to these names. The presence of events is a useful flag for homing in on code that contains important business logic.
Construction
constructor() public { factory = msg.sender; } // called once by the factory at time of deployment function initialize(address _token0, address _token1) external { require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check token0 = _token0; token1 = _token1; }
When the contract is deployed to the blockchain, some initial setup has to be done.
The constructor is a special function that can only be called once as a contract is being deployed. The UniswapV2 Pair contract only performs one operation on construction: setting the factory address as msg.sender
, or the sender of the transaction. This is due to the assumed pattern of the Factory deploying the pair.
The initialize
function performs a few more pieces of work - setting the address of each of the tokens in the pool. Additionally, the require statement will revert any transaction that doesn’t match its conditional. In this case, the message has to come from the Factory contract. Since the Factory’s code can only ever call this function once, the developers did not implement any additional protections on this function.
- This contract assumes it was deployed by the factory
- On initialization, the pair’s underlying tokens are set
- The factory is trusted to only call the token-setting function once
Minting
function mint(address to) external lock returns (uint liquidity) { (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings uint balance0 = IERC20(token0).balanceOf(address(this)); uint balance1 = IERC20(token1).balanceOf(address(this)); uint amount0 = balance0.sub(_reserve0); uint amount1 = balance1.sub(_reserve1); bool feeOn = _mintFee(_reserve0, _reserve1); uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee if (_totalSupply == 0) { liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens } else { liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1); } require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED'); _mint(to, liquidity); _update(balance0, balance1, _reserve0, _reserve1); if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date emit Mint(msg.sender, amount0, amount1); }
The mint function is the low-level method that creates the Liquidity Provider (LP) token when you provide liquidity to a pool.
I’ve skipped past a few internal functions to the first external business logic function. Now we’re getting into the meat of the contract and the complexity ramps up significantly. Let’s see if we can figure it out by walking through line-by-line.
This function has the reentrancy guard implemented (note the presence of the lock
in the function definition line) which signals that it’s important.
First things first, the function retrieves its current reserves of each token. Then, using the IERC20
interface it imported, it checks the balance of each of the ERC-20 pair tokens. Then, it subtracts the reserves from the balance and stores it as the amount.
Huh? Shouldn’t balance - reserves = 0
? You would certainly think so. The only way this wouldn’t be the case is if tokens have been sent to the contract after the last time the reserves were updated. This suggests a design pattern where tokens have to be sent to the contract before invoking the mint
function. A quick trip to the Uniswap V2 Docs confirms this is the developer’s intent!
Pickle’s Note: throughout this contract you won’t usually see math operators like +, -, * , etc. In their place, you’ll find .sub
and .mul
and the like. This is the Safe Math pattern, and it’s used because this file uses Solidity 0.5.16. Any Solidity version prior to 0.8 should use Safe Math to avoid some edge cases and vulnerabilities involving math operations. Solidity versions 0.8 and up implement these checks automatically, and do not require Safe Math.
bool feeOn = _mintFee(_reserve0, _reserve1); uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee if (_totalSupply == 0) { liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens } else { liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1); } require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED'); _mint(to, liquidity);
On to the next block of logic. The function appears to check whether there’s a mint fee enabled (remember: the bool type means TRUE/FALSE). We’ll follow that internal function call in a minute, but for now we take it at face value.
It then makes a local copy of the totalSupply
state variable, which is the total supply of the pair’s ERC-20 LP token.
The next check is an if/else statement. Just like the Excel IF function, this will execute one branch if the condition is true, and another branch if the condition is false. The condition is _totalSupply == 0
, so the if statement will perform some initial setup if the pool hasn’t been initialized yet.
The liquidity =
part of each path does the math to determine how many tokens are going to be minted. You can parse these functions using your knowledge of DEX mechanics and see why the two functions are different.
The MINIMUM_LIQUIDITY
line confused me for a bit until I dug through the documentation. From the Uniswap V2 Docs:
To ameliorate rounding errors and increase the theoretical minimum tick size for liquidity provision, pairs burn the first MINIMUM_LIQUIDITY pool tokens. For the vast majority of pairs, this will represent a trivial value. The burning happens automatically during the first liquidity provision, after which point the totalSupply is forevermore bounded.
This is an example of a situation where the effect of the code is clear (burn a small portion of the first liquidity contribution) but the design intent was not clear. Only the documentation helps us figure out that point.
Finally after the token amount is calculated, it performs a sanity check to make sure it’s not trying to mint 0 tokens, then makes an internal mint call to actually mint the tokens!
_update(balance0, balance1, _reserve0, _reserve1); if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date emit Mint(msg.sender, amount0, amount1);
The function finishes up with some housekeeping. It updates its internal balances, then emits an event.
Recap time! Knowing what we know about DEX pairs, and what we were able to gather from this function, what does this function do?
- The function has a reentrancy guard and emits an event, signaling that it is core logic
- It requires that you send tokens to the pair prior to calling
mint
- It does some math, and decides how many tokens to mint
- It mints tokens to the specified address
- It updates the value of its reserves
This must be the low-level function which adds liquidity and mints LP tokens to the liquidity provider!
Autist Note: You may see at this point why use of an interface like the router is desired. Attempting to manually LP by sending tokens then calling mint would be likely to fail, and likely to instead add your tokens to the stack of someone else. Token transfer and mint should be done in a single transaction, which means you need to call them via a smart contract - whether the Uniswap router or your own custom implementation.
Diving Deeper
Let’s explore some of the internal functions that we glossed over when going through mint
for the first time.
// if fee is on, mint liquidity equivalent to 1/6th of the growth in sqrt(k) function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) { address feeTo = IUniswapV2Factory(factory).feeTo(); feeOn = feeTo != address(0); uint _kLast = kLast; // gas savings if (feeOn) { if (_kLast != 0) { uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1)); uint rootKLast = Math.sqrt(_kLast); if (rootK > rootKLast) { uint numerator = totalSupply.mul(rootK.sub(rootKLast)); uint denominator = rootK.mul(5).add(rootKLast); uint liquidity = numerator / denominator; if (liquidity > 0) _mint(feeTo, liquidity); } } } else if (_kLast != 0) { kLast = 0; } }
The _mintFee
function is responsible for taking profit for Uniswap.
This function first asks the factory what address to send the fee to. If the factory has not enabled a fee, the address returned will be the zero address (0x0000...000), and this function will not send any fee.
If the factory has enabled fee-taking, and if the pair has liquidity, it will calculate the amount of tokens to mint, then mint them to the fee taking address. Turbos or those looking to determine the funds flow and business logic are again encouraged to work out the calculations.
By examining this function, we see that the fee taking does not occur by directly subtracting part of the newly added tokens from the liquidity provider’s account, but via dilution. The fee taking address gains LP tokens, which provides it access to a share of the pair’s funds.
This is an important distinction! By leaving the underlying tokens in this contract, the fee does not inhibit the liquidity of the pool.
// update reserves and, on the first call per block, price accumulators function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private { require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW'); uint32 blockTimestamp = uint32(block.timestamp % 2**32); uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) { // * never overflows, and + overflow is desired price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed; price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed; } reserve0 = uint112(balance0); reserve1 = uint112(balance1); blockTimestampLast = blockTimestamp; emit Sync(reserve0, reserve1); }
The update function is in charge of updating the state variable values after changes to the pair’s token balances have occurred.
It also updates the price accumulators. The price accumulators are part of the Uniswap Time-Weighted Average Price (TWAP) oracle, and are not used internally in the pair contract’s logic.
Setting aside all the overflow checking and assorted uint112 shenanigans, this one is pretty simple. It just sets the variables. Further detail is outside the scope of this walkthrough.
Give Me My Money Back!
// this low-level function should be called from a contract which performs important safety checks function burn(address to) external lock returns (uint amount0, uint amount1) { (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings address _token0 = token0; // gas savings address _token1 = token1; // gas savings uint balance0 = IERC20(_token0).balanceOf(address(this)); uint balance1 = IERC20(_token1).balanceOf(address(this)); uint liquidity = balanceOf[address(this)]; bool feeOn = _mintFee(_reserve0, _reserve1); uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED'); _burn(address(this), liquidity); _safeTransfer(_token0, to, amount0); _safeTransfer(_token1, to, amount1); balance0 = IERC20(_token0).balanceOf(address(this)); balance1 = IERC20(_token1).balanceOf(address(this)); _update(balance0, balance1, _reserve0, _reserve1); if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date emit Burn(msg.sender, amount0, amount1, to); }
This function is the low-level method for removing liquidity and retrieving funds.
Next on the list is the burn
function. If the mint
function was to add liquidity, this one must logically be to remove liquidity. It starts off by setting a few local variables to obtain the various quantities needed. Next, it incurs a fee if fees are enabled, although the internal logic of _mintFee
means it is unlikely to take a fee except in edge cases where the fee is deserved but not already applied.
Next, the amounts are calculated. This function also uses the pattern of sending the LP tokens to the pair first, before calling burn
. The uint liquidity = balanceOf[address(this)]
check indicates that we’re checking the pair’s balance of its own token, which should be 0 unless someone has just sent it LP tokens in preparation to burn.
This pattern means that the amount0
and amount1
quantities will be the fraction of each token’s reserves that the user is entitled to. After a sanity check, the function burns the LP tokens, transfers out the appropriate amount of underlying tokens, and updates its state variables.
Swap
// this low-level function should be called from a contract which performs important safety checks function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock { require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT'); (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY'); uint balance0; uint balance1; { // scope for _token{0,1}, avoids stack too deep errors address _token0 = token0; address _token1 = token1; require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO'); if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data); balance0 = IERC20(_token0).balanceOf(address(this)); balance1 = IERC20(_token1).balanceOf(address(this)); } uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0; uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0; require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT'); { // scope for reserve{0,1}Adjusted, avoids stack too deep errors uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3)); uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3)); require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K'); } _update(balance0, balance1, _reserve0, _reserve1); emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to); }
Now we come to the meat of the pair. The swap function is the whole reason the DEX exists. Understanding this function gives us insight into the capabilities of the platform.
At this point, we should expect the same pattern of requiring token transfer prior to the function being called. Bear that in mind as we parse the function. Note also that the same function handles swaps from token0 to token 1, and swaps from token1 to token0.
We start with a pair of sanity checks that you’re actually trying to transfer something out, and that you’re not asking to withdraw more than the pair’s token reserves.
The comment // scope for _token{0,1}, avoids stack too deep errors
just tells us that the developers are structuring this code in a specific way to allow it to execute. Stack too deep errors occur when you try to get the Ethereum Virtual Machine to do too much at one time.
The function sanity checks that you’re not trying to send tokens to the actual token contracts (where it would be lost forever), then sends you the tokens.
How is it safe to just optimistically hand you the money? Don’t worry, Uniswap will get its due in the end. Require statements later in the function prevent you from withdrawing funds that you haven’t paid for, and undo any effects that may have happened up to that point.
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
I’m going to dwell on the next line for a moment, because this is both beautiful and important.
This line, combined with optimistically transferring you the funds in the two lines before, is what enables flash loans. As you may recall, flash loans are a DeFi-exclusive financial primitive that allows you to access an immense amount of capital as long as it is instantaneously paid back.
This line calls back out of the pair contract to the address sending the message, which allows the sender to execute the arbitrage or whatever else it’s using the flash loaned funds to do. This all occurs in the middle of this transaction, before the rest of the swap
function finishes. When the swap
function resumes, the checks and balances in the second half of the function will ensure that the pool did not take a loss due to the code executed by the other party.
Take a moment to appreciate this. You are looking at the beating heart of something that is exclusively crypto native. You can never replicate this effect in meatspace. And it only takes a few lines of code.
After the flash loan or regular token transfer occurs, the pair checks its own balances in the same manner as before. It then uses the balance and reserves to calculate the amounts paid into the pair in the next two lines. These use the ?
operator and are called ternary conditionals. A ternary conditional is just shorthand for an IF statement. If the condition before the ? is true, the statement before the colon is executed. If the condition before the ? is false, the statement after the colon is executed. So, the line
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
is functionally equivalent to:
if (balance0 > _reserve0 - amount0Out) { amount0In = balance0 - (_reserve0 - amount0Out); } else { amount0In = 0; }
Next, we require that at least one of the amounts in is greater than zero. If that’s not true, either your flash loan was not profitable, or you’re trying to take tokens out without paying for them, and the function will revert. The double pipe operator ||
means a logical OR.
require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3)); uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3)); require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
Finally, the function checks that the y*x=k invariant has been preserved. This is the part that ensures you’ve paid the correct price for the tokens you’ve taken out! The multiplication and factors are to avoid loss of precision errors.
Autist Note: The pair does not care what price you paid. It only cares that y*x after swap >= y*x before swap. Trivial example: Say that liquidity is 1000 Token A and 1000 Token B. It will be equally happy with you paying 1.12 A to receive 1 B or paying 100 A to receive 1 B.
After updating the state variables and emitting an event, the swap function concludes!
To recap, we’ve deducted that this function:
- Optimistically transfers tokens to the transaction sender
- If a flash loan, calls back to the transaction sender after transferring
- Requires you to transfer tokens before calling OR perform a profitable flash loan operation
- Checks to make sure it’s getting paid back the appropriate amount of tokens for the y*x=k invariant to hold
Administrative Functions
// force balances to match reserves function skim(address to) external lock { address _token0 = token0; // gas savings address _token1 = token1; // gas savings _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0)); _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1)); } // force reserves to match balances function sync() external lock { _update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1); }
Finally, we have two functions for correcting unforeseen errors that may arise. skim
allows a user to withdraw any extra if the balance has somehow exceeded the reserves. sync
resets the reserves to the token balances if they somehow exceed the balance.
Wrapping Up
There you have it! We’ve just looked under the hood of the Toyota Hilux of DeFi. Like the robust, functional, and omnipresent pickup, you will find Uniswap V2 clones powering swaps on every EVM-compatible chain. If you’ve ever swapped on a DEX, you’ve probably used this exact contract.
We’ve also seen inside the guts of the atomic bomb. Flash loans are an almost incomprehensible product from a TradFi perspective. The idea that random strangers can take uncollateralized loans of hundreds of millions of dollars would probably give most bank VPs heart attacks. Yet as we saw, a few lines of code contain all the checks and balances needed to trustlessly allow random users to wield the power of the whale for a few milliseconds.
You shouldn’t have to be a smart contract auditor or a web3 developer to understand the binding transactions you’re signing. I sincerely hope that this walkthrough helps you gain the confidence to do your own due diligence on the protocols you invest in.
I’ve tried to make this as friendly as possible, but code is complex. If there’s anything you’d like clarification on, I’ll take questions in the comments of this article, or you can shoot me a DM on Twitter. Special thanks to the DeFi Education team for the opportunity to share this with you!
DeFi Ed Note: We hope this helps some of you get a deeper understanding of how smart contracts work. We certainly don’t expect that people can go from zero coding knowledge to fluency, but this gives you an idea of the different components of a smart contract. Until next time..