DEX tutorial
This tutorial utilizes the predeployed DEX smart contract to swap the ERC20 tokens of the predeployed Token smart contracts, which we instantiate with the help of the ADDRESS utility.
This example introduces the use of Acala EVM+ predeployed DEX that is present on every network at a fixed address (the address of a predeployed contract is the same on a local development network, public test network as well as the production network). As this example focuses on showcasing the interactions with the predeployed
DEX
, it doesn't have its own smart conract. We will get all of the required imports from the @acala-network/contracts
dependency. The precompiles and predeploys are a specific feature of the Acala EVM+, so this tutorial is no longer compatible with traditional EVM development networks (like Ganache) or with the Hardhat's built in network emulator.Let's take a look!
NOTE: You can refer to the complete code of this tutorial at https://github.com/AcalaNetwork/hardhat-tutorials/tree/master/DEX
As mentioned in the introduction, this tutorial doesn't include the smart contract, so the
contracts
folder can be removed as well. We will however be using the @acala-network/contracts
dependency in order to gain access to the precompiled resources of the DEX
smart contract.Tests for this tutorial will validate the expected values returned by the DEX predeployed smart contract. The test file in our case is called
DEX.js
. Within it we import the expect
from chai
dependency and Contract
from the ethers
dependency. We are using Contract
in stead of ContractFactory
, because the contract is already deployed to the network. The ACA
, AUSD
, LP_ACA_AUSD
, DOT
, RENBTC
and DEX
, which are exports from the ADDRESS
utility of @acala-network/contracts
dependency, are imported and they hold the values of the addresses of the corresponding smart contracts. Additionally we are importing the compiled DEX
smart contract from the @acala-network/contracts
dependency, which we will use to instantiate the smart contract. Token
precompile is imported from @acala-network/contracts
so that we can instantiate the predeployed token smart contracts and validate the balance changes after interacting with the DEX.NOTE: Since the ACA ERC20 token mirrors the balance of the native ACA currency, we can not expect that sending x amount of ACA into the DEX, will decrease our ACA balance by that exact amount. Some of it will also be used to pay for the transaction fees. We have to keep this in mind while writing the tests and scripts.
The test file with import statements and an empty test should look like this:
const { expect } = require("chai");
const { Contract } = require("ethers");
const { ACA, AUSD, LP_ACA_AUSD, DOT, RENBTC, DEX } = require("@acala-network/contracts/utils/MandalaAddress");
const DEXContract = require("@acala-network/contracts/build/contracts/DEX.json");
const TokenContract = require("@acala-network/contracts/build/contracts/Token.json");
const { parseUnits } = require("@acala-network/eth-providers/node_modules/@ethersproject/units");
const NULL_ADDRESS = "0x0000000000000000000000000000000000000000";
describe("DEX contract", function () {
});
To prepare for the testing, we have to define the global variables,
instance
, ACAinstance
, AUSDinstance
, deployer
and deployerAddress
. The instance
will store the predeployed DEX smart contract instance, the ACAinstance
and AUSDinstance
variables will store their respective ERC20 predeployed token smart contracts. The deployer
will store Signer
and the deployerAddress
will store its address. Let's assign these values in the beforeEach
action: let instance;
let ACAinstance;
let AUSDinstance;
let deployer;
let deployerAddress;
beforeEach(async function () {
[deployer] = await ethers.getSigners();
deployerAddress = await deployer.getAddress();
instance = new Contract(DEX, DEXContract.abi, deployer);
ACAinstance = new Contract(ACA, TokenContract.abi, deployer);
AUSDinstance = new Contract(AUSD, TokenContract.abi, deployer);
});
You can see how we used the
DEX
, ACA
and AUSD
from the ADDRESS
utility in order to set the addresses of our predeployed smart contract.Our test will only contain one top-level section called
Operation
in which we will be checking the following functions (which will each be tested in its own section):- 1.
getLiquidityPool
function to get the liquidity pool of the desired pair. - 2.
getLiquidityTokenAddress
function to get the address of the liquidity token for the desired pair. - 3.
getSwapTargetAddress
function to get the informative amount of the swap egress token based on set supply of the ingress token. - 4.
getSwapSupplyAmount
function to get the informative amount of the swap supply based on the set target amount of egress token. - 5.
swapWithExactSupply
function to swap tokens based on the set supply of the ingress token. - 6.
swapWithExactTarget
function to swap tokens based on the set target of the egress token. - 7.
addLiquidity
function that adds liquidity of the desired pair. - 8.
removeLiquidity
function that removes liquidity of the desired pair.
Before we add the inner describe blocks within the
Operation
describe block, we should increase the timeout for this test to 50s, to make sure that the tests can be run on the public test network in addition to the local development network: describe("Operation", function () {
this.timeout(50000);
describe("getLiquidityPool", function () {
});
describe("getLiquidityTokenAddress", function (){
});
describe("getSwapTargetAddress", function () {
});
describe("getSwapSupplyAmount", function () {
});
describe("swapWithExactSupply", function () {
});
describe("swapWithExactTarget", function () {
});
describe("addLiquidity", function () {
});
describe("removeLiquidity", function () {
});
});
When validating the
getLiquidityPool
function, we will check for the following examples:- 1.
tokenA
should not be a0x0
address. - 2.
tokenB
should not be a0x0
address. - 3.Liquidity of the non-existent pair should be 0.
- 4.Liquidity should be returned for the existing pairs.
The section should look like this:
it("should not allow tokenA to be a 0x0 address", async function () {
await expect(instance.getLiquidityPool(NULL_ADDRESS, ACA)).to
.be.revertedWith("DEX: tokenA is zero address");
});
it("should not allow tokenB to be a 0x0 address", async function () {
await expect(instance.getLiquidityPool(ACA, NULL_ADDRESS)).to
.be.revertedWith("DEX: tokenB is zero address");
});
it("should return 0 liquidity for nonexistent pair", async function () {
const response = await instance.getLiquidityPool(ACA, DOT);
const liquidityA = response[0];
const liquidityB = response[1];
expect(liquidityA).to.equal(0);
expect(liquidityB).to.equal(0);
});
it("should return liquidity for existing pairs", async function () {
const response = await instance.getLiquidityPool(ACA, AUSD);
const liquidityA = response[0];
const liquidityB = response[1];
expect(liquidityA).to.be.above(0);
expect(liquidityB).to.be.above(0);
});
When validating the
getLiquidityTokenAddress
function, we will check for the following examples:- 1.
tokenA
should not be a0x0
address. - 2.
tokenB
should not be a0x0
address. - 3.Liquidity token address should be returned for the existing pairs.
The section should look like this:
it("should not allow tokenA to be a 0x0 address", async function () {
await expect(instance.getLiquidityTokenAddress(NULL_ADDRESS, ACA)).to
.be.revertedWith("DEX: tokenA is zero address");
});
it("should not allow tokenB to be a 0x0 address", async function () {
await expect(instance.getLiquidityTokenAddress(ACA, NULL_ADDRESS)).to
.be.revertedWith("DEX: tokenB is zero address");
});
it("should return liquidity token address for an existing pair", async function () {
const response = await instance.getLiquidityTokenAddress(ACA, AUSD);
expect(response).to.equal(LP_ACA_AUSD);
});
When validating the
getSwapTargetAddress
function, we will check for the following examples:- 1.
path
should not include the0x0
address. - 2.
supplyAmount
should not be 0. - 3.Getting swap target amount should return 0 for an incompatible path.
- 4.Swap target amount should be returned when all parameters are correct.
The section should look like this:
it("should not allow for the path to include a 0x0 address", async function () {
let path = [NULL_ADDRESS, ACA, DOT, RENBTC];
await expect(instance.getSwapTargetAmount(path, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, NULL_ADDRESS, DOT, RENBTC];
await expect(instance.getSwapTargetAmount(path, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, DOT, NULL_ADDRESS, RENBTC];
await expect(instance.getSwapTargetAmount(path, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, DOT, RENBTC, NULL_ADDRESS];
await expect(instance.getSwapTargetAmount(path, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
});
it("should not allow supplyAmount to be 0", async function () {
await expect(instance.getSwapTargetAmount([ACA, DOT], 0)).to
.be.revertedWith("DEX: supplyAmount is zero");
});
it("should return 0 for an incompatible path", async function () {
const response = await instance.getSwapTargetAmount([ACA, DOT], 100);
expect(response.toString()).to.equal('0');
})
it("should return a swap target amount", async function () {
const response = await instance.getSwapTargetAmount([ACA, AUSD], 100);
expect(response).to.be.above(0);
});
When validating the
getSwapSupplyAmount
function, we will check for the following examples:- 1.
path
should not include the0x0
address. - 2.
targetAmount
should not be 0. - 3.Getting swap supply amount should revert for an incompatible path.
- 4.Swap supply amount should be returned when all parameters are correct.
The section should look like this:
it("should not allow an address in the path to be a 0x0 address", async function () {
let path = [NULL_ADDRESS, ACA, DOT, RENBTC];
await expect(instance.getSwapSupplyAmount(path, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, NULL_ADDRESS, DOT, RENBTC];
await expect(instance.getSwapSupplyAmount(path, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, DOT, NULL_ADDRESS, RENBTC];
await expect(instance.getSwapSupplyAmount(path, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, DOT, RENBTC, NULL_ADDRESS];
await expect(instance.getSwapSupplyAmount(path, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
});
it("should not allow targetAmount to be 0", async function () {
await expect(instance.getSwapSupplyAmount([ACA, AUSD], 0)).to
.be.revertedWith("DEX: targetAmount is zero");
});
it("should return 0 for an incompatible path", async function () {
const response = await instance.getSwapSupplyAmount([ACA, DOT], 100);
expect(response.toString()).to.equal('0');
});
it("should return the supply amount", async function () {
const response = await instance.getSwapSupplyAmount([ACA, AUSD], 100);
expect(response).to.be.above(0);
});
When validating the
swapWithExactSupply
function, we will check for the following examples:- 1.
path
should not include the0x0
address. - 2.
supplyAmount
should not be 0. - 3.Egress token balance of the caller should increase.
- 4.Successful execution should emit a
Swaped
event.
The section should look like this:
it("should not allow path to contain a 0x0 address", async function () {
let path = [NULL_ADDRESS, ACA, DOT, RENBTC];
await expect(instance.swapWithExactSupply(path, 12345678990, 1)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, NULL_ADDRESS, DOT, RENBTC];
await expect(instance.swapWithExactSupply(path, 12345678990, 1)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, DOT, NULL_ADDRESS, RENBTC];
await expect(instance.swapWithExactSupply(path, 12345678990, 1)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, DOT, RENBTC, NULL_ADDRESS];
await expect(instance.swapWithExactSupply(path, 12345678990, 1)).to
.be.revertedWith("DEX: token is zero address");
});
it("should not allow supplyAmount to be 0", async function () {
await expect(instance.swapWithExactSupply([ACA, AUSD], 0, 1)).to
.be.revertedWith("DEX: supplyAmount is zero");
});
it("should allocate the tokens to the caller", async function () {
const initalBalance = await ACAinstance.balanceOf(deployerAddress);
const initBal = await AUSDinstance.balanceOf(deployerAddress);
const path = [ACA, AUSD];
const expected_target = await instance.getSwapTargetAmount(path, 100);
await instance.connect(deployer).swapWithExactSupply(path, 100, 1);
const finalBalance = await ACAinstance.balanceOf(deployerAddress);
const finBal = await AUSDinstance.balanceOf(deployerAddress);
// The following assertion needs to check for the balance to be below the initialBalance - 100, because some of the ACA balance is used to pay for the transaction fee.
expect(finalBalance).to.be.below(initalBalance.sub(100));
expect(finBal).to.equal(initBal.add(expected_target));
});
it("should emit a Swaped event", async function () {
const path = [ACA, AUSD];
const expected_target = await instance.getSwapTargetAmount(path, 100);
await expect(instance.connect(deployer).swapWithExactSupply(path, 100, 1)).to
.emit(instance, "Swaped")
.withArgs(deployerAddress, path, 100, expected_target);
});
When validating the
swapWithExactTarget
function, we will check for the following examples:- 1.
path
should not include the0x0
address. - 2.
targetAmount
should not be 0. - 3.Egress token balance of the caller should increase.
- 4.Successful execution should emit a
Swaped
event.
The section should look like this:
it("should not allow a token in a path to be a 0x0 address", async function () {
let path = [NULL_ADDRESS, ACA, DOT, RENBTC];
await expect(instance.swapWithExactTarget(path, 1, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, NULL_ADDRESS, DOT, RENBTC];
await expect(instance.swapWithExactTarget(path, 1, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, DOT, NULL_ADDRESS, RENBTC];
await expect(instance.swapWithExactTarget(path, 1, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
path = [ACA, DOT, RENBTC, NULL_ADDRESS];
await expect(instance.swapWithExactTarget(path, 1, 12345678990)).to
.be.revertedWith("DEX: token is zero address");
});
it("should not allow targetAmount to be 0", async function () {
await expect(instance.swapWithExactTarget([ACA, AUSD], 0, 1234567890)).to
.be.revertedWith("DEX: targetAmount is zero");
});
it("should allocate tokens to the caller", async function () {
const initalBalance = await ACAinstance.balanceOf(deployerAddress);
const initBal = await AUSDinstance.balanceOf(deployerAddress);
const path = [ACA, AUSD];
const expected_supply = await instance.getSwapSupplyAmount(path, 100);
await instance.connect(deployer).swapWithExactTarget(path, 100, 1234567890);
const finalBalance = await ACAinstance.balanceOf(deployerAddress);
const finBal = await AUSDinstance.balanceOf(deployerAddress);
// The following assertion needs to check for the balance to be below the initialBalance - 100, because some of the ACA balance is used to pay for the transaction fee.
expect(finalBalance).to.be.below(initalBalance.sub(expected_supply));
expect(finBal).to.equal(initBal.add(100));
});
it("should emit Swaped event", async function () {
const path = [ACA, AUSD];
const expected_supply = await instance.getSwapSupplyAmount(path, 100);
await expect(instance.connect(deployer).swapWithExactTarget(path, 100, 1234567890)).to
.emit(instance, "Swaped")
.withArgs(deployerAddress, path, expected_supply, 100);
});
When validating the
addLiquidity
function, we will check for the following examples:- 1.
tokenA
should not be a0x0
address. - 2.
tokenB
should not be a0x0
address. - 3.
maxAmountA
should not be 0. - 4.
maxAmountB
should not be 0. - 5.Successfull execution should increase the liquidity of the pair.
- 6.Successfull execution should emit
AddedLiquidity
event.
The section should look like this:
it("should not allow tokenA to be 0x0 address", async function () {
await expect(instance.addLiquidity(NULL_ADDRESS, AUSD, 1000, 1000, 1)).to
.be.revertedWith("DEX: tokenA is zero address");
});
it("should not allow tokenB to be 0x0 address", async function () {
await expect(instance.addLiquidity(ACA, NULL_ADDRESS, 1000, 1000, 1)).to
.be.revertedWith("DEX: tokenB is zero address");
});
it("should not allow maxAmountA to be 0", async function () {
await expect(instance.addLiquidity(ACA, AUSD, 0, 1000, 1)).to
.be.revertedWith("DEX: maxAmountA is zero");
});
it("should not allow maxAmountB to be 0", async function () {
await expect(instance.addLiquidity(ACA, AUSD, 1000, 0, 1)).to
.be.revertedWith("DEX: maxAmountB is zero");
});
it("should increase liquidity", async function () {
const intialLiquidity = await instance.getLiquidityPool(ACA, AUSD);
await instance.addLiquidity(ACA, AUSD, parseUnits("2", 12), parseUnits("2", 12), 1);
const finalLiquidity = await instance.getLiquidityPool(ACA, AUSD);
expect(finalLiquidity[0]).to.be.above(intialLiquidity[0]);
expect(finalLiquidity[1]).to.be.above(intialLiquidity[1]);
});
it("should emit AddedLiquidity event", async function () {
await expect(instance.connect(deployer).addLiquidity(ACA, AUSD, 1000, 1000, 1)).to
.emit(instance, "AddedLiquidity")
.withArgs(deployerAddress, ACA, AUSD, 1000, 1000);
});
When validating the
removeLiquidity
function, we will check for the following examples:- 1.
tokenA
should not be a0x0
address. - 2.
tokenB
should not be a0x0
address. - 3.
removeShare
should not be 0. - 4.Successfull execution should reduce the liquidity of the pair.
- 5.Successfull execution should emit
RemovedLiquidity
event.
The section should look like this:
it("should not allow tokenA to be a 0x0 address", async function () {
await expect(instance.removeLiquidity(NULL_ADDRESS, AUSD, 1, 0, 0)).to
.be.revertedWith("DEX: tokenA is zero address");
});
it("should not allow tokenB to be a 0x0 address", async function () {
await expect(instance.removeLiquidity(ACA, NULL_ADDRESS, 1, 0, 0)).to
.be.revertedWith("DEX: tokenB is zero address");
});
it("should not allow removeShare to be 0", async function () {
await expect(instance.removeLiquidity(ACA, AUSD, 0, 0, 0)).to
.be.revertedWith("DEX: removeShare is zero");
});
it("should reduce the liquidity", async function () {
const intialLiquidity = await instance.getLiquidityPool(ACA, AUSD);
await instance.removeLiquidity(ACA, AUSD, 10, 1, 1);
const finalLiquidity = await instance.getLiquidityPool(ACA, AUSD);
expect(finalLiquidity[0]).to.be.below(intialLiquidity[0]);
expect(finalLiquidity[1]).to.be.below(intialLiquidity[1]);
});
it("should emit RemovedLiquidity event", async function () {
await expect(instance.connect(deployer).removeLiquidity(ACA, AUSD, 1, 0, 0)).to
.emit(instance, "RemovedLiquidity")
.withArgs(deployerAddress, ACA, AUSD, 1);
});
With that, our test is ready to be run.
const { expect } = require('chai');
const { Contract } = require('ethers');
const { ACA, AUSD, LP_ACA_AUSD, DOT, RENBTC, DEX } = require('@acala-network/contracts/utils/MandalaAddress');
const DEXContract = require('@acala-network/contracts/build/contracts/DEX.json');
const TokenContract = require('@acala-network/contracts/build/contracts/Token.json');
const { parseUnits } = require('@ethersproject/units');
const NULL_ADDRESS = '0x0000000000000000000000000000000000000000';
describe('DEX contract', function () {
let instance;
let ACAinstance;
let AUSDinstance;
let deployer;
let deployerAddress;
beforeEach(async function () {
[deployer] = await ethers.getSigners();
deployerAddress = await deployer.getAddress();
instance = new Contract(DEX, DEXContract.abi, deployer);
ACAinstance = new Contract(ACA, TokenContract.abi, deployer);
AUSDinstance = new Contract(AUSD, TokenContract.abi, deployer);
});
describe('Operation', function () {
this.timeout(50000);
describe('getLiquidityPool', function () {
it('should not allow tokenA to be a 0x0 address', async function () {
await expect(instance.getLiquidityPool(NULL_ADDRESS, ACA)).to.be.revertedWith('DEX: tokenA is zero address');
});
it('should not allow tokenB to be a 0x0 address', async function () {
await expect(instance.getLiquidityPool(ACA, NULL_ADDRESS)).to.be.revertedWith('DEX: tokenB is zero address');
});
it('should return 0 liquidity for nonexistent pair', async function () {
const response = await instance.getLiquidityPool(ACA, DOT);
const liquidityA = response[0];
const liquidityB = response[1];
expect(liquidityA).to.equal(0);
expect(liquidityB).to.equal(0);
});
it('should return liquidity for existing pairs', async function () {
const response = await instance.getLiquidityPool(ACA, AUSD);
const liquidityA = response[0];
const liquidityB = response[1];
expect(liquidityA).to.be.above(0);
expect(liquidityB).to.be.above(0);
});
});
describe('getLiquidityTokenAddress', function () {
it('should not allow tokenA to be a 0x0 address', async function () {
await expect(instance.getLiquidityTokenAddress(NULL_ADDRESS, ACA)).to.be.revertedWith(
'DEX: tokenA is zero address'
);
});
it('should not allow tokenB to be a 0x0 address', async function () {
await expect(instance.getLiquidityTokenAddress(ACA, NULL_ADDRESS)).to.be.revertedWith(
'DEX: tokenB is zero address'
);
});
it('should return liquidity token address for an existing pair', async function () {
const response = await instance.getLiquidityTokenAddress(ACA, AUSD);
expect(response).to.equal(LP_ACA_AUSD);
});
});
describe('getSwapTargetAddress', function () {
it('should not allow for the path to include a 0x0 address', async function () {
let path = [NULL_ADDRESS, ACA, DOT, RENBTC];
await expect(instance.getSwapTargetAmount(path, 12345678990)).to.be.revertedWith('DEX: token is zero address');
path = [ACA, NULL_ADDRESS, DOT, RENBTC];
await expect(instance.getSwapTargetAmount(path, 12345678990)).to.be.revertedWith('DEX: token is zero address');
path = [ACA, DOT, NULL_ADDRESS, RENBTC];
await expect(instance.getSwapTargetAmount(path, 12345678990)).to.be.revertedWith('DEX: token is zero address');
path = [ACA, DOT, RENBTC, NULL_ADDRESS];
await expect(instance.getSwapTargetAmount(path, 12345678990)).to.be.revertedWith('DEX: token is zero address');
});
it('should not allow supplyAmount to be 0', async function () {
await expect(instance.getSwapTargetAmount([ACA, DOT], 0)).to.be.revertedWith('DEX: supplyAmount is zero');
});
it('should return 0 for an incompatible path', async function () {
const response = await instance.getSwapTargetAmount([ACA, DOT], 100);
expect(response.toString()).to.equal("0");
});
it('should return a swap target amount', async function () {
const response = await instance.getSwapTargetAmount([ACA, AUSD], 100);
expect(response).to.be.above(0);
});
});
describe('getSwapSupplyAmount', function () {
it('should not allow an address in the path to be a 0x0 address', async function () {
let path = [NULL_ADDRESS, ACA, DOT, RENBTC];
await expect(instance.getSwapSupplyAmount(path, 12345678990)).to.be.revertedWith('DEX: token is zero address');
path = [ACA, NULL_ADDRESS, DOT, RENBTC];
await expect(instance.getSwapSupplyAmount(path, 12345678990)).to.be.revertedWith('DEX: token is zero address');
path = [ACA, DOT, NULL_ADDRESS, RENBTC];
await expect(instance.getSwapSupplyAmount(path, 12345678990)).to.be.revertedWith('DEX: token is zero address');
path = [ACA, DOT, RENBTC, NULL_ADDRESS];
await expect(instance.getSwapSupplyAmount(path, 12345678990)).to.be.revertedWith('DEX: token is zero address');
});
it('should not allow targetAmount to be 0', async function () {
await expect(instance.getSwapSupplyAmount([ACA, AUSD], 0)).to.be.revertedWith('DEX: targetAmount is zero');
});
it('should return 0 for an incompatible path', async function () {
const response = await instance.getSwapSupplyAmount([ACA, DOT], 100);
expect(response.toString()).to.equal("0");
});
it('should return the supply amount', async function () {
const response = await instance.getSwapSupplyAmount([ACA, AUSD], 100);
expect(response).to.be.above(0);
});
});
describe('swapWithExactSupply', function () {
it('should not allow path to contain a 0x0 address', async function () {
let path = [NULL_ADDRESS, ACA, DOT, RENBTC];
await expect(instance.swapWithExactSupply(path, 12345678990, 1)).to.be.revertedWith(
'DEX: token is zero address'
);
path = [ACA, NULL_ADDRESS, DOT, RENBTC];
await expect(instance.swapWithExactSupply(path, 12345678990, 1)).to.be.revertedWith(
'DEX: token is zero address'
);