Building a Decentralized Escrow Application
Hi, and welcome back. I'm continuing Alchemy University Ethereum Developer Bootcamp blog series with this article and this one is the fifth blog post of it. Week 5 of this bootcamp is about Solidity basics like mapping, events, escrows etc. The assignment for this week was to create a decentralized escrow application and you are going to see the mistakes I made and read about my struggles while creating this application.
What is Escrow and what is this application?
You can think of the escrow as a middleman. Let's say I want to buy something, and you want to sell something but we both don't trust each other. You don't trust me that I will pay the exact amount of money, and I don't trust you that you will send me the right object I've bought. We decide on a third person/party to be the middleman to prove if the transaction happened as it should be. We both trust that third party, but what if that trusted middleman is dishonest? What if the game is rigged?
Smart contracts come to the rescue right at this point. That middleman might be dishonest but if both parties agree on a smart contract that all the rules of this transaction are set, then there will be no worries about an escrow being dishonest.
Okay, let's move on to the application. You can find the base code of this application on this GitHub link. It has two parts: the first one is the Hardhat project which is the base directory, and the second one is the react app for the frontend which is in the /app
directory. We need to use a wallet, we need to start a hardhat node that acts like a local blockchain (you can start it with npx hardhat node
in the base directory) and of course, we need to start the react app (npm start
in the/app
directory) for this project.
Escrow Smart Contract
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
contract Escrow {
address public arbiter;
address public beneficiary;
address public depositor;
bool public isApproved;
constructor(address _arbiter, address _beneficiary) payable {
arbiter = _arbiter;
beneficiary = _beneficiary;
depositor = msg.sender;
}
event Approved(uint);
function approve() external {
require(msg.sender == arbiter);
uint balance = address(this).balance;
(bool sent, ) = payable(beneficiary).call{value: balance}("");
require(sent, "Failed to send Ether");
emit Approved(balance);
isApproved = true;
}
}
This is the smart contract we get from that repository. Yep, that's all. Just a few lines of code, and a basic escrow contract. There are addresses: arbiter, beneficiary and depositor. In the constructor, you can see depositor = msg.value
. What happens here? The depositor is the buyer and the beneficiary is the seller. The depositor deploys a contract with some money. It pays the price of the product to the contract (not to the seller) when deploying. There is an arbiter and basically, it acts like a referee. If the seller sends the product to the buyer arbiter approves this transaction. Only the arbiter can call the approve() function. After the arbiter's (referee) approval, the balance of the contract is sent to the beneficiary (seller) and the Approved event is emitted.
Let's make it short. The depositor pays to the contract. Arbiter checks and approves. The seller gets the contract balance after the approval.
Understanding the Frontend App of the Repo
In our repo's /app/src
directory, there are multiple JS files and they all interacting each other. Before implementing weekly challenges on this base project, we have to understand these files and their interactions first.
Let's start with window.ethereum
. We'll see that term multiple times and we can say that it is simply MetaMask. window.ethereum
allows websites to request the user's Ethereum accounts, read data from the blockchain and let them sign messages. If window.ethereum
is not present, which means MetaMask is not installed. You can read the MetaMask documentation with this link. There is also another thing we need to learn which is "provider" and you should check the ethers documentation for that too. These are the things that connect our app to the blockchain.
Okay, we got them. What else? How is our app working? You should check the repo's "/app/src/app.js" file. It's rendering a page that requires the user to write some input which are addresses of the arbiter and depositor. When deploy button is clicked, the app calls a function which is called newContract()
and this function creates a contract with those input values and deploys it. Then we can see the deployed contract and the arbiter can approve it. That's all but we need to implement new features.
Challenges to Implement
In the base code, the deposit amount and value are not in Eth but in Wei. Because of that, it was hard to read the values and confirm a transaction. The first challenge is changing the values to Eth which is not a hard thing to do. The previous code was:
const value = ethers.BigNumber.from(document.getElementById('wei').value);
and it was getting the value typed and changing it to BigNumber using ethers library. The only thing I needed to do was using parseEther
and that's all:
const value = ethers.utils.parseEther(document.getElementById('eth').value);
The next thing I wanted to do was to increase the arbiter count. Why would I want only one referee? Let's make it two or maybe three. I wanted it to be like a multi-sig wallet. I wanted the contract to require two approvals from different arbiters to emit the "Approved" event and to transfer the balance. So I made these changes to the contract:
// This part is the Solidity code.
uint public approveCount = 0;
function approve() external {
require(msg.sender == arbiter1 || msg.sender == arbiter2);
if (approveCount <= 1){
approveCount++;
}
if (approveCount == 2){
uint balance = address(this).balance;
(bool sent, ) = payable(beneficiary).call{value: balance}("");
require(sent, "Failed to send Ether");
emit Approved(balance);
}
}
It's basically the same approve function but only tiny little changes. The balance transfer occurs only if both arbiters approve. We can do many more different things like this. We might create 5 arbiters and we expect 3 approvals for example. We might want 2/3 approvals etc.
In the current situation of the application, whenever we refresh the page, everything goes blank, all the deployed contacts are erased and we need to start over because we are not storing any data. So I wanted to implement something to see already deployed contracts in the app too.
When I searched for it, I learned that I might run a local server to solve this issue. There is something called JSON server which helps developers to create a local server, fetch and save some data to it. Only reading those was not enough for me to understand how to implement it in my app and I watched some videos too, which I can recommend watching this YouTube playlist.
After I learned how to run a local server, I tried to implement it. I was using localhost:3000 for my react app, so I had to run the server in some other port which was localhost:8000 (You can run it with json-server --watch db.json --port 8000
) Before starting the server I created an empty JSON file, db.json
, and then started the server and added the codes below to my app.js file.
useEffect(() => {
async function getContracts() {
const res = await fetch('http://localhost:8000/escrows');
const contract = await res.json();
setContracts(contract);
}
getContracts();
}, []);
With this code, I could get the data from my 'db.json' file which was empty at the moment. The more important part, I have to be able to write data to that file. Do you remember there was a function which is called when the user clicks the deploy button? Yes, newContract()
function. I decided to write some code inside this function, and whenever a new contract is deployed, it should add data to my JSON file. I learned that I could use "POST" method for that, like this:
// This is the escrow constant part which were in the base code.
const escrow = {
address: escrowContract.address,
arbiter,
beneficiary,
value: value.toString(),
handleApprove: async () => {
escrowContract.on('Approved', () => {
document.getElementById(escrowContract.address).className =
'complete';
document.getElementById(escrowContract.address).innerText =
"✓ It's been approved!";
});
// This is the part I added to save every escrow to JSON database
await fetch('http://localhost:8000/escrows', {
method: 'POST',
body: JSON.stringify(escrow),
headers: { 'Content-Type': 'application/json' }
});
In the code above, there is a const escrow = ...
part which was in the base code and there is the part I added. It turns out we have to use headers: { 'Content-Type': 'application/json' }
if we want to post some data to JSON server. After I completed these, I tried to deploy a contract and I wanted to check my db.json file after it, which is down below.
You can see the URL is localhost:8000/escrows and there is some data is added to it (It was an empty array before). Okay, that's great. I was able to write some data when a contract deployed, fetch the data from a JSON file and see all the deployed contracts. But, there were problems. Big ones.
The first problem was this: When I deployed a contract, I was not seeing the contract right at moment, I had to refresh the page to see it. So I decided to add this: window.location.replace("/");
I know, I know, this is not logical but it was working. I had to render the page again when the contract is deployed not refresh it. The thing is I am realizing this right now while writing this post, I should have added dependency to my getContracts()
function (the one fetches data from the database) which is inside the useEffect. I left the dependency array empty and it was rendering only one time. Debugging while blogging :D
The second problem was much, much bigger. It was this:
Oh, my! handleApprove is not a function. In the base code above, you can see the escrow has an async function which is handleApprove() and it makes the arbiter sign the transaction when approve button is clicked. But now it was not working. It's broken :O
What was the deal? Normally, the escrow object has some values and a function inside. If I deploy a contract like the base code and then use the escrow object I could get the handleApprove() function working. But if I send data to the JSON server and get data from the server, the function was not working. It turns out we can not send functions to the JSON server, but it doesn't give errors too. It only takes the values it can take, and leave the rest. It doesn't try to get everything. It's NOT all or nothing. The "POST" method I mentioned above was posting only the address, arbiter, value etc.
Alright, now I knew the issue, how I could solve it? I tried many things but I got stuck. I tried to move the handleApprove() function outside of the object. But then I was not able to reach the contract instance. I tried to pass all the necessary values to the escrow.js file and write the function in that file. But same again, I couldn't send the contract instance. Then, I wanted to create a the contract instance for that specific contract address inside the handleApprove function, like this:
const handleApprove = async () => {
const escrow = ethers.getContractAt("Escrow", address);
escrow.on('Approved', () => {
document.getElementById(escrow.address).className =
'complete';
document.getElementById(escrow.address).innerText =
"✓ It's been approved!";
})
await approve(escrow, signer);
}
It's almost the same code. The only difference is this line: const escrow = ethers.getContractAt("Escrow", address)
. Because I could not reach the deployed contract outside of the newContract() function, I am trying to create it again. Not deploying again, creating an instance for the already deployed contract because I know the address. I am pretty certain this would work.
Unfortunately, the ethers library doesn't have the ethers.getContractAt()
function. It is part of the hardhat-ethers plugin and the errors didn't stop, at all.
Because of the webpack 5 issue, the hardhat-ethers library was not compiling. It started with 252 errors, I tried tons of things like config-overrides etc. I tried these methods from Alchemy and other things but still more than 50 errors. If I use ethers, it compiles but if I try hardhat-ethers, it doesn't compile.
I've decided to move on, and return to the previous version but I will find a way to handle this issue. But if I don't move on and stay there for many days it won't help me.
It's saddening not to be able to pass an obstacle, go back and walk around but I'm only at the beginning of my path. I'm sure these kinds of things will happen in the future too. Even though I struggled quite a bit during this project I learned tons of new stuff and I'm happy about it. If you have the solution for my previous problem or any ideas you want to mention, please let me know.
Thank you for reading this post, and hope to see you next week's project.
Take Home Messages (for myself :D)
You might break your code while trying to add new features.
There will be some bugs which will make you crazy.
It might be better to move on and learn new things, and come back later, stronger.