Decentralized Exchange (DEX)

Go up to the CCC HW page (md) | view one-page version

Overview

In this assignment you are going to create a Decentralized Cryptocurrency Exchange (hereafter: DEX) for your token cryptocurrency (hereafter: TCC) that you created in the Ethereum Tokens (md) assignment. Once deployed, anybody will be able to exchange (fake) ETH for your token cryptocurrency. The DEX will use the Constant Product Automated Market Maker (CPAMM) method for determining the exchange rates.

Completion this homework will require completion of the following assignments:

You are expected to use your TokenCC code from the Ethereum Tokens (md) assignment. If you did not get it working properly, then contact us. You have to make a small modification to your TokenCC.sol file and then re-deploy it; however, you may find that you have to re-deploy it many times as you are testing your DEX. Be sure to save the contract address of the final deployment that you will use when you submit the assignment.

You will also need to be familiar with the Ethereum slide set, the Solidity slide set, the Tokens slide set, and the Blockchain Applications slide set. The last one is most relevant, as it discusses how a DEX works.

In addition to your source code, you will submit an edited version of dex.py (src).

Changelog

Any changes to this page will be put here for easy reference. Typo fixes and minor clarifications are not listed here. So far there aren’t any significant changes to report.

ETH price

To simulate changing market conditions, we have deployed two smart contracts to help one determine the price of our (fake) ETH. Both of these contracts fulfill the IEtherPriceOracle.sol (src) interface:

interface IEtherPriceOracle is IERC165 {

    // The name (really a description) of the implementing contract
    function name() external pure returns (string memory);

    // The currency symbol this is being reported in, such as '$'
    function symbol() external pure returns (string memory);

    // How many decimals this is being reported in; for cents, it's 2
    function decimals() external pure returns (uint);

    // The current price, in cents, of the (fake) ether
    function price() external view returns (uint);

    // also supportsInterface() from IERC165.sol
}

The price() function will return the current price in cents. Thus, if the price is $99.23 per (fake) ETH, it would return 9923.

There are two deployed contracts that implemented this interface, the contract addresses of which are on the Canvas landing page. The first is a constant implementation, which always returns $100.00 (formally: 10000) as the price. The implementation for this is in EtherPriceOracleConstant.sol (src), and shown below. You can use this file for debugging or on the Javascript development environment in Remix, as it always returns the same value.

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;
import "./IEtherPriceOracle.sol";

contract EtherPriceOracleConstant is IEtherPriceOracle {
    string public constant name = "A constant EtherPrice oracle that always returns $100.00";
    string public constant symbol = "$";
    uint public constant decimals = 2;
    uint public constant price = 10000; // in cents

    function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
        return interfaceId == type(IEtherPriceOracle).interfaceId || interfaceId == type(IERC165).interfaceId;
    }
}

The second deployed contract is a variable version, whose price ranges greatly, but generally averages (over time) around $100 in price. As there is no true randomness on a fully deterministic blockchain, the value is based on the highest block number and/or the latest block hash. So while this will change at each block, it will not change until a new block is created. The implementation for the variable version is not being provided, but it implements the IEtherPriceOracle interface, above.

You should use the first (constant) one while you are debugging your code. You will need to use the second (variable) one when you make your final deployment. The current variable price of our (fake) ETH is shown on the DEX web page, which is described below. The addresses for these two contracts (constant and variable) are on the Canvas landing page.

TokenCC

You will be using your TokenCC contract from the Ethereum Tokens (md) assignment. However, you will need to make two changes to your contract. These are to your TokenCC.sol file, NOT to the interface.

When tokens are transferred to any contract address, we are going to have our TokenCC code attempt to call an onERC20Received() function on that contract, ignoring the error if the contract does not implement the IERC20Receiver interface. Calling this method will also not be attempted on an owned account.

