Introduction
DeFi, short for Decentralized Finance, is a prominent use case for blockchain technology. Among the many protocols that exist, Uniswap was one of the earliest to implement the automatic market maker (AMM) model and remains one of the most widely used solutions to date.
To develop our solution, we have chosen to use Vyper. This is because it offers clear readability, built-in security features, and optimization capabilities right out of the box. By leveraging Vyper, we aim to enhance the overall quality and reliability of our code.
What is an AMM?
Automated market makers (AMMs) are part of the decentralized finance (Defi) ecosystem. They allow digital assets to be traded in a permissionless and automatic way by using liquidity pools rather than a traditional market of buyers and sellers. AMM users supply liquidity pools with crypto tokens, whose prices are determined by a constant mathematical formula. Liquidity pools can be optimized for different purposes, and are proving to be an important instrument in the Defi ecosystem. In this tutorial, we are going to use a constant-product AMM.
Setup project
For this particular project, we have decided to use ApeWorx, a novel framework for smart contract development that utilizes Python. We chose ApeWorx for its simplicity, ease of use, and powerful capabilities. Additionally, the framework supports plugins, allowing us to extend its functionality and enhance our development process even further.
By leveraging ApeWorx, we aim to streamline our smart contract development workflow, improve the readability of our code, and maximize the efficiency of our development efforts. We believe that ApeWorx's advanced features and flexibility will allow us to build high-quality smart contracts that meet our specific needs and requirements.
To begin using ApeWorx for this project, you will need to have Python installed along with a virtual environment. You can set up these prerequisites by following the links provided.
If you are new to this process, do not be intimidated. The setup process is simple, and we are confident that you will be able to get started quickly. With the necessary tools in place, we can begin leveraging ApeWorx's powerful features to build robust and reliable smart contracts.
To start creating our project, we first need to create a new folder called uviswap and navigate to it using the terminal:
$ mkdir uviswap
$ cd ./uviswap
Now we need to create a Python Virtual Enviroment, this is to keep our dependencies isolated from other packages. And we install our packages, in this case, we just need to install ApeWorx.
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install eth-ape
Next, we will initialize a new ApeWorx project and name it uviswap:
$ ape init
$ Please enter project name: uviswap
🚧 If you encounter the issue of
No module named 'pkg_resources'
, you need to runpip install setuptools
to fix it.
Finally, we need to install Vyper as a plugin so that we can work with it using ApeWorx. Fortunately, the process is straightforward to understand, and can be accomplished using a single command:
$ ape plugins install vyper
Great! With our project now set up, we can move on to implementing the smart contracts.
Smart contract development
For this DeFi application, we will define two smart contracts: the Pair and the Factory.
The Pair contract is the heart of our automated market maker project and will be responsible for holding a pair of tokens.
The Factory contract will be responsible for creating new pairs using new tokens or returning the address of a pair if it already exists.
Together, these contracts will form the backbone of our DeFi application and allow us to implement our automated market-maker model.
UviswapPair
To begin implementing the Pair contract, we will create a new file named UviswapPair.vy in the contracts/ directory. We will specify the Vyper version as ^0.3.10 and import the ERC20 and ERC20 Detailed interfaces from Vyper.
This boilerplate code will provide us with the basic foundation for developing our Pair contract.
# pragma version ^0.3.10
from vyper.interfaces import ERC20
from vyper.interfaces import ERC20Detailed
implements: ERC20
implements: ERC20Detailed
This is our basic boilerplate, we need the ERC20 definition to allow our users to use their shares however they want.
# pragma version ^0.3.10
from vyper.interfaces import ERC20
from vyper.interfaces import ERC20Detailed
###### EVENTS ######
event Transfer:
sender: indexed(address)
receiver: indexed(address)
value: uint256
event Approval:
owner: indexed(address)
spender: indexed(address)
value: uint256
###### STATE ######
# Share token name
name: public(String[32])
# Share token symbol
symbol: public(String[32])
# Share token decimals
decimals: public(uint8)
# By declaring `balanceOf` as public, vyper automatically generates a 'balanceOf()' getter
# method to allow access to account balances.
balanceOf: public(HashMap[address, uint256])
# By declaring `allowance` as public, vyper automatically generates the `allowance()` getter
allowance: public(HashMap[address, HashMap[address, uint256]])
# By declaring `totalSupply` as public, we automatically create the `totalSupply()` getter
totalSupply: public(uint256)
###### CONSTRUCTOR ######
@external
def __init__():
self.name = "Uviswap"
self.symbol = "UVI"
self.decimals = 18
###### INTERNAL METHODS ######
@internal
def _mint(_to: address, _value: uint256):
assert _to != empty(address), "ERC20: mint to the zero address"
self.balanceOf[_to] += _value
self.totalSupply += _value
log Transfer(empty(address), _to, _value)
@internal
def _burn(_to: address, _value: uint256):
assert _to != empty(address), "ERC20: burn to the zero address"
self.balanceOf[_to] -= _value
self.totalSupply -= _value
log Transfer(_to, empty(address), _value)
###### EXTERNAL METHODS ######
@external
def transfer(_to : address, _value : uint256) -> bool:
assert _to != empty(address), "ERC20: transfer to the zero address"
self.balanceOf[msg.sender] -= _value
self.balanceOf[_to] += _value
log Transfer(msg.sender, _to, _value)
return True
@external
def transferFrom(_from : address, _to : address, _value : uint256) -> bool:
self.allowance[_from][msg.sender] -= _value
self.balanceOf[_from] -= _value
self.balanceOf[_to] += _value
log Transfer(_from, _to, _value)
return True
@external
def approve(_spender : address, _value : uint256) -> bool:
self.allowance[msg.sender][_spender] = _value
log Approval(msg.sender, _spender, _value)
return True
@external
def burn(_value: uint256):
self._burn(msg.sender, _value)
This code is the implementation of the Uviswap Pair smart contract, which represents a liquidity pool that holds two tokens. The Pair contract is written in Vype and uses two ERC20 interfaces - ERC20 and ERC20Detailed - to define the basic functionality and properties of a token.
The code defines the following state variables:
- name: the name of the share token
- symbol: the symbol of the share token
- decimals: the number of decimal places for the share token
- balanceOf: a mapping of addresses to their token balances
- allowance: a mapping of addresses to their approved spending limits
- totalSupply: the total number of tokens in circulation
The constructor sets the initial values for the name, symbol, and decimal state variables.
The code also defines several internal and external methods:
- _mint: an internal method used to mint new tokens and update the balances of the token holder and the total supply
- _burn: an internal method used to burn existing tokens and update the balances of the token holder and the total supply
- transfer: an external method used to transfer tokens between accounts and update their balances
- transferFrom: an external method used to transfer tokens between accounts with the approval of the token holder and update their balances
- approve: an external method used to approve a third-party spender to use the tokens on behalf of the token holder
- burn: an external method used to burn tokens from the caller's account and update their balance
Finally, the code defines two events, Transfer and Approval, that are emitted when tokens are transferred or approved for spending. The smart contract is divided on:
- EVENTS
- STATE
- INTERNAL METHODS
- EXTERNAL METHODS
🐍⚠️ This part is important since we are going to jump between those. So take note so you don't get lost.
In addition to the ERC20 functionality, we need to add some state variables to our smart contract to implement the AMM functionality. These state variables include the two tokens in the pair and the corresponding reserves for each token. We will also define the invariant constant k, which is the product of the two reserves and is constant in a balanced pair:
###### STATE ######
...
# Tokens in the pair
token0: public(address)
token1: public(address)
# Reserves for each token
reserve0: public(uint256)
reserve1: public(uint256)
...
To enable our pool to track the tokens it handles and the amount of reserves available for each token, we need to add the token variables and reserve variables to our smart contract. These variables will be used to keep track of the balances for each token and the amount of liquidity in the pool.
We also need to create an internal update method to keep track of the reserves each time we add or remove liquidity. This method will be called internally every time liquidity is added or removed from the pool and will update the reserves accordingly.
###### INTERNAL METHODS ######
...
@internal
def _update(_reserve0: uint256, _reserve1: uint256):
self.reserve0 = _reserve0
self.reserve1 = _reserve1
...
To set up the Pair, we need to add a setup function that initializes the tokens and reserves. To ensure that it can only be called once, we'll add a flag variable that will be set to true once the setup is complete. This function will be called by the Factory when a new Pair is created.
###### STATE ######
...
# Setup flag
_setup: bool
...
Since the method will be called from the outside we need to define it in the external methods section.
###### EXTERNAL METHODS ######
...
@external
def setup(_token0: address, _token1: address):
assert self._setup == False, "Uviswap Pair: already initialized"
self.token0 = _token0
self.token1 = _token1
self._setup = True
...
Add liquidity
The addLiquidity method is responsible for allowing users to deposit tokens into our smart contract, which is necessary for the AMM to function. When a user deposits tokens, the smart contract mints some share tokens in return. These share tokens can be redeemed later along with some collected fees resulting from other users swapping tokens with this liquidity.
To prevent bad actors from exploiting this functionality, we added a reentrancy guard.
###### EXTERNAL METHODS ######
...
@external
@nonreentrant("add")
def addLiquidity(_amount0: uint256, _amount1: uint256) -> uint256:
# add liquidity
ERC20(self.token0).transferFrom(msg.sender, self, _amount0)
ERC20(self.token1).transferFrom(msg.sender, self, _amount1)
# keep the pool balanced (k=x*y)
if self.reserve0 > 0 or self.reserve1 > 0:
assert self.reserve0 * _amount1 == self.reserve1 * _amount0, "Uviswap Pair: x / y != dx / dy"
shares: uint256 = 0
if self.totalSupply == 0:
shares = isqrt(_amount0 * _amount1)
else:
shares = min(
(_amount0 * self.totalSupply) * self.reserve0,
(_amount1 * self.totalSupply) * self.reserve1
)
assert shares > 0, "Uviswap Pair: shares were zero"
# mint shares to liquidity provider
self._mint(msg.sender, shares)
# update reserves
self._update(ERC20(self.token0).balanceOf(self), ERC20(self.token1).balanceOf(self))
return shares
...
This code is defining the addLiquidity
in Vyper.
The @external
decorator indicates that this function can be called externally. The @nonreentrant("add")
decorator prevents reentrancy attacks on the function.
When a user calls this function, they must pass in two arguments _amount0
and _amount1
, which represent the amount of token0 and token1 they want to add to the liquidity pool.
First, the function transfers _amount0
and _amount1
from the sender to the smart contract using the transferFrom
method of the ERC20
token contract.
Next, the function checks if the reserves of token0 and token1 are greater than 0. If they are, it verifies that the new reserves maintain the constant product (k=x*y), which is necessary for the AMM to function properly. If the new reserves do not maintain this constant product, an assertion error is raised.
The function then calculates the amount of shares to mint for the liquidity provider, based on the amount of tokens they deposited, the current reserves, and the total supply of shares already in circulation. If this is the first liquidity deposit, the function sets the initial shares based on the square root of the product of the deposited token amounts.
If the calculation of shares results in a value of 0, an assertion error is raised.
The function then mints the calculated amount of shares to the liquidity provider and updates the reserves using the _update
method and the function returns the amount of shares minted to the liquidity provider.
Remove liquidity
To remove liquidity from the pool, the user needs to call the removeLiquidity
function, passing the number of shares to burn. In exchange for burning the shares, the user will receive a proportional amount of the tokens that were originally deposited into the pool.
The code for the removeLiquidity
function is as follows:
###### EXTERNAL METHODS ######
...
@external
@nonreentrant("remove")
def removeLiquidity(_shares: uint256) -> (uint256, uint256):
_token0: ERC20 = ERC20(self.token0)
_token1: ERC20 = ERC20(self.token1)
bal0: uint256 = _token0.balanceOf(self)
bal1: uint256 = _token1.balanceOf(self)
amount0: uint256 = (_shares * bal0) / self.totalSupply
amount1: uint256 = (_shares * bal1) / self.totalSupply
# _burn checks if the user has enough shares
self._burn(msg.sender, _shares)
# update reserves
self._update(bal0 - amount0, bal1 - amount1)
# transfers the tokens back
_token0.transfer(msg.sender, amount0)
_token1.transfer(msg.sender, amount1)
return (amount0, amount1)
...
First, the function calculates the proportional amounts of each token based on the number of shares being burned. It then burns the shares that the user passed in and transfers the proportional amounts of each token back to the user. Finally, it updates the reserves to reflect the removed liquidity.
As with the addLiquidity
function, a reentrancy guard has been added to prevent reentrancy attacks.
Swap
The swap function is a critical feature of any automated market maker. In this function, a user provides a token and an amount, and based on these inputs, we return the opposite token of the pair along with the corresponding amount. In the process, we charge a small fee to compensate our liquidity providers.
To protect against malicious actors exploiting this feature, we have added a reentrancy guard.
###### EXTERNAL METHODS ######
...
@external
@nonreentrant("swap")
def swap(_tokenIn: address, _amountIn: uint256) -> uint256:
assert _tokenIn == self.token0 or _tokenIn == self.token1, "Uviswap Pair: invalid token"
assert _amountIn > 0, "Uviswap Pair: amount in is zero"
# variables to interact with the liquidity pool
tokenIn: ERC20 = empty(ERC20)
tokenOut: ERC20 = empty(ERC20)
reserveIn: uint256 = 0
reserveOut: uint256 = 0
# determine which token is being swapped in
# and assigning variables accordingly
isToken0: bool = _tokenIn == self.token0
if isToken0:
tokenIn = ERC20(self.token0)
tokenOut = ERC20(self.token1)
reserveIn = self.reserve0
reserveOut = self.reserve1
else:
tokenIn = ERC20(self.token1)
tokenOut = ERC20(self.token0)
reserveIn = self.reserve1
reserveOut = self.reserve0
# transfer in the tokens
tokenIn.transferFrom(msg.sender, self, _amountIn)
# 0.3% fee
amountInWithFee: uint256 = (_amountIn * 997) / 1000
# calculate tokens to transfer
amountOut: uint256 = (reserveOut * amountInWithFee) / (reserveIn + amountInWithFee)
tokenOut.transfer(msg.sender, amountOut)
# update reserves
self._update(ERC20(self.token0).balanceOf(self), ERC20(self.token1).balanceOf(self))
# transfer in the tokens
return amountOut
...
The function allows a user to swap a given token for its counterpart in the pair by calling the method with the token address and the amount to be swapped.
The function first checks whether the token being swapped is one of the tokens in the pair, and whether the amount being swapped is greater than zero. If either of these conditions fails, the function will not execute.
Next, determines which token in the pair is being swapped in, and assigns variables accordingly. It then transfers the input token from the user to the contract.
Then calculates the fee for the swap, which is 0.3% of the input amount, and calculates the corresponding amount of the output token to transfer back to the user based on the current reserves in the pool.
Finally, it transfers the output tokens to the user, updates the reserves, and returns the amount of output tokens transferred.
🦄 You can find the complete UviswapPair smart-contract here.
UviswapFactory
To begin implementing the Factory contract, we will create a new file named UviswapFactory.vy in the contracts directory and specify the Vyper version as 0.3.10. This smart contract will be responsible for creating liquidity pools that use two tokens that can be provided by users.
The code for our initial implementation should look something like this:
# pragma version ^0.3.10
###### INTERFACES ######
###### STATE ######
###### CONSTRUCTOR ######
###### METHODS ######
The following code block defines an interface for the UviswapPair smart contract. This interface only includes one method, setup, which we will use to create a new liquidity pool.
...
###### INTERFACES ######
interface UviswapPair:
def setup(_token0: address, _token1: address): nonpayable
...
This interface declares the method signature for the setup function that takes two parameters, _token0
and _token1
of type address
, representing the two tokens that will be used in the liquidity pool. The nonpayable
modifier ensures that this method does not accept any Ether as payment.
Next, we'll declare the state of the smart contract. We need to map the token pairs that we have created and add an array to store each one of them. Additionally, we need to declare a master pair contract address, which will be used as a blueprint by Vyper to create and deploy new pair smart contracts.
Here's the code:
###### STATE ######
...
# Pair Contract => Token <=> Token
getPair: public(HashMap[address, HashMap[address, address]])
# All pairs list
allPairs: public(DynArray[address, 30])
# Uviswap Pair contract address used to create clones of it
pairContract: address
...
After declaring the state variables, we can define the constructor function. This function is important because it allows us to pass the address of an empty pair smart contract that we must deploy before we deploy this one.
###### CONSTRUCTOR ######
...
@external
def __init__(_pairContract: address):
self.pairContract = _pairContract
...
That would be everything we need before defining the main createPair method, which will allow our users to create a token pair freely.
Create pair
The create pair method is simple, we pass in two tokens, token A and token B., with these two tokens we deploy a new pair smart contract (using the pairContract state variable we defined in the constructor) and we add to the pairs mapping and the pairs array, and that's it!
###### METHODS ######
...
@external
def createPair(_tokenA: address, _tokenB: address) -> address:
assert _tokenA != _tokenB, "Uviswap Factory: identical addresses"
assert _tokenA != empty(address) and _tokenB != empty(address), "Uviswap Factory: zero address"
assert self.getPair[_tokenA][_tokenB] == empty(address), "Uviswap Factory: pair exists"
# create pair smart contract
pair: address = create_forwarder_to(self.pairContract)
UviswapPair(pair).setup(_tokenA, _tokenB)
# populate mapping in, and in the reverse direction
self.getPair[_tokenA][_tokenB] = pair
self.getPair[_tokenB][_tokenA] = pair
# append pair to all pairs array
self.allPairs.append(pair)
return pair
...
🦄 You can find the complete Uviswap Factory smart-contract here.
Testing
Now, we need to cover the testing section, which is a crucial part of our application. In this section, we will cover the basics of token swaps and liquidity addition. To begin with, we need to deploy mock smart contracts for testing, and for that, we will use Vyper's official example.
🚧 To do this, we need to create a mocks/ folder inside the contracts/ folder, and within it, create an Token.vy file. You can add the code from the following link to this file: Vyper ERC20 Token.
🐍⚠️ It is important to note that while the current testing strategy covers the basics, it is not sufficient for production. It is recommended to conduct thorough testing that includes fuzz testing and stateful testing before deploying an application to production.
After setting up the necessary mock smart contracts, we will create a conftest.py file inside the tests folder. This file will be used to define reusable elements such as fixtures, which act like functions that return a new deployment each time to test our application. For testing our automated market maker, we will only need two demo tokens.
import pytest
@pytest.fixture(scope="function")
def usdt_token(project, accounts):
"""
Returns a new instance of a mock USDT token contract, with an initial supply of 1,000.
"""
return project.Token.deploy("USDT", "USDT", 18, 1000, sender=accounts[0])
@pytest.fixture(scope="function")
def usdc_token(project, accounts):
"""
Returns a new instance of a mock USDC token contract, with an initial supply of 1,000.
"""
return project.Token.deploy("USDC", "USDC", 18, 1000, sender=accounts[0])
@pytest.fixture(scope="function")
def uviswap_pair(project, accounts):
"""
Returns a new instance of an empty Pair contract, primarily to be used as a blueprint.
"""
return project.UviswapPair.deploy(sender=accounts[0])
@pytest.fixture(scope="function")
def uviswap_factory(project, accounts, uviswap_pair):
"""
Returns a new instance of a Factory contract, the main entry point of our AMM.
"""
return project.UviswapFactory.deploy(uviswap_pair.address, sender=accounts[0])
The code defines four fixtures: usdt_token
, usdc_token
, uviswap_pair
, and uviswap_factory
.
The uviswap_pair
fixture creates and deploys a new instance of the UviswapPair
contract.
The uviswap_factory
fixture creates and deploys a new instance of the UviswapFactory
contract, which requires the address of an empty UviswapPair
contract as an argument. In this case, the address of the uviswap_pair
fixture is used as the argument for the UviswapFactory
deployment.
To test the functionality that we defined earlier, we will create a file called test_uviswap.py inside the tests folder. At the top of this file, we should import project
from Ape. This allows us to access the contracts on some of the tests.
from ape import project
🧪 Creating a Liquidity Pool
The test creates a new pair contract by calling the createPair
function of the uviswap_factory
contract and passing the addresses of two tokens as arguments. It then retrieves the address of the created pair contract and uses it to instantiate a new UviswapPair object. Finally, it asserts that the UviswapPair
object has the expected default values for its token0
, token1
, reserve0
, reserve1
, and totalSupply
variables.
def test_create_pair(accounts, usdt_token, usdc_token, uviswap_factory):
# Create a new pair contract by calling the method with the addresses of two tokens
uviswap_factory.createPair(usdt_token.address, usdc_token.address, sender=accounts[0])
# Access the address of the created pair contract
pair_address = uviswap_factory.allPairs(0)
# Access the pair contract object by its address
pair = project.UviswapPair.at(pair_address)
# Assert that the pair contract object has the expected default values
assert pair.token0() == usdt_token.address
assert pair.token1() == usdc_token.address
assert pair.reserve0() == 0
assert pair.reserve1() == 0
assert pair.totalSupply() == 0
🧪 Adding liquidity to the Pool
This code is testing the functionality of creating a new pair contract, adding liquidity to the contract, and checking if all the values are set correctly.
def test_add_liquidity(accounts, usdt_token, usdc_token, uviswap_factory):
# Create a new pair contract by calling the method with the addresses of two tokens
uviswap_factory.createPair(usdt_token.address, usdc_token.address, sender=accounts[0])
# Access the address of the created pair contract
pair_address = uviswap_factory.allPairs(0)
# Access the pair contract object by its address
pair = project.UviswapPair.at(pair_address)
# Define the amount to add liquidity to the pair contract (500 USDC/USDT)
amount = int(500e18)
# Approve pair to use our tokens
usdt_token.approve(pair_address, amount, sender=accounts[0])
usdc_token.approve(pair_address, amount, sender=accounts[0])
# Add liquidity to the pair contract
pair.addLiquidity(amount, amount, sender=accounts[0])
# Check if the liquidity is added correctly
assert pair.balanceOf(accounts[0]) == amount # minted shares
assert pair.reserve0() == amount
assert pair.reserve1() == amount
assert usdt_token.balanceOf(pair) == amount
assert usdc_token.balanceOf(pair) == amount
🧪 Removing liquidity from the Pool
This code tests the functionality of creating a new pair contract, adding liquidity to the contract, then removing value from the contract and checking if all the values are set correctly.
def test_remove_liquidity(accounts, usdt_token, usdc_token, uviswap_factory):
# Create a new pair contract by calling the method with the addresses of two tokens
uviswap_factory.createPair(usdt_token.address, usdc_token.address, sender=accounts[0])
# Access the address of the created pair contract
pair_address = uviswap_factory.allPairs(0)
# Access the pair contract object by its address
pair = project.UviswapPair.at(pair_address)
# Define the amount to add liquidity to the pair contract (500 USDC/USDT)
amount = int(500e18)
# Approve pair to use our tokens
usdt_token.approve(pair_address, amount, sender=accounts[0])
usdc_token.approve(pair_address, amount, sender=accounts[0])
# Add liquidity to the pair contract
pair.addLiquidity(amount, amount, sender=accounts[0])
# Get the number of shares owned by the account
shares = pair.balanceOf(accounts[0])
# Remove liquidity from the pair
pair.removeLiquidity(shares, sender=accounts[0])
# Assert that the pool reserves and token balances are now all zero
assert pair.balanceOf(accounts[0]) == 0 # confirm shares have been removed
assert pair.reserve0() == 0
assert pair.reserve1() == 0
assert usdc_token.balanceOf(pair) == 0
assert usdt_token.balanceOf(pair) == 0
🧪 Swapping assets
This code is testing the swap()
function of the pair contract. The code creates a new pair contract with mock USDT and USDC tokens, adds liquidity to it, and then performs a swap of 100 USDT for USDC. The balances of the accounts before and after the swap are compared to ensure the swap was performed correctly.
def test_swap(accounts, usdt_token, usdc_token, uviswap_factory):
# Create a new pair contract by calling the method with the addresses of two tokens
uviswap_factory.createPair(usdt_token.address, usdc_token.address, sender=accounts[0])
# Access the address of the created pair contract
pair_address = uviswap_factory.allPairs(0)
# Access the pair contract object by its address
pair = project.UviswapPair.at(pair_address)
# Define the amount to add liquidity to the pair contract (500 USDC/USDT)
amount = int(500e18)
# Approve pair to use our tokens
usdt_token.approve(pair_address, amount, sender=accounts[0])
usdc_token.approve(pair_address, amount, sender=accounts[0])
# Add liquidity to the pair contract
pair.addLiquidity(amount, amount, sender=accounts[0])
# Save the initial balances before the swap
initial_balances = {
"usdt": usdt_token.balanceOf(accounts[0]),
"usdc": usdc_token.balanceOf(accounts[0])
}
# Define the amount to be swapped for the other token on the pair contract (100 USDT)
swap_amount = int(100e18)
# Approve token for a swap
usdt_token.approve(pair_address, swap_amount, sender=accounts[0])
# Perform the swap
pair.swap(usdt_token.address, swap_amount, sender=accounts[0])
# Get the balances after the swap
final_balances = {
"usdt": usdt_token.balanceOf(accounts[0]),
"usdc": usdc_token.balanceOf(accounts[0])
}
# Test that we swapped tokens correctly
assert initial_balances["usdt"] - swap_amount == final_balances["usdt"] # USDT balance should decrease (initial - swapped amount)
assert initial_balances["usdc"] < final_balances["usdc"] # USDC balance should increase
🧪 Collecting fees
This code is testing the removeLiquidity()
function of the pair contract. The code creates a new pair contract with mock USDT and USDC tokens, adds liquidity to it, and then performs a swap of 10 USDT for USDC. The balances of the accounts before and after the swap are compared to ensure the swap was performed correctly.
def test_collecting_fees(accounts, usdt_token, usdc_token, uviswap_factory):
# Create a new pair contract by calling the method with the addresses of two tokens
uviswap_factory.createPair(usdt_token.address, usdc_token.address, sender=accounts[0])
# Access the address of the created pair contract
pair_address = uviswap_factory.allPairs(0)
# Access the pair contract object by its address
pair = project.UviswapPair.at(pair_address)
# Define the amount to add liquidity to the pair contract (100 USDC/USDT)
amount = int(100e18)
# Approve pair to use our tokens
usdt_token.approve(pair_address, amount, sender=accounts[0])
usdc_token.approve(pair_address, amount, sender=accounts[0])
# Add liquidity to the pair contract
pair.addLiquidity(amount, amount, sender=accounts[0])
# Save the initial balances before the swap
balances = {
"usdt": usdt_token.balanceOf(accounts[0]),
"usdc": usdc_token.balanceOf(accounts[0])
}
# Define the amount to be swapped for the other token on the pair contract (10 USDT)
swap_amount = int(10e18)
# Approve token for a swap
usdt_token.approve(pair_address, swap_amount, sender=accounts[0])
# Perform the swap
pair.swap(usdt_token.address, swap_amount, sender=accounts[0])
# Get shares
shares = pair.balanceOf(accounts[0])
assert shares == int(100e18) # shares received should be equal to the amount of liquidity added
# Remove liquidity
pair.removeLiquidity(shares, sender=accounts[0])
# Get the balances after claiming all the shares
balances = {
"usdt": usdt_token.balanceOf(accounts[0]),
"usdc": usdc_token.balanceOf(accounts[0])
}
# Test that we collected fees correctly
assert balances["usdt"] == int(1000e18) # USDT balance should be the original mint
assert balances["usdc"] == int(1000e18) # USDC balance should be the original mint
assert pair.balanceOf(accounts[0]) == 0 # confirm shares have been removed
assert pair.reserve0() == 0
assert pair.reserve1() == 0
assert usdc_token.balanceOf(pair) == 0
assert usdt_token.balanceOf(pair) == 0
This concludes the testing process for the smart contracts. Run the ape test
on the terminal to execute the tests on a local network.
$ ape test
We defined several tests for the smart contracts that were implemented in the project. These tests cover various functionalities, including adding and removing liquidity, swapping tokens, and collecting fees. Each test is written in a way that clearly describes the functionality being tested and sets up the necessary conditions to execute the test.
You can extend the tests to cover additional scenarios because the current set of tests is not exhaustive.
🧪 You can find the complete test_uviswap.py
file here.
Scripts
We'll be adding a deploy script to make it easy to deploy our application to any blockchain. For this demonstration, we'll be deploying to Avalanche, and thanks to ApeWorx, the process is incredibly simple. All we need to do is install the official Avalanche plugin provided by ApeWorx.
In this case, since we'll be deploying our Uviswap App to Avalanche, we'll need to install the Avalanche plugin. You can easily do this by running the command on your terminal:
$ ape plugins install avalanche
Also, we need to configure the RPC URL endpoints ape is going to connect to, this can be done on the ape-config.yaml
file, yours should look something like this:
# ape-config.yaml
name: uviswap
plugins:
- name: avalanche
- name: vyper
geth:
avalanche:
mainnet:
uri: https://api.avax.network/ext/bc/C/rpc
fuji:
uri: https://avalanche-fuji.drpc.org/
It's worth noting that plugins are a great way to expand the functionality of ApeWorx.
Accounts
To deploy our smart contracts, we'll need an account with AVAX (if you are on testnet you can fund your wallet using the official faucet). While many frameworks suggest adding your private key to a .env
file, this approach can be a serious security vulnerability. Luckily, ApeWorx provides a better solution out of the box that allows us to work with accounts more securely.
With ApeWorx, all we need to do is execute a few simple commands to get set up. We won't have to worry about storing our private key in a file or exposing it to potential attackers. This way, we can work with our accounts and deploy our smart contracts with confidence and peace of mind. Let's dive in and get started!
For this guide I will be using the name
test_account
to deploy the smart contract, you can choose any name you like, but be sure to use the same name on your script.
$ ape accounts generate <name_of_your_account>
This will prompt you to put in your private key
and a password
, you can get your private key from MetaMask or any other wallet that you are using, and the password is going to be used to encrypt your account.
After we complete that inside your scripts folder create a deploy.py, this is going to hold the code for our deployments.
from ape import project, accounts
def main():
test_account = accounts.load("test_account")
uviswap_pair = project.UviswapPair.deploy(sender=test_account)
project.UviswapFactory.deploy(uviswap_pair.address, sender=test_account)
That would be it, just type ape run deploy --network <network>
and Ape will run the deploy script.
Command to deploy on Avalanche Tesnet
$ ape run deploy --network avalanche:fuji
Command to deploy on Avalanche Mainnet
$ ape run deploy --network avalanche:mainnet
Conclusion
Automatic Market Makers are a wonderful tool used in the decentralized finance space, it allows users to swap tokens without "normal" market makers almost instantly. These kinds of smart contracts have a lot of use cases, and you can expand a lot more on this automatic market maker idea. Be creative!
Uviswap GitHub Repository
Explore Uviswap on GitHub: https://github.com/rafael-abuawad/uviswap
Discover the complete source code, smart contracts, testing scripts, and deployment instructions. Contribute, open issues, and engage with the community.
Top comments (0)