Creating Smart Contract with Solidity
From this point onward in section 1, we will be working with files inside the AVAX-AMM/packages/contract
directory. 🙌
👩💻 Review of What We'll Implement
We will build a smart contract implementing AMM functionality.
Users will be able to use our smart contract to swap tokens.
Specifically, if our smart contract supports swapping between USDC and JOE tokens, users can connect to the smart contract and exchange USDC for JOE tokens(or JOE for USDC).
Here are the three key terms essential for implementing an AMM:
🐦 Pool
A pool refers to a collection of tokens available for swapping within a smart contract.
If the smart contract contains a USDC and JOE pool, users will be able to trade between USDC and JOE.
🦒 Liquidity Provision
A market with a low amount of tokens in the pool(i.e., large price fluctuations during trades)is said to have low liquidity. Conversely, when there’s a large amount of tokens(i.e., smaller price fluctuations), the market is said to have high liquidity.
Many AMMs include a mechanism where token holders can deposit their tokens into the pool to improve liquidity—this is called liquidity provision.
For example, if there’s a USDC and JOE pool, people who own USDC and JOE tokens can deposit both into the pool. (Depending on the DEX, you may or may not be required to provide both tokens.)
Liquidity providers are often rewarded. In this project, we will implement rewards by collecting a fee during swaps and distributing it to liquidity providers.
🦍 Swap
Refers to the action of exchanging one token for another.
Let’s summarize the features we will implement in this project:
- Token holders can provide liquidity.
- Users who have provided liquidity can withdraw their deposited tokens.
- Users can swap tokens.
- A fee is charged during swaps.
- The fee generated by swaps is distributed to liquidity providers.
🥮 Create the Contracts
We will create three contracts.
One AMM contract
, which is the main smart contract of this project, and two ERC20Token contracts
to simulate the AMM contract behavior.
While it is possible to use already existing tokens on Fuji C-Chain
with the AMM contract
,
having our own deployable ERC20
contracts makes token acquisition more flexible and simpler.
Create two files under the contracts
directory: ERC20Tokens.sol
and AMM.sol
.
When using Hardhat, file structure is very important, so please be careful. If your structure looks like this, you’re good to go 😊
contract
└── contracts
├── AMM.sol
└── ERC20Tokens.sol
Now open the project in your code editor.
Paste the following code into ERC20Tokens.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract USDCToken is ERC20 {
constructor() ERC20("USDC Token", "USDC") {
_mint(msg.sender, 10000 ether);
}
function faucet(address recipient, uint256 amount) external {
_mint(recipient, amount);
}
}
contract JOEToken is ERC20 {
constructor() ERC20("JOE Token", "JOE") {
_mint(msg.sender, 10000 ether);
}
function faucet(address recipient, uint256 amount) external {
_mint(recipient, amount);
}
}
Make sure 0.8.17
matches the version specified in hardhat.config.ts
.
If the Solidity version in hardhat.config.ts
is different from 0.8.17
, change the version in ERC20Tokens.sol
to match it.
We are creating two ERC20Token contracts
here:
USDCToken
and JOEToken
.
Let’s take a closer look at USDCToken
.
contract USDCToken is ERC20 {
constructor() ERC20("USDC Token", "USDC") {
_mint(msg.sender, 10000 ether);
}
function faucet(address recipient, uint256 amount) external {
_mint(recipient, amount);
}
}
The USDCToken
contract inherits from ERC20
, so it implements the functions of the ERC20
standard.
ERC20 is a token standard.
In the constructor of USDCToken
, we call the constructor of ERC20
with parameters to set the token name and symbol.
Within the constructor, 10000 ether (= 10000 x 10^18)
worth of USDC
is minted to the deploying account.
※ We will treat USDC
and JOE
in the same smallest unit as ether.
The _mint
function is implemented in ERC20.
Internally, it adds the specified token amount to both the total token supply and the balance of the specified account.
The faucet
function is also implemented in USDCToken
, which simply calls _mint
.
This is designed to easily mint tokens to any address.
Next, paste the following code into AMM.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract AMM {
IERC20 private _tokenX; // ERC20-compliant contract
IERC20 private _tokenY; // ERC20-compliant contract
uint256 public totalShare; // Total share
mapping(address => uint256) public share; // Share per user
mapping(IERC20 => uint256) public totalAmount; // Amount of each token locked in pool
uint256 public constant PRECISION = 1_000_000; // Precision constant for shares (= 6 digits)
// Specify tokens usable in the pool
constructor(IERC20 tokenX, IERC20 tokenY) {
_tokenX = tokenX;
_tokenY = tokenY;
}
}
At the top of the file, we import IERC20.sol
from openzeppelin/contracts
to use IERC20
.
In the implementation of the AMM contract
, two objects of type IERC20
are maintained:
contract AMM {
IERC20 private _tokenX; // ERC20-compliant contract
IERC20 private _tokenY; // ERC20-compliant contract
...
}
These two objects represent the contract addresses for the token pair that our AMM will manage.
In this project, we’ll pass the addresses of USDCToken
and JOEToken
to operate the AMM.
IERC20 is the interface of ERC20
,
meaning it only defines the function signatures(i.e., how ERC20 behaves).
The AMM contract
does not need to know which exact contract tokenX
and tokenY
are.
As long as the contracts implement the ERC20
functions, we can interact with them via the interface.
To call a function from another contract, you can use the interface like so:
tokenX.transfer()
Reference article on interfaces
📓 About Shares
Now let’s look at the state variables related to shares.
In this project, a share represents the proportion of tokens a liquidity provider has deposited into the pool. It serves the same role as LP tokens commonly provided by DEXs.
To keep things simple, we will represent this as a numeric value stored in the contract.
📓 What are LP Tokens? LP stands for Liquidity Provider tokens. They serve as proof of liquidity provided and can be used to reclaim the deposited assets and any earned rewards.
The share-related state variables are:
uint256 public totalShare; // Total shares
mapping(address => uint256) public share; // Shares per user
uint256 public constant PRECISION = 1_000_000; // Precision for shares (= 6 digits)
totalShare
represents the sum of all users’ shares, and share
keeps track of each user's individual share.
For example, to calculate the ratio of a user's deposited tokens (addr
) to the total pool, use the following:
This logic will be used when a liquidity provider withdraws tokens from the pool.
PRECISION
handles decimals in share calculations, providing 6 digits of precision.
So a share of 1.23 will be stored as 1_230_000
internally.
This is similar to the relationship between ether
and wei
in Ethereum.
The value 1_000_000
is simply a more readable version of 1000000
.
Additionally, totalAmount
is a mapping that stores the amount of each token that has been provided to the pool.
Finally, our AMM contract
will determine the token pair for the pool at the time of deployment using a constructor,
so it accepts two contract addresses as arguments.
🧪 Write Tests
Now that we’ve implemented the contracts, let’s write a test.
Create a new file AMM.ts
under the test
directory and add the following code:
import { ethers } from "hardhat";
import { BigNumber } from "ethers";
import { expect } from "chai";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
describe("AMM", function () {
async function deployContract() {
const [owner, otherAccount] = await ethers.getSigners();
const amountForOther = ethers.utils.parseEther("5000");
const USDCToken = await ethers.getContractFactory("USDCToken");
const usdc = await USDCToken.deploy();
await usdc.faucet(otherAccount.address, amountForOther);
const JOEToken = await ethers.getContractFactory("JOEToken");
const joe = await JOEToken.deploy();
await joe.faucet(otherAccount.address, amountForOther);
const AMM = await ethers.getContractFactory("AMM");
const amm = await AMM.deploy(usdc.address, joe.address);
return {
amm,
token0: usdc,
token1: joe,
owner,
otherAccount,
};
}
describe("init", function () {
it("init", async function () {
const { amm } = await loadFixture(deployContract);
expect(await amm.totalShare()).to.eql(BigNumber.from(0));
});
});
});
This is a simple test to check the deployment process.
Inside the deployContract
function, we deploy all three contracts in sequence.
During token contract deployment, we also mint tokens for both owner
and otherAccount
using the faucet
.
When deploying the AMM contract
, we pass in USDCToken
and JOEToken
to the constructor.
The following line checks the initial value of the totalShare
variable in the deployed AMM contract
.
※ Return values from contracts are of type BigNumber
.
expect(await amm.totalShare()).to.eql(BigNumber.from(0));
We will write more comprehensive tests in the next lesson.
💁 For more on testing with Hardhat, see this guide.