The first change is that you will have to import the IERC20Receiver.sol (src) file. This file defines the IERC20Receiver interface which defines only one function: onERC20Received(). Our TokenCC contracts are going to call this function any time tokens are transferred to another contract. There is a similar concept for ERC-721 contracts, but not (yet) for ERC-20 contracts. Note that your TokenCC contract does NOT implement this interface; it just needs to know about it so it can call a function (onERC20Received()) on another contract that implements this interface.

The second change is that we have to include the following two functions, adapted from here, in our TokenCC.sol file:

// This overrides the _update() function in ERC20.sol -- first we call the
// overridden function, then we call afterTokenTransfer().  Note that this is
// called on a mint, burn, or transfer.
function _update(address from, address to, uint256 value) internal override virtual {
    ERC20._update(from,to,value);
    afterTokenTransfer(from,to,value);
}

// When a transfer occurs to a contract, this function will call
// onERC20Received() on that contract.
function afterTokenTransfer(address from, address to, uint256 amount) internal override {
    if ( to.code.length > 0  && from != address(0) && to != address(0) ) {
        // token recipient is a contract, notify them
        try IERC20Receiver(to).onERC20Received(from, amount, address(this)) returns (bool success) {
            require(success,"ERC-20 receipt rejected by destination of transfer");
        } catch {
            // the notification failed (maybe they don't implement the `IERC20Receiver` interface?)
            // we choose to silently ignore this case
        }
    }
}

This function overrides the afterTokenTransfer() function in the ERC20.sol (src) contract; this “hook” is called any time a token is transferred. Our overridden function above will first check if the to is a contract by checking if it has a non-zero code size; owned accounts always have zero length code. It also checks that both addresses are non-zero (from is zero on a mint operation, and to is zero on a burn operation). If it passed those checks, it will attempt to call the onERC20Received() function, if it exists; since it’s in a try-catch block, nothing happens if it the function does not exist. If that function does not exist, then it does nothing (we could have had it revert in the catch clause as well).

The net effect of these two changes is that any time your TokenCC is transferred to a contract, it will attempt to notify that contract that it just received some ERC-20 tokens.

Lastly, we recommend minting a large amount of coins (a million or so, which is multiplied by 10d, where d is how many decimals your coin uses). This will allow you to use the same deployed TokenCC contract for multiple DEX deployments and tests.

The next section describes a way to “turn off” the functionality of the onERC20Received() function.

Lastly, you will need to send me 10.0 of your TCC. But do this from the final deployment – we remind you about that below.

Background

Exchange method

