DEV Community

Cover image for Create your own Uniswap DeFi Application🦄! From scratch using Vyper & Python🐍
Rafael Abuawad
Rafael Abuawad

Posted on

Create your own Uniswap DeFi Application🦄! From scratch using Vyper & Python🐍

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, we will initialize a new ApeWorx project and name it uviswap:

$ ape init
$ Please enter project name: uviswap
Enter fullscreen mode Exit fullscreen mode

🚧 If you encounter the issue of No module named 'pkg_resources', you need to run pip 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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)

...
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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)

...
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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 ######

Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

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

...
Enter fullscreen mode Exit fullscreen mode

🦄 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])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🧪 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

Enter fullscreen mode Exit fullscreen mode

🧪 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

Enter fullscreen mode Exit fullscreen mode

🧪 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

Enter fullscreen mode Exit fullscreen mode

🧪 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

Enter fullscreen mode Exit fullscreen mode

🧪 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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Command to deploy on Avalanche Mainnet

$ ape run deploy --network avalanche:mainnet
Enter fullscreen mode Exit fullscreen mode

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.

Happy coding! 🐍🦍

Top comments (0)