Part 2: Building a Decentralized Exchange with Solidity and React
Write and test your first token swap function, fork the Binance Smart Chain with Hardhat and Moralis and integrate your local blockchain with Metamask
Welcome back frens!
In Part One we got to setting up our base project and our constructor function for the DEX. Today we'll be working on our first token swap function and exploring testing with hardhat.
Here's a live github repo of the project to keep-up with the article and video series.
To understand more about PancakeRouter integration for this project, watch Gabi's breakdown
First Swap Function - $BNB
In our very first function, we'll be allowing the user to send Binance native tokens $BNB to the smart contract for exchange, giving the Router permission to transact the token for the swap, performing the actual token swap and emitting an event which says "Hey! Just swapped my BNB for some select tokens!"
First, we need to declare an event which will be emitted when token swaps are successfully complete…
event SwapTransfer (address from, address to, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut);
but why?
Turns out, events are Solidity's way of logging transaction details in smart contracts. Essentially, we declare an event that accepts certain parameters, and then we emit it in a function after a certain transaction has occurred. The event is basically an EVM dispatch signal we can listen to on the client side, with information about whatever transaction triggers it.
Next, we declare our function body,
function swapExactBNBForTokens(uint amountOutMin, address tokenOut) external payable {
}
external? this indicates a function that can only by called by external parties, and not within the same smart contract.
payable? this modifier is used to indicate that a function can transact (receive and send) within a contract.
Inside this function, we'll declare a fixed-size array that holds two addresses. The first address being that of the token we are trading in, and the second address being that of the token we would be receiving in return.
function swapExactBNBForTokens(uint amountOutMin, address tokenOut) external payable {
address[] memory path = new address[](2);
path[0] = pancakeRouter.WETH(); // technically wrappedBNB
path(1) = tokenOut; // address of selected token on frontend
}
Next, we approve the Router
to transact our token so it can perform the swap. For this, we'll need a dependency, the IERC20 approve()
function which allows another contract
to transact tokens
for a user
.
Here's what the IERC20 approval function looks like
approve(address spender, uint265 amount);
Navigate to your terminal and install this dependency
npm install @openzeppelin/contracts
Then import this line at the top of your contract
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
Back in our function, we'll implement this function using our Router
IERC20(pancakeRouter.WETH()).approve(address(pancakeRouter), msg.value);
// technically wrappedBNB
This line means, the "wrapped" token-$BNB is approving pancakeRouter
to transact a user provided amount of tokens . msg.value
represents the user inputted amount.
Note: WETH here means wrapped $ETH, but since we're on the Binance Smart Chain, it represents wrapped $BNB, the native token of BSC. Now we write the swap!
pancakeRouter.swapExactETHForTokens{value: msg.value}(amountOutMin, path, msg.sender, block.timestamp + 60 * 10);
// technically its swapExactBNBForTokens
We call the function in pancakeRouter
to swap our exact amount of BNB tokens for any other token
we want. Let's examine the interface of this function to understand the parameters it accepts.
function swapExactETHForTokens(
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external payable returns (uint[] memory amounts);
the amountOutMin
represents the minimum amount of tokens sent back to the user.
the path
array holds the addresses of both tokens being swapped.
the address
to refers to the destination of the token after it's swapped.
the deadline
is a specified time limit in which a transaction fails, to avoid long pending transactions and inaccurate prices.
But holdon! There's a certain piece of syntax lost on us - {value: msg.value}
This represents solidity's way of sending a blockchain's
native token to a payable
function. Without the payable
keyword, this transaction will revert. So this is how we send the user's BNB to the contract.
Moving On…
The next thing we need to do is get the amountsOut
from the router
, we do so using an internal pancakeRouter
function that accepts the user's input, the path
array and returns two amounts - the number of tokens sent in and number of tokens
sent back to the user.
uint256[] memory amounts = pancakeRouter.getAmountsOut(msg.value, path);
We then log our token swap using the emit
keyword and passing arguments to the event we created earlier.
emit SwapTransfer(address(pancakeRouter), msg.sender, pancakeRouter.WETH(), tokenOut, msg.value, amounts[1]);
Marvelous work! We've effectively sent BNB to our contract, approved the contract to transact our tokens, swapped the tokens and logged that transaction from the blockchain!
Your function should look like this now.
Testing our first Function with Hardhat and Moralis
We're ready to test our function and see it in action. So head over to the folder named tests
in your project directory and open sample.js
. In there, you should find some sample test functions that come by default with a hardhat project.
We'll be changing the file name to TestDexter.js
and updating the function descriptions inside the describe block.
...
describe("Dex Testing", () => {
it("Should accept user's BNB and swap for Cake", async () => { }
}
Next, we'll setup our constants, including parameters for the swap function and then a beforeEach
method to deploy the smart contract so we can run the swap function.
For our swap function, recall we need the contract address of both tokens.
Note - in production, these token addresses are automatically fetched by the DEX
after user picks what token they wish to exchange for. But for the purpose of writing tests, we need to provide these addresses ourselves.
Head over to pancakeswap docs and copy the contract address for the PancakeRouter.
And retrieve the contract address of the $cake token over here.
Once you're done setting up constants, the next line you need is getting a signer
with ethers.
const accounts = await ethers.getSigners();
owner = accounts[0]
A Signer in Ethers.js is an object that represents an Ethereum account. It's used to send transactions to contracts and other accounts. Here we're getting a list of the accounts in the node we're connected to, which in this case is Hardhat Network, and only keeping the first/owner account for our test purposes - source is Hardhat documentation on testing
Next, we create a contract factory for any instances of our exchange
we need to deploy
const DexterExchangeContract = await ethers.getContractFactory("DexterExchange");
This line basically says, "from our smart contract, generate a factory and store in a variable" - this makes it easy to run tests on instances of the same smart contract.
read up on the
factory
pattern in OOP to gain more insight.
After creating the factory, we deploy the smart contract with ethers
and log the address of the contract
to the console.
...
dexterExchange = await DexterExchangeContract.deploy(pancakeRouterAddress);
await dexterExchange.deployed();
console.log(`dexterExchange deployed at ${dexterExchange.address}`);
For the actual test block,
it("Should accept user's BNB and swap for Cake", async () => {
const bnb_cake_swap_tx = await dexterExchange.connect(owner).swapExactBNBForTokens
(0,
cakeTokenAddress,
{value: ethers.utils.parseEther("500")});
});
This code snippet gets the connect
method from our exchange
to connect the owner
to the contract, then tests the swap
function we wrote.
For our function parameters, we will pass in zero as theamountOutMin
parameter, pass in the cakeTokenAddress
for the tokenOut
parameter and pass in the amount of tokens we wish to send to the contract using the utils.parseEther
method.
At this point, your entire test file should look like this.
Up Next: Setting up Hardhat Config
Head over to your hardhat.config.js
file and replace the existing module.exports
object with this
...
module.exports = {
solidity: "0.8.4",
networks: {
hardhat: {
chainId: 31337,
},
localhost: {
url: "https://localhost:8545",
chainId: 31337,
},
},
};
This sets up our hardhat
local chain id and sets up localhost as the destination url for the forked version of the BSC mainnet.
Forking Binance Smartchain Mainnet From Moralis
Next, you want to head to moralis, create an account and head over to the admin dashboard. On the bottom-left side of the dashboard, click on "speedy nodes". This will give you a list of options.
On the Binance Smart Chain, click on the button that says endpoints and it should reveal a list of endpoints.
Copy the url to the "mainnet archive", head back to your project terminal and enter the following command
npx hardhat node --fork "paste mainnet archive url here"
This command will complete a fork of the BSC mainnet and you should see the following result in your terminal.
Congrats! You now have a local running node of the Binance Smart Chain.
Setting up Local Blockchain in Metamask
Now, we get to add our mainnet fork to Metamask so we can view changes in token amounts after running our test swaps.
If you haven't already, head over to the download page and install metamask for your web browser of choice. Once you're done setting installing and setting up a wallet, head over to settings, click on networks
and hit add network
. This should open up the following page
Enter the following details next...
You might encounter an error here since we entered the values AFTER running the node. To fix this, close your current running node in the terminal and run the command again
npx hardhat node --fork "paste mainnet archive url here"
Then re-enter your chain id in the field and click "save".
Voila! Your own local binance chain is running and recognized by metamask. You should be able to see all your wallet information now with the currency $BNB.
Import a Wallet
To import one of the available free accounts on the BSC localhost fork, right click on the account button in the metamask extension.
head to the terminal in your project and copy the private key from the first account and paste in the 'private key' field.
Running our Tests
Whew! Finally!
To run your test function, type this command into the terminal
npx hardhat test --network localhost
If all's well, this should be your result!
Now head back to your metamask wallet and notice your native BNB balance is no longer 10,000 but now
Wait! Where's the token I swapped for? Where's my CAKE?!!!!!
Well...see, we lied...
JK
Turns out you need to import a token
for your wallet to recognize it.
So, on your wallet page, click "import token", head over to coinmarketcap and copy the contract address for the $CAKE token.
Paste it into your import form and it should automatically recognize the token.
Great work! You've officially performed the sexiest token swap in DeFi history, on your own local smart chain. Here's my result!
Rounding up - Next Lesson Preview
Hey! I know this was a much longer lesson but you did amazing, if you have any questions or would simply like to keep up with the series, reach myself or Gabi.
In the next lesson, we'll be writing and testing more swap functions for various types of tokens and discussing advanced solidity patterns. Thanks for joining us!