Your DEX must follow the CPAMM (Constant Product Automated Market Maker method as discussed in the lecture slides. Once deployed, there will be some liquidity that must be added to the DEX before trading can start. Anybody can then exchange some of our (fake) ETH for your token cryptocurrency. This, combined with the varying price of our (fake) ETH, will cause the price of your token cryptocurrency to fluctuate significantly. At the end of the assignment you will register your DEX with the course-wide DEX web page so that the entire class can see all of the exchangeable token cryptocurrencies.

Number of DEXes

As far as this assignment is concerned, there will only be one DEX for each token cryptocurrency. You may have deployed multiple ones to test your code, but for our class trading we will only be using the one DEX that you register with the DEX web page, described below. Thus, for this assignment, arbitrage trading is not possible, since that requires trading between two or more exchanges that exchange the same pairs of tokens. Furthermore, we are not going to be implementing routing.

Obtaining a balance

To get the ether balance of a given account, you just use the balance property. You may have to cast it as a address first, as such: address(a).balance. This reports the ether balance in wei. To get the ERC-20 balance, you call the balanceOf() function on the TokenCC contract, which reports it with as many decimals as the ERC-20 contract uses (call decimals() to find out how many).

Initiating an exchange

To initiate an exchange, you just transfer the appropriate cryptocurrency to the DEX.

To exchange ether for TCC, you transfer some amount of ether to the DEX. This will call the receive() function, which will handle the payout of the TCC back to the caller (aka msg.sender).

To exchange TCC for ether, you transfer the TCC to the DEX via your TokenCC contract; based on the modifications done above, this will call the onERC20Received() function, which will handle the payout of the ether back to the caller (aka msg.sender).

receive()

A contract can receive either in one of two ways. The first is to have a payable function is called along with some ether transfer. This was done in the placeBid() function in the dApp Auction (md) assignment.

To receive ether without a function call – meaning to receive a regular ether transfer – a special function called receive() must be present. It doesn’t have to do anything, necessarily, but it does have to be declared. Note that, in this assignment, our receive() function is going to have to do quite a bit. This function has a special form:

receive() payable external { // might need 'override' also
    // ...
}

Note that there is no function keyword! Other than the different syntax, and the special case when it is called, it operates like any other function. It can take any action, including reverting (which will abort the transfer). In our case, this is how we are going to exchange ether for TCC. To initiate an exchange of ether for TCC, we transfer ether in, which will call receive(), and the TCC will be transferred back to the caller. As our receive() function is overriding what is in an interface (described below), we also put the override keyword there.

In Remix, you can invoke the receive() function by sending some ether without a function call. To do this, put the amount in the “Value” box of the Deployment pane, set the right unit (ether, gwei, or wei), and then click on the “Transact” button at the very bottom of the contract (below the “Low level interactions” header). This is just like transferring ether in geth. Note that the Javascript environment seems to hang on some platforms when doing this, but if you are connected to the course blockchain, then it seems to work fine.

Transferring ether

To transfer ether to an address a, you could use the following:

(bool success, ) = payable(a).call{value: amount}("");
require (success, "payment didn't work");

A bunch of notes on this:

If this causes a reversion – for example, the receiving contract reverts in receive() – then look in the Testing section, below, for how to decode the reversion reason.

Receiving ether

In any function, the msg.value contains how much ether was sent in with the function call. It’s in wei, so 1 ether would have a msg.value value of 1018. Non-payable functions will always have msg.value equal to zero. You can’t check the balance of msg.sender, as they do not have that amount of ether during the function call (they sent it in with the call).

onERC20Received()

The onERC20Received() function will be called any time TCC is transferred to a contract. We are going to use this to initiate an exchange of TCC for ether – one just has to transfer the TCC to the DEX, and then the DEX will compute the amount of ether to send back.

This function takes in three parameters – the address that sent in the TC (from), the amount sent in (amount), and the ERC-20 contract for that TC (erc20). For the amount, keep in mind that it is with all the decimals – so if you send in 10 TC, and you have 10 decimals, then the value of amount will be 10 * 1010. The point of the erc20 parameter is to make sure that one is not trying to send in another TC, with a different ERC-20 contract, to get ether out of the DEX. Thus, we have to check (via a require()) that the erc20 address is the same as the ERC-20 contract that the DEX uses.

However, there are some times where we may NOT want onERC20Received() to do anything. In particular, addLiquidity() (and possibly removeLiquidity()) will initiate a ERC-20 transfer (via calling transferFrom()), but we probably don’t want onERC20Received() to be called at that point (it’s not an exchange). So we are going to want to have a way to “turn off” the functionality of onERC20Received(). The easiest way to do this is to have an internal contract variable, such as adjustingLiquidity, that is normally set to false. In addLiquidity() and removeLiquidity(), you set it to true when you are about to initiate the transfer, and then set it to false when done.

IMPORTANT NOTE: Your onERC20Received() MUST check that the address passed in as the third parameter is the same address as the contract it is part of; require(erc20==erc20Address,"witty error message"); will do this. Otherwise, somebody could call that function with a different ERC-20 contract and drain all the TCC from your contract.

Interface

Formally, you must implement a DEX contract that implements the IDEX.sol (src) interface. Your contract opening line MUST be: contract DEX is IDEX. Note that the IDEX interface extends the IERC165 (src) interface, so you will have to implement the supportsInterface() function as well. It also implements the IERC20Receiver.sol (src) interface, which means implementing the onERC20Received() function. The functions in this interface are shown below, and much more detail is provided in the comments in the IDEX.sol (src) file.

Note that many of these functions are just the getter functions from public variables; which ones are described in the full source file and also below. Also note that x is the amount of ether liquidity (with 18 decimals) and y is the amount of token liquidity (with 8-12 decimals).

// SPDX-License-Identifier: GPL-3.0-or-later

pragma solidity ^0.8.24;

import "./IERC165.sol";
import "./IEtherPriceOracle.sol";

interface IDEX is IERC165, IERC20Receiver {

    // Events
    event liquidityChangeEvent();

    // Getting the exchange rates and prices
    function decimals() external view returns (uint);
    function symbol() external returns (string memory);
    function getEtherPrice() external view returns (uint);
    function getTokenPrice() external view returns (uint);

    // Getting the liquidity of the pool or part thereof
    function k() external view returns (uint);
    function x() external view returns (uint); // amount of eth
    function y() external view returns (uint); // amount of tc
    function getPoolLiquidityInUSDCents() external view returns (uint);
    function etherLiquidityForAddress(address who) external returns (uint);
    function tokenLiquidityForAddress(address who) external returns (uint);

    // Pool creation
    function createPool(uint _tokenAmount, uint _feeNumerator, uint _feeDenominator, 
                        address _erc20token, address _etherPricer) external payable;

    // Fees
    function feeNumerator() external view returns (uint);
    function feeDenominator() external view returns (uint);
    function feesEther() external view returns (uint);
    function feesToken() external view returns (uint);

    // Managing pool liquidity
    function addLiquidity() external payable;
    function removeLiquidity(uint amountWei) external;

    // Exchanging currencies (the second one is from the IERC20Receiver interface)
    receive() external payable;
    // function onERC20Received(address from, uint amount) external returns (bool);

    // Functions for debugging and grading
    function setEtherPricer(address p) external;
    function etherPricer() external returns (address);
    function erc20Address() external returns (address);

    // Functions for efficiency
    function getDEXinfo() external returns (address, string memory, string memory, 
                            address, uint, uint, uint, uint, uint, uint, uint, uint);

    // From IERC165.sol; this contract supports three interfaces
    // function supportsInterface(bytes4 interfaceId) external view returns (bool);

    // Functions for a future assignment; they should just revert for now
    function reset() external;

}

This may seem like a lot, as there are 26 functions (including supportsInterface(), onERC20Received(), and the constructor) to implement, but it turns out it’s not quite as much as it seems:

Here are all the files you will need:

When you want to test your program, this is the expected flow to get it started, whether to the Javascript blockchain in Remix or to our private Ethereum blockchain:

As far this this assignment is concerned, the exchange rate between our (fake) ETH and your token cryptocurrency is initially set based on the ratio of what you send in via createPool(). The overall value of the DEX is based on the current (fake) ETH price. So if you have 100 (fake) ETH, and the price of the (fake) ETH is $99.23, then the ETH liquidity is $9,923; the value of the DEX is twice that, or $19,846.

Fees

Each transaction will have fees deducted. Fees are always deducted from the amount the DEX pays out (either ether or token) – it just pays that much less. Reasonable fees are a fraction of a percent – between 0.2% and 0.5%, for example. Thus, if you were trading some amount of ETH and getting 100 TCC, with 0.2% fees, you would trade the same amount of (fake) ETH, but receive 99.8 TCC; the other 0.2 TCC are the fees. When fees are withheld, the amount that is withheld is added to the feesEther and feesToken variables. These variables accumulate the total amount of fees that the DEX has accumulated over time.

NOTE: the ONLY functions that remove fees are receive() and onERC20Received(), and they only remove the fee from the amount paid out. The other functions (specifically addLiquidity() and removeLiquidity()) do not deduct fees.

Managing fee payout to the liquidity providers is quite complicated – one has to take into account how much liquidity each provider has in the DEX, and over what time frame. There could be thousands of liquidity providers in the pool, each of which had different times that the DEX held their liquidity, and each of which gets a cut – proportional to their liquidity – of each transaction’s fee. Furthermore, fees are added to the liquidity pool, but only when they can be balanced with the other currency so that they can be added in appropriate proportions.

For this assignment, we are not going to handle distributing fees back to the liquidity providers – we are just going to accumulate them into the feesEther and feesToken variables. It adds a lot of complexity to compute who is owned what part of the fees based on the amount of liquidity they have in the DEX and for how long they have had it. This means that this inability to retrieve the fees will result in lost ETH and TCC. That’s fine for this assignment, even if it would not be realistic in a real world situation.

Example

To help you debug your program, here is a worked-out example of how the values in the DEX change as various transactions occur. This is assuming a constant (fake) ETH price of $100. For reasons we will see below, we are only putting in 10 (fake) ETH in this example, whereas you will have put in 100 when you deploy it at the end of the assignment.

Testing

Handling reversions on payment

When paying out to another address, your Solidity code is going to look like the following:

(bool success, ) = payable(a).call{value: v}("");
require(success, "Failed to transfer ETH");

If that call fails (by returning false), then it will stop on that require. However, if that call fails by reverting – such as when the receiving contract reverts in receive() – then we have to do a bit more work to get (and display) the reversion reason.

The following function will decode the reversion reason:

// From https://ethereum.stackexchange.com/questions/83528/how-can-i-get-the-revert-reason-of-a-call-in-solidity-so-that-i-can-use-it-in-th
function getRevertMsg(bytes memory _returnData) internal pure returns (string memory) {
    // If the _res length is less than 68, then the transaction failed silently (without a revert message)
    if (_returnData.length < 68)
        return 'Transaction reverted silently';

    assembly {
        // Slice the sighash.
        _returnData := add(_returnData, 0x04)
    }
    return abi.decode(_returnData, (string)); // All that remains is the revert string
}

However, in order to use that function, we have to get the encoded reversion reason. We will use the following code to pay from a contract – it captures the (encoded) reversion reason in the second part of the tuple (result).

(bool success, bytes memory result) = payable(address(dex)).call{value:amtEther * 1 ether}("");
require (success, string.concat("Payment to DEX didn't work: ", getRevertMsg(result)));

DEXtest testing contract

To help you test your code, below is a method that will test the first case from the example above – the createPool() step. This is intended to be done on the Javascript development environment in Remix and NOT on the course blockchain. This file is saved as DEXtest.sol (src).

// SPDX-License-Identifier: GPL-3.0-or-later

// This file is part of the http://github.com/aaronbloomfield/ccc repository,
// and is released under the GPL 3.0 license.

pragma solidity ^0.8.24;

import "./DEX.sol";
import "./TokenCC.sol";
import "./EtherPriceOracleConstant.sol";

contract DEXtest {

    TokenCC public tc;
    DEX public dex;

    constructor() {
        tc = new TokenCC();
        dex = new DEX();
    }

    function test() public payable {
        require (msg.value == 13 ether, "Must call test() with 13 ether");

        // Step 1: deploy the ether price oracle
        IEtherPriceOracle pricer = new EtherPriceOracleConstant();

        // Step 1 tests: DEX is deployed
        require(dex.k() == 0, "k value not 0 after DEX creation()");
        require(dex.x() == 0, "x value not 0 after DEX creation()");
        require(dex.y() == 0, "y value not 0 after DEX creation()");

        // Step 2: createPool() is called with 10 (fake) ETH and 100 TCC
        bool success = tc.approve(address(dex),100*10**tc.decimals());
        require (success,"Failed to approve TCC before createPool()");
        try dex.createPool{value: 10 ether}(100*10**tc.decimals(), 0, 1000, address(tc), address(pricer)) {
            // do nothing
        } catch Error(string memory reason) {
            require (false, string.concat("createPool() call reverted: ",reason));
        }
        
        // Step 2 tests
        require(dex.k() == 1e21 * 10**tc.decimals(), "k value not correct after createPool()");
        require(dex.x() == 10 * 1e18, "x value not correct after createPool()");
        require(dex.y() == 100 * 10**tc.decimals(), "y value not correct after createPool()");

        // Step 3: transaction 1, where 2.5 ETH is provided to the DEX for exchange

        // Step 3 tests

        // Step 4: transaction 2, where 120 TCC is provided to the DEX for exchange
  
        // Step 4 tests

        // Step 5: addLiquidity() is called with 1 (fake) ETH and 40 TCC

        // Step 5 tests

        // finish up
        require(false,"end fail"); // huh?  see why in the homework description!
    }
 
    receive() external payable { } // see note in the HW description

}

Using DEXtest

To use this file, deploy it and then call test() with with 13 ether. There are a few new concepts here, and various notes as well:

General debugging hints

We have collected a number of debugging hints here.

Deployment

This part has three different steps. This may require a few runs to get it right – that’s fine, just be sure to submit the various values (contract addresses and transaction hashes) from the most recent deployment.

Step 1: You will need to have deployed your (updated) TokenCC smart contract to the private Ethereum blockchain, and you will need to know its contract address.

Step 2: Deploy your DEX smart contract to the private Ethereum blockchain. So that it will work properly with all of your other classmates’ DEX implementations, we have some strict requirements for the deployment:

Step 3: You need to register your DEX with the course-wide exchange board website; the URL for this is on the Canvas landing page. To register your DEX, fill out the contract address form at the bottom of that page. You will see your DEX values populate one of the table rows – make sure they are correct. Note that the current ETH price is listed at the top of the page.

Exchanges

Now that your exchange is registered, you can view all the exchanges. You should see your exchange in there, along with your cryptocurrency’s logo. The stats of each exchange are listed in that table.

You need to make 4 total exchanges with DEXes other than you own (meaning four or more different exchanges, but with four different DEXes). You are welcome to exchange for more if you want to own more. As you accumulate more TCC from other students, you can see them on the blockchain explorer page for your account. As you likely have more of your own Token cryptocurrency, you can now exchange that with your DEX to get some ether. Or you can get more ether from the faucet and use that to exchange for the others.

Depending on when you submit your assignment, there may not be other DEXes to interact with. That’s fine – you don’t have to have those bids completed by the time the assignment is due; you have an extra few days to place your bids. We are going to judge lateness on this assignment by the Gradescope submission time, and the Google form does not ask for the transaction hashes of the exchanges. We are going to check whether you exchange for the other token cryptocurrencies by looking if your eth.coinbase account, the address of which you will submit below, initiated exchanges on any one of your classmate’s submitted DEX addresses by a few days after the due date. Note that you have to place the bid via Remix or geth; the course website just displays the auctions.

Submission

You will need to fill in the various values from this assignment into the dex.py (src) file. That file clearly indicates all the values that need to be filled in. That file, along with your Solidity source code, are the only files that must be submitted. The sanity_checks dictionary is intended to be a checklist to ensure that you perform the various other aspects to ensure this assignment is fully submitted.

There are five forms of submission for this assignment; you must do all five.

Submission 1: Deploy the DEX smart contract to the private Ethereum blockchain. Your TokenCC will need to have been deployed as well. These were likely done in the deployment section, above. You have to call createPool() with exactly 100 (fake) ether, some number of TCC (no less than 10.0 TCC), and the address of the variable EtherPriceOracle.

Submission 2: Send 10.0 TCC to the address listed on the Canvas landing page. This means that if your TokenCC has 10 decimal places, then the value you need to send is 100,000,000,000. You can check how much of your TCC is owned by any account by looking at that account page in the blockchain explorer.

Submission 3: You should submit your DEX.sol, your (updated) TokenCC.sol files, and your completed dex.py file, and ONLY those three files, to Gradescope. All your Solidity code should be in the first two files, and you should specifically import the various interfaces. Those interface files will be placed in the same directory on Gradescope when you submit. NOTE: Gradescope cannot fully test this assignment, as it does not have access to the private blockchain. So it can only do a few sanity tests (correct files submitted, successful compilation, valid values in dex.py, etc.).

Submission 4: Register your DEX smart contract with the course-wide exchange. This, also, was likely done in the deployment section, above.

Submission 5: Make at least 4 exchanges with other DEXes.