A Guide To Build, Test, and Deploy Your First DApp
There are some great combinations of tools we can use to build a DApp. In this tutorial, learn how to build a DApp using Truffle, Ganache, Ethers.js, and React.
Join the DZone community and get the full member experience.
Join For FreeI recently picked up Ethereum development with Solidity, and while it's been fairly easy to write smart contract code, I've had some trouble putting all the pieces together to form a decentralized application (DApp).
For example, I couldn't understand the role of wallets as a means of identification in Web3. I also didn't know how to simulate a staging environment similar to what we have in Web2 where I could thoroughly test out my DApp before deploying to the Mainnet, since smart contracts are "Immutable" and cannot be updated once deployed.
Before starting my Web3 journey, I worked full-time as a backend engineer using tools like Node.js, Golang, Postgres, Docker, and AWS to build and deploy web APIs that are currently being consumed by millions around the world. While at it, I was a passive observer of the Web3 space, and it only piqued my interest in 2021.
After spending weeks on multiple discord servers, telegram groups, blogs, and YouTube channels trying to understand what the buzz around Web3 was about, I quit my job and decided to go all-in on Web3. Luckily for me, that was around the same time I enrolled in an annual blockchain boot camp, and as they say, the rest is history.
There are some great combinations of tools we can use to build a DApp. However, I've come to depend on the following stack:
Truffle: A development environment and testing framework for smart contracts
MetaMask: A wallet for interacting with decentralized applications
Ganache: A local blockchain simulator for deploying smart contracts and inspecting state during development
Ethers.js: A library for interacting with deployed smart contracts on the frontend
React.js: A Javascript library for building user interfaces
In this guide, we will be building a DApp that allows a homeowner to auction their home, accepting bids from users in Eth, and withdrawing them into their wallets once the auction ends.
The goal is to demonstrate how these tools can come together well to create a decentralized application that removes every need for a middleman by using smart contracts to execute instructions as needed.
Finally, creating a beautiful user interface isn't the focus of this guide, so we'll use a very bare-bone user interface.
To follow through with this guide, you will need to download:
Node.js runtime
Truffle development environment
Ganache local blockchain
MetaMask wallet browser extension
Note that you won’t be needing real Eth tokens as we'll be using fake Eth from Ganache. You can find the complete code for this guide here.
Creating a Truffle Project and Writing Our Smart Contract
Once you've installed Truffle globally, create a new folder tiny-dapp and run truffle init
inside to get started with a basic project template that has all the necessary directories and files. The folder structure should look like this:
- contracts/: Directory for Solidity contracts
- migrations/: Directory for scriptable deployment files
- test/: Directory for test files for testing your application and contracts
- truffle-config.js: Truffle configuration file
Update the truffle-config.js file to look like this:
module.exports = {
networks: {
development: {
host: "127.0.0.1",
port: 7545,
network_id: "*",
},
},
compilers: {
solc: {
version: "0.8.9"
}
}
};
The solidity compiler version in the truffle-config.js file should be the same as the one in our contracts file (solidity file) to prevent running into issues due to version changes as the solidity programming language is still very much under development.
In the contracts directory, create a new file Auction.sol and define the contract properties like so:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
contract Auction {
address private owner;
uint256 public startTime;
uint256 public endTime;
mapping(address => uint256) public bids;
// Properties
struct House {
string houseType;
string houseColor;
string houseLocation;
}
struct HighestBid {
uint256 bidAmount;
address bidder;
}
House public newHouse;
HighestBid public highestBid;
}
We've defined an owner property with the private access modifier. The owner property can only be accessed from within the defining contract; in this case, our contract. Read about access modifiers in Solidity from their official doc.
We've also defined the auction startTime, endTime, and bids as public properties, meaning one can access them from anywhere. Next, we've added two structs, House and HighestBid, that help define the house's properties and the highest bid, respectively. Finally, we initialized both structs, creating two new public variables, newHouse and hightestBid.
With those out of the way, let's define some modifiers for our smart contract. In Solidity, modifiers are functions used to alter the behavior of other functions. They are typically used to enforce security and ensure certain conditions are met before invoking a function. Read about modifiers here.
Add the following code just below the properties we already defined:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
contract Auction {
....
// Modifiers
modifier isOngoing() {
require(block.timestamp < endTime, 'This auction is closed.');
_;
}
modifier notOngoing() {
require(block.timestamp >= endTime, 'This auction is still open.');
_;
}
modifier isOwner() {
require(msg.sender == owner, 'Only owner can perform task.');
_;
}
modifier notOwner() {
require(msg.sender != owner, 'Owner is not allowed to bid.');
_;
}
}
Here, we've defined four modifiers:
isOngoing: Ensures that the auction is still accepting bids by comparing the current time with the auction endTime property
notOngoing: Does the opposite of isOngoing
isOwner: Verifies that the calling user is the contract owner (the address that deployed the contract)
notOwner: Does the opposite of isOwner
All four modifiers use the require() check and accepts a message parameter that it returns as the error message, should the check fail.
Next, let's define some events we would emit when our contract changes state. This is important because it allows our frontend code to subscribe to changes to the contract state, thereby giving users some sense of interactiveness seeing that the Ethereum blockchain takes time to process transactions (typically 15 secs or more). Read more on events in Solidity here.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
contract Auction {
....
// Events
event LogBid(address indexed _highestBidder, uint256 _highestBid);
event LogWithdrawal(address indexed _withdrawer, uint256 amount);
}
To wrap up our smart contract, let's define a constructor function that would execute during contract deployment. Inside this constructor function, we'll assign values to some of the properties we defined earlier. We'll also define our smart contract functions.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
contract Auction {
....
// Assign values to some properties during deployment
constructor () {
owner = msg.sender;
startTime = block.timestamp;
endTime = block.timestamp + 1 hours;
newHouse.houseColor = '#FFFFFF';
newHouse.houseLocation = 'Lagos, Nigeria';
newHouse.houseType = 'Duplex';
}
function makeBid() public payable isOngoing() notOwner() returns (bool) {
uint256 bidAmount = bids[msg.sender] + msg.value;
require(bidAmount > highestBid.bidAmount, 'Bid error: Make a higher Bid.');
highestBid.bidder = msg.sender;
highestBid.bidAmount = bidAmount;
bids[msg.sender] = bidAmount;
emit LogBid(msg.sender, bidAmount);
return true;
}
function withdraw() public notOngoing() isOwner() returns (bool) {
uint256 amount = highestBid.bidAmount;
bids[highestBid.bidder] = 0;
highestBid.bidder = address(0);
highestBid.bidAmount = 0;
(bool success, ) = payable(owner).call{ value: amount }("");
require(success, 'Withdrawal failed.');
emit LogWithdrawal(msg.sender, amount);
return true;
}
function fetchHighestBid() public view returns (HighestBid memory) {
HighestBid memory _highestBid = highestBid;
return _highestBid;
}
function getOwner() public view returns (address) {
return owner;
}
}
We've added four new functions to our smart contract:
makeBid: A public payable function that accepts Eth. We'll call this function whenever a user wants to make a fresh bid or add to their current offer. It uses the isOngoing modifier to allow bidding only while the auction is still ongoing and notOwner modifiers to prevent the auction owner from placing a bid.
withdraw: A public function that allows the contract owner to withdraw funds into their wallet; it uses the notOngoing modifier to prevent premature withdrawal while the auction is still ongoing and the isOwner modifier to restrict access to the auction owner.
fetchHighestBid and getOwner are both public view functions, which means unlike makeBid and withdraw functions, they do not modify state. This is also why they do not log any events. These functions return the auction's highest bid and the auction owner's address, respectively.
With our smart contract ready to be tested and deployed, let's verify that there are no errors in our code. We can do that by compiling our contract using truffle compile
. It should compile successfully, and a new build folder with our contract ABI should appear on the project's root directory. ABI stands for Application Binary Interface and is simply an object that defines how you can call smart contract functions on the frontend and receive data back. Note that we will need to change the location where our contract's ABI lives before we can use it in our frontend code.
To deploy our smart contract to any network, we need a migration script. In the migrations folder, create a new file and call it 2_initial_migrations.js. The prefix 2_ tells Truffle which migration to run first and should increase as we add new migration scripts.
const Auction = artifacts.require("Auction");
module.exports = function (deployer) {
deployer.deploy(Auction);
};
The code above tells Truffle to fetch the Auction.sol smart contract and deploy it to whatever network we specify in our truffle.config.js file.
Testing Our Smart Contract With JavaScript, Mocha, and Chai
As mentioned earlier, Truffle comes bundled with a testing environment based on Mocha (an automated test framework for Node.js) and Chai (an assertion library for Node.js and the browser). Head to the /test folder and create an Auction.test.js file with the following content:
const Auction = artifacts.require("Auction");
contract("Auction", async accounts => {
let auction;
const ownerAccount = accounts[0];
const userAccountOne = accounts[1];
const userAccountTwo = accounts[2];
const amount = 5000000000000000000; // 5 ETH
const smallAmount = 3000000000000000000; // 3 ETH
beforeEach(async () => {
auction = await Auction.new({from: ownerAccount});
})
it("should make bid.", async () => {
await auction.makeBid({value: amount, from: userAccountOne});
const bidAmount = await auction.bids(userAccountOne);
assert.equal(bidAmount, amount)
});
it("should reject owner's bid.", async () => {
try {
await auction.makeBid({value: amount, from: ownerAccount});
} catch (e) {
assert.include(e.message, "Owner is not allowed to bid.")
}
});
it("should require higher bid amount.", async () => {
try {
await auction.makeBid({value: amount, from: userAccountOne});
await auction.makeBid({value: smallAmount, from: userAccountTwo});
} catch (e) {
assert.include(e.message, "Bid error: Make a higher Bid.")
}
});
it("should fetch highest bid.", async () => {
await auction.makeBid({value: amount, from: userAccountOne});
const highestBid = await auction.fetchHighestBid();
assert.equal(highestBid.bidAmount, amount)
assert.equal(highestBid.bidder, userAccountOne)
});
it("should fetch owner.", async () => {
const owner = await auction.getOwner();
assert.equal(owner, ownerAccount)
});
})
Learn more about testing with Truffle here.
To run these test cases, navigate to the project root and run truffle develop
followed by test
.
Building the User Interface With React
Before we start putting the front end together, we first have to set up a react project. We will use the create-react-app command-line tool.
First, run the command npx create-react-app client
on the root of the tiny-dapp folder. This will set up a React project with all the dependencies needed to write modern JavaScript code inside a folder called client. Navigate into the client folder and run yarn start
. You should have a React app running on port 3000. Since we're still putting the code together for the user interface, use CTRL + C to kill the process.
With the React project set up, let's install Ethers.js and the Ethersproject's unit package (for manipulating numbers out of Javascript's range). First, run yarn add ethers @ethersproject/units
. Next, open the src/App.js file and update it with the following code:
import './App.css';
import { useEffect, useState } from 'react';
import { ethers } from 'ethers';
import { parseEther, formatEther } from '@ethersproject/units';
import Auction from './contracts/Auction.json';
const AuctionContractAddress = "0x76b868C4Bb5f38167c3BC325f12591B3297a002C";
const emptyAddress = '0x0000000000000000000000000000000000000000';
function App() {
const [account, setAccount] = useState('');
const [amount, setAmount] = useState(0);
const [myBid, setMyBid] = useState(0);
const [isOwner, setIsOwner] = useState(false);
const [highestBid, setHighestBid] = useState(0);
const [highestBidder, setHighestBidder] = useState('');
async function initializeProvider() {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
return new ethers.Contract(AuctionContractAddress, Auction.abi, signer);
}
async function requestAccount() {
const account = await window.ethereum.request({ method: 'eth_requestAccounts' });
setAccount(account[0]);
}
async function fetchHighestBid() {
if (typeof window.ethereum !== 'undefined') {
const contract = await initializeProvider();
try {
const highestBid = await contract.fetchHighestBid();
const { bidAmount, bidder } = highestBid;
setHighestBid(parseFloat(formatEther(bidAmount.toString())).toPrecision(4));
setHighestBidder(bidder.toLowerCase());
} catch (e) {
console.log('error fetching highest bid: ', e);
}
}
}
async function fetchMyBid() {
if (typeof window.ethereum !== 'undefined') {
const contract = await initializeProvider();
try {
const myBid = await contract.bids(account);
setMyBid(parseFloat(formatEther(myBid.toString())).toPrecision(4));
} catch (e) {
console.log('error fetching my bid: ', e);
}
}
}
async function fetchOwner() {
if (typeof window.ethereum !== 'undefined') {
const contract = await initializeProvider();
try {
const owner = await contract.getOwner();
setIsOwner(owner.toLowerCase() === account);
} catch (e) {
console.log('error fetching owner: ', e);
}
}
}
async function submitBid(event) {
event.preventDefault();
if (typeof window.ethereum !== 'undefined') {
const contract = await initializeProvider();
try {
const wei = parseEther(amount);
await contract.makeBid({ value: wei });
contract.on('LogBid', (_, __) => {
fetchMyBid();
fetchHighestBid();
});
} catch (e) {
console.log('error making bid: ', e);
}
}
}
async function withdraw() {
if (typeof window.ethereum !== 'undefined') {
const contract = await initializeProvider();
contract.on('LogWithdrawal', (_) => {
fetchMyBid();
fetchHighestBid();
});
try {
await contract.withdraw();
} catch (e) {
console.log('error withdrawing fund: ', e);
}
}
}
}
export default App;
To clarify what's happening in the code:
We set the application's initial state to reasonable defaults.
The initializeProvider function establishes a client connection to the deployed contract using the contract address and the ABI.
The requestAccount function connects to the user's MetaMask wallet and pulls their wallet address.
The fetchMyBid function fetches and displays the logged-in user's current offer.
The fetchHigestBid function fetches and displays the highest bidder's current offer and wallet address.
The submitBid function allows users to submit a new offer or update an existing offer.
Finally, the withdraw function allows the auction owner to withdraw funds at the end of the auction.
Finally, let's wrap up the front end by adding some more code:
import './App.css';
import { useEffect, useState } from 'react';
import { ethers } from 'ethers';
import { parseEther, formatEther } from '@ethersproject/units';
import Auction from './contracts/Auction.json';
const AuctionContractAddress = "0x76b868C4Bb5f38167c3BC325f12591B3297a002C";
const emptyAddress = '0x0000000000000000000000000000000000000000';
function App() {
....
useEffect(() => {
requestAccount();
}, []);
useEffect(() => {
if (account) {
fetchOwner();
fetchMyBid();
fetchHighestBid();
}
}, [account]);
return (
<div style={{ textAlign: 'center', width: '50%', margin: '0 auto', marginTop: '100px' }}>
{isOwner ? (
<button type="button" onClick={withdraw}>
Withdraw
</button>
) : (
""
)}
<div
style={{
textAlign: 'center',
marginTop: '20px',
paddingBottom: '10px',
border: '1px solid black'
}}>
<p>Connected Account: {account}</p>
<p>My Bid: {myBid}</p>
<p>Auction Highest Bid Amount: {highestBid}</p>
<p>
Auction Highest Bidder:{' '}
{highestBidder === emptyAddress
? 'null'
: highestBidder === account
? 'Me'
: highestBidder}
</p>
{!isOwner ? (
<form onSubmit={submitBid}>
<input
value={amount}
onChange={(event) => setAmount(event.target.value)}
name="Bid Amount"
type="number"
placeholder="Enter Bid Amount"
/>
<button type="submit">Submit</button>
</form>
) : (
""
)}
</div>
</div>
);
export default App;
Here, we are simply using React's useEffect hook to request user account details and fetch data from our smart contract on load time. We then display these data and attach callbacks to JavaScript events.
Deploying Our Smart Contract to Ganache
It's time to deploy our smart contract to Ganache so that it is available to be called from the React code. To do that, update the truffle.config.js file to look like this:
module.exports = {
contracts_build_directory: './client/src/contracts',
networks: {
development: {
host: "127.0.0.1",
port: 7545,
network_id: "*",
},
},
compilers: {
solc: {
version: "0.8.9"
}
}
};
We added a new contracts_build_directory key to the config object that points to ./client/src/contracts. This places the contract ABI inside this folder whenever we run the compile command, making it easier to interact with from the React code.
Launch the Ganache application and click the QUICKSTART button to get a local blockchain running. Ganache provides 10 test Ethereum accounts with 100 fake Eth each for playing around and testing our DApp. When deploying smart contracts to Ganache, it uses the first account's address as the deploying address.
Navigate to the project root directory and run truffle migrate
. You should see a new contracts directory in the client/src folder if successful.
Copy your unique contract address from the output on your console and update this line in the React code (App.js).
const AuctionContractAddress = “CONTRACT ADDRESS HERE”;
Note: After running the migration command for the first time, subsequent migrations will need to be run with truffle migrate--reset
. This would redeploy the contract to a new Ethereum address. Always remember to update the contract address in the React code.
Manual Testing With MetaMask
To interact with a decentralized application like the one we just created, we need to connect our MetaMask wallet. We will use some of the accounts given to us for free by Ganache: to be precise, the first two accounts (the Auction owner and one random account). Let's go ahead and import those into the MetaMask wallet.
Launch the MetaMask wallet and follow these instructions to import accounts:
1. Click the dropdown and select the Add Network option.
3. Head back and fill in the details for the new network.
4. Get the private key for the first test account from Ganache.
5. Import account into MetaMask:
Repeat steps 2 and 3 to import the second test account to MetaMask.
With both accounts imported and ready for use, let's start playing with our app.
In MetaMask, switch from the Ethereum Mainnet to the newly added Ganache network (ensure Ganache is running), navigate to the client folder, and start the react app with yarn start
.
Proceed to localhost:3000. MetaMask will prompt you to connect your account. Select the two newly added accounts:
Now, depending on which account you're on, you can do the following:
User Account: Make a fresh bid or add to your current offer. The UI will show the current highest bid, the bidder, your current bid, and your connected account address.
Owner Account: See what address made the highest bid and the highest bid amount. You can also withdraw the current highest offer to your wallet address once the auction ends (auction startTime + 1 hour).
That’s it! If you’ve followed through to this point, then congratulations, you’ve written, tested, and deployed your first decentralized application.
Opinions expressed by DZone contributors are their own.
Comments