Introduction
In this tutorial, we will be learning how to create a quadratic voting app with collaboratively managed ranked lists. This is a fairly complex application and a good exercise in serious dApp development. We're going to write a smart contract in Solidity and a front-end in Vue.js, then deploy our dApp on the Polygon testnet.
Here's a preview of what we will be building:
Prerequisites
This tutorial assumes basic knowledge of blockchain, Solidity and Vue.js.
By the end of this tutorial you will be able to:
- Build a full stack dApp on the Ethereum blockchain
- Create smart contracts with Solidity
- Write tests for smart contracts
- Compile and migrate smart contracts with Truffle
- Build a front-end with Vue.js and Tailwind CSS
- Call smart contract functions with Web3
- Serve images with IPFS
- Deploy smart contracts to the Polygon Mumbai testnet
Requirements
Node, NPM and Yarn
Node.js is a runtime environment that allows us to execute JavaScript outside of a web browser.
NPM and Yarn are package managers. Which you use is a matter of preference. We will be using Yarn in this tutorial.
- Install Node.js here.
- Check NPM installation:
npm -v
- Install yarn:
npm i -g yarn
Truffle
Truffle is a development environment for Ethereum that we will use to compile and deploy our smart contract.
npm i -g truffle
Ganache
Ganache is a personal Ethereum blockchain you can deploy on your computer for development purposes.
This is mostly optional, but it is recommended to install it so we can use it later to run our tests.
MetaMask
MetaMask is a browser extension that allows you to access your Ethereum wallet and interact with dApps.
Install it here.
Use the following configuraton to add the Polygon Mumbai testnet.
Preview
These are the additional technologies we will be using later.
Project setup
To generate initial project files, we will use the Truffle and Vue CLI tools.
npm i -g truffle @vue/cli vue create quadratic-voting-app cd quadratic-voting-app truffle init
When creating the Vue app, choose "Default (Vue 3)" and "Use Yarn".
Your project should look like this.
Creating the smart contract in Solidity
Create a new file called
QuadraticVoting.sol
in the contracts
folder.// SPDX-License-Identifier: MIT pragma solidity >=0.4.0 <0.9.0; contract QuadraticVoting { struct Item { address payable owner; uint amount; bytes32 title; string imageHash; // IPFS cid string description; mapping(address => uint) positiveVotes; // user => weight mapping(address => uint) negativeVotes; // user => weight uint totalPositiveWeight; uint totalNegativeWeight; }
The primary data structure in our app is going to be the Item. Users will vote positively or negatively on items to control their ranking, paying a fee that depends on the weight of their vote. Votes are quadratically funded, which means that anyone can add as much weight to their vote as they like, however, the price of submitting this vote will be the weight squared.
Any fee paid for a positive vote is rewarded to the creator of the item, creating an economy where the best suggestions, meaning the ones ranked the highest by others, receive the highest earnings. This incentivizes high quality, honest suggestions. Negative vote fees are redistributed to other items.
uint constant public voteCost = 10_000_000_000; // wei mapping(uint => Item) public items; // itemId => id uint public itemCount = 0; // also next itemId
The
voteCost
constant is the price of a vote of weight 1 in terms of wei. One MATIC (Polygon equivalent of ETH) consists of 1,000,000,000,000,000,000 wei and one gwei consists of 1,000,000,000 wei. Therefore, voteCost
is set to 10 gwei, or 0.00000001 MATIC. You can change this value to whatever you wish.As an example of how quadratic voting works, let's say this contract was for a ranked list of the most favored Star Wars characters. One person may suggest Han Solo while another may suggest Chewbacca. If someone votes +2 for Han Solo, it will cost them
10 gwei * 2 * 2 =
40 gwei. If someone votes +3 for Chewbacca, it will cost them 10 gwei * 3 * 3 =
90 gwei. This leads to an ecosystem where a single person may vote more times than another if they care about the topic more, but it gets exponentially more expensive with each vote, ensuring a fair democracy.event ItemCreated(uint itemId); event Voted(uint itemId, uint weight, bool positive); function currentWeight(uint itemId, address addr, bool isPositive) public view returns(uint) { if (isPositive) { return items[itemId].positiveVotes[addr]; } else { return items[itemId].negativeVotes[addr]; } } function calcCost(uint currWeight, uint weight) public pure returns(uint) { if (currWeight > weight) { return weight * weight * voteCost; // cost is always quadratic } else if (currWeight < weight) { // this allows users to save on costs if they are increasing their vote // example: current weight is 3, they want to change it to 5 // this would cost 16x (5 * 5 - 3 * 3) instead of 25x the vote cost return (weight * weight - currWeight * currWeight) * voteCost; } else { return 0; } } function createItem(bytes32 title, string memory imageHash, string memory description) public { uint itemId = itemCount++; Item storage item = items[itemId]; item.owner = msg.sender; item.title = title; item.imageHash = imageHash; item.description = description; emit ItemCreated(itemId); }
currentWeight
and calcCost
are helper functions we will be using later.The function
createItem
is used to publish a new Item object to the ranked list. The item will require a title, IPFS image hash and text description to be set before the object can be created. The current sender is considered the owner of the item.function positiveVote(uint itemId, uint weight) public payable { Item storage item = items[itemId]; require(msg.sender != item.owner); // owners cannot vote on their own items uint currWeight = item.positiveVotes[msg.sender]; if (currWeight == weight) { return; // no need to process further if vote has not changed } uint cost = calcCost(currWeight, weight); require(msg.value >= cost); // msg.value must be enough to cover the cost item.positiveVotes[msg.sender] = weight; item.totalPositiveWeight += weight - currWeight; // weight cannot be both positive and negative simultaneously item.totalNegativeWeight -= item.negativeVotes[msg.sender]; item.negativeVotes[msg.sender] = 0; item.amount += msg.value; // reward creator of item for their contribution emit Voted(itemId, weight, true); }
Users are not able to vote for their own items because this would allow them to claim what they spent and use it to vote again, essentially meaning they could infinitely vote on their own items. Therefore, we need to make sure the sender is not the item owner. Restricting the owner wallet from voting helps with this, though technically they could just create a different address, so this isn't a complete solution to this problem. The value of the transaction must also be enough to cover the cost of voting.
function negativeVote(uint itemId, uint weight) public payable { Item storage item = items[itemId]; require(msg.sender != item.owner); uint currWeight = item.negativeVotes[msg.sender]; if (currWeight == weight) { return; // no need to process further if vote has not changed } uint cost = calcCost(currWeight, weight); require(msg.value >= cost); // msg.value must be enough to cover the cost item.negativeVotes[msg.sender] = weight; item.totalNegativeWeight += weight - currWeight; // weight cannot be both positive and negative simultaneously item.totalPositiveWeight -= item.positiveVotes[msg.sender]; item.positiveVotes[msg.sender] = 0; // distribute voting cost to every item except for this one uint reward = msg.value / (itemCount - 1); for (uint i = 0; i < itemCount; i++) { if (i != itemId) items[i].amount += reward; } emit Voted(itemId, weight, false); }
Negative votes are slightly different in their distribution. Rather than reward the owner for a poor addition to the list, the funds are distributed to every item except for the one being voted on. This acts as a sort of basic income for all participants.
Note that this will reward items whose total voting weight is more negative than positive, which essentially means that even the worst suggestions will still make a partial income from this mechanism. You may want to innovate with this code to fix this issue by only allowing net positive suggestions to have a basic income. To keep things simple, we will not dive into that topic in this tutorial.
function claim(uint itemId) public { Item storage item = items[itemId]; require(msg.sender == item.owner); item.owner.transfer(item.amount); item.amount = 0; } }
This allows the owner of an item to transfer any reward to their wallet.
And there we go! Our smart contract is finished. Now let's learn how to deploy it.
Compiling and deploying with Truffle
We'll need to compile our contracts before we can use them. We can do this with the Truffle CLI tool we installed earlier.
truffle compile
You should get similar output.
You can find the compiled contracts in the
build/contracts
directory.Now we need to migrate (deploy) our contracts to the Matic Mumbai test network. Create a new file called
2_quadratic_voting.js
in the migrations
folder.const QuadraticVoting = artifacts.require("QuadraticVoting") module.exports = function (deployer) { deployer.deploy(QuadraticVoting) }
The Truffle CLI will use this file later to deploy our
QuadraticVoting
contract.Edit the
truffle-config.js
file to add the network.const fs = require("fs") const HDWalletProvider = require("@truffle/hdwallet-provider") const mnemonic = fs.readFileSync(".secret").toString().trim() module.exports = { networks: { development: { host: "localhost", port: 7545, network_id: "*", }, // Matic Mumbai testnet RPC (requires API key) matic: { provider: () => new HDWalletProvider( mnemonic, "https://rpc-mumbai.maticvigil.com/v1/{APP_ID}", ), network_id: 80001, confirmations: 2, timeoutBlocks: 200, skipDryRun: true, }, }, compilers: { solc: { optimizer: { enabled: true, runs: 200, }, }, }, db: { enabled: false, }, }
We'll need to create an account here to have a quota for the RPC. Create an App and replace
{APP_ID}
in the config with the App Id. You can alternatively use DataHub.Make sure to install the HD wallet provider class from Truffle, so we can supply a mnemonic to sign the deployment transaction.
yarn add -D @truffle/hdwallet-provider
In order to publish contracts to the blockchain we will need to pay the gas fees. Get testnet MATIC from the Mumbai faucet by inputting your wallet address. Small warning - due to an issue with the faucet, you may encounter an error the first few times you attempt to confirm the transaction.
Export your private key from MetaMask and paste it in a
.secret
file. This will be used as the mnemonic
variable in the config.Now we can deploy our contract to Polygon.
truffle migrate --network matic
You should see similar output.
Writing tests for the smart contract
Next we will be writing tests for our contract. Tests allow us to ensure our contract code is working as intended in a programmatic way.
Create a file named
quadratic-voting-test.js
in the test
directory.const QuadraticVoting = artifacts.require("QuadraticVoting") contract("QuadraticVoting", (accounts) => { describe("deployment", () => { it("should be a valid contract address", () => QuadraticVoting.deployed() .then((instance) => instance.address) .then((address) => { assert.notEqual(address, null) assert.notEqual(address, 0x0) assert.notEqual(address, "") assert.notEqual(address, undefined) })) }) describe("items", () => { it("should be the correct item data", () => { let instance QuadraticVoting.deployed() .then((i) => (instance = i)) .then(() => instance.createItem( web3.utils.utf8ToHex("Chewbacca"), // title "ipfs_hash", // imageHash "The ultimate furry.", // description ), ) .then(() => instance.itemCount()) .then((count) => assert.equal(count, 1)) // should be 1 item in the registry .then(() => instance.items(0)) .then((item) => { assert.equal(web3.utils.hexToUtf8(item.title), "Chewbacca") assert.equal(item.imageHash, "ipfs_hash") assert.equal(item.description, "The ultimate furry.") }) }) }) })
The Truffle testing suite uses Chai as its library for writing tests. It is already installed for us.
Tests are broken up into two groups:
deployment
and items
. The deployment
tests are used to ensure successful deployment and a valid contract address. The items
tests are used to ensure the correct item data is being published to the blockchain.We need to use
utf8ToHex
and hexToUtf8
because title
is defined as the bytes32
type in our contract.You may write additional tests for the remaining smart contract functions if you wish.
Spin up Ganache to run our tests on a local development blockchain.
truffle test
You should see similar output.
Communicating with the smart contract with Web3
Now we'll be using web3.js to communicate with our smart contracts from JavaScript.
Install the web3 package.
yarn add web3
Create a file at
src/lib/quadratic-voting.js
.import Web3 from "web3" import QuadraticVoting from "../../build/contracts/QuadraticVoting.json" let web3 let contract let accounts let loaded = false // This semicolon is needed or the above statement will cause this to be read as "loaded = false(..)()" ;(async () => { if (window.ethereum) { web3 = new Web3(window.ethereum) await window.ethereum.request({ method: "eth_requestAccounts" }) } else if (window.web3) { web3 = new Web3(window.web3.currentProvider) } else { window.alert( "No compatible wallet detected. Please install the Metamask browser extension to continue.", ) } const networkData = QuadraticVoting.networks["80001"] // Matic network data contract = new web3.eth.Contract(QuadraticVoting.abi, networkData.address) // address of contract we deployed earlier accounts = await web3.eth.getAccounts() loaded = true })()
The anonymous function allows us to asynchronously define the following variables when the web app is loaded:
web3
: An instance of the importedWeb3
class which allows us to interact with the Ethereum blockchain.contract
: An instance of ourQuadraticVoting
contract which allows us to use its methods and events.accounts
: List of our client's Ethereum account addresses.loaded
: Tells our front-end it's safe to call contract functions.
Next we will add some helper functions to
quadratic-voting.js
.export function isReady() { return loaded } export function address() { return accounts[0] } export async function voteCost() { return await contract.methods.voteCost().call() } export async function items(itemId) { const item = await contract.methods.items(itemId).call() if (item) { return { id: itemId, owner: item.owner, amount: item.amount, title: web3.utils.hexToUtf8(item.title), // bytes32 => string imageHash: item.imageHash, description: item.description, positiveWeight: item.totalPositiveWeight, negativeWeight: item.totalNegativeWeight, } } else { return null } } export async function itemCount() { return await contract.methods.itemCount().call() } export async function currentWeight(itemId, isPositive) { return await contract.methods .currentWeight(itemId, address(), isPositive) .call() } export async function calcCost(currWeight, weight) { return await contract.methods.calcCost(currWeight, weight).call() } export async function createItem(title, imageHash, description) { return await contract.methods .createItem( web3.utils.utf8ToHex(title), // string => bytes32 imageHash, description, ) .send({ from: address() }) } export async function positiveVote(itemId, weight, cost) { return await contract.methods .positiveVote(itemId, weight) .send({ from: address(), value: cost }) } export async function negativeVote(itemId, weight, cost) { return await contract.methods .negativeVote(itemId, weight) .send({ from: address(), value: cost }) } export async function claim(itemId) { return await contract.methods.claim(itemId).send({ from: address() }) } export async function rankedItems() { const count = await itemCount() let itemsArr = [] for (let i = 0; i < count; i++) { const item = await items(i) if (item) itemsArr.push(item) } return itemsArr.sort((a, b) => { const netWeightB = b.positiveWeight - b.negativeWeight const netWeightA = a.positiveWeight - a.negativeWeight return netWeightB - netWeightA // sort from greatest to least }) }
These exported functions allow us to serialize/deserialize data to and from our smart contract. They will help simplify our UI code by abstracting over Web3.
contract.methods
is how we access the functions we defined in our smart contract earlier. .call()
allows us to call certain functions offchain while .send()
will create a transaction that needs to be signed with our wallet.rankedItems
creates a list of all published items and sorts it based off of votes, from most positive to most negative.We'll be using these from our Vue components later.
Uploading image files with IPFS
We'll need to add a few things to our
quadratic-voting.js
file to add support for image uploads.To begin, add the IPFS package.
yarn add ipfs-core
Now we will edit the top of our file to import the package.
import Web3 from "web3" import * as IPFS from "ipfs-core" import QuadraticVoting from "../../build/contracts/QuadraticVoting.json" let web3 let contract let accounts let ipfs let loaded = false
We will need to create our IPFS node in the loading function.
accounts = await web3.eth.getAccounts() ipfs = await IPFS.create() loaded = true })()
And then we will define a new exported
uploadFile
function.export async function uploadFile(file) { const { cid } = await ipfs.add(file) return cid // will be used as the imageHash }
That's all for now!
Creating the front-end with Vue.js
It's finally time to create our Vue components and build our app UI.
App
Edit the
src/App.vue
file.<template> <main> <CreateItem /> <RankedList /> </main> </template> <script> import CreateItem from "@/components/CreateItem.vue" import RankedList from "@/components/RankedList.vue" export default { name: "App", components: { CreateItem, RankedList, }, } </script>
This is a simple component containing the item creation form and ranked list of items we will be creating later. It's the root component of our application.
CreateItem
The
CreateItem
component is a form that allows us to fill in data for item creation.Create a file at
src/components/CreateItem.vue
.<template> <!-- .prevent will prevent the page from redirecting --> <form @submit.prevent="submit"> <input type="text" v-model="title" placeholder="Title" required /> <br /> <input type="file" placeholder="Upload image" @input="uploadImage" required /> <br /> <p v-if="imageHash">{{ imageHash }}</p> <textarea v-model="description" placeholder="Description" required /> <br /> <input type="submit" value="Create Item" /> </form> </template> <script> import { uploadFile, createItem } from "@/lib/quadratic-voting" export default { name: "CreateItem", methods: { async uploadImage(e) { const file = e.target.files[0] // get file from oninput event this.imageHash = await uploadFile(file) // upload to IPFS network }, async submit() { await createItem(this.title, this.imageHash, this.description) }, }, data() { return { title: "", imageHash: null, description: "", } }, } </script>
The component will look like this. We will style it later.
When you click on the "Create Item" button you should see MetaMask prompt you to confirm the transaction.
RankedList
The
RankedList
component displays a sorted list of all items previously published to the contract.Create a file at
src/components/RankedList.vue
.<template> <section> <h1>Ranked List</h1> <Item v-for="item in items" :key="item.id" :item="item" /> </section> </template> <script> import Item from "@/components/Item.vue" import { isReady, rankedItems } from "@/lib/quadratic-voting" export default { name: "RankedList", components: { Item, }, data() { return { items: [], } }, created() { // make sure app is started before attempting to retrieve items const wait = async () => { if (isReady()) { this.items = await rankedItems() } else { setTimeout(wait, 100) } } wait() }, } </script>
In the
created
function we wait until the contract and IPFS node are defined, checking every 100ms, before filling the page with list items.Item
We will need to create the
Item
component next so that our RankedList
code will compile.Create a file at
src/components/Item.vue
.<template> <div> <h2>{{ item.title }}</h2> <!-- ipfs.io is the gateway we will be using to serve our image files --> <img :src="`https://ipfs.io/ipfs/${item.imageHash}`" alt="item image" width="640" height="480" /> <p>{{ item.description }}</p> <p>{{ item.owner }}</p> <!-- the "Claim" button is only displayed to the item owner --> <template v-if="address() === item.owner"> <!-- item.amount is in wei but must be translated to gwei --> <p>Amount: {{ item.amount / 1_000_000_000 }} gwei</p> <button @click="claimGwei">Claim</button> </template> <!-- non-owners are shown voting controls --> <template v-else> <button @click="upvote">Up</button> <p>Votes: {{ item.positiveWeight }}</p> <button @click="downvote">Down</button> <p>Votes: {{ item.negativeWeight }}</p> <!-- submit section is only displayed if a user changes their weight --> <div v-if="weight !== startWeight"> <p>Weight: {{ weight }}</p> <p>Cost: {{ cost / 1_000_000_000 }} gwei</p> <button @click="submitVote">Submit Vote</button> </div> </template> </div> </template> <script> import { address, currentWeight, calcCost, positiveVote, negativeVote, claim, } from "@/lib/quadratic-voting" export default { name: "Item", props: ["item"], methods: { address, upvote() { this.weight += 1 this.setCost() }, downvote() { this.weight -= 1 this.setCost() }, async setCost() { if (this.weight === 0) { this.cost = 0 } else { const isPositive = this.weight > 0 const currWeight = await currentWeight(this.item.id, isPositive) this.cost = await calcCost(currWeight, Math.abs(this.weight)) } }, async submitVote() { if (this.weight >= 0) { // submit positive vote if weight is positive await positiveVote(this.item.id, this.weight, this.cost) } else if (this.weight < 0) { // submit negative vote if weight is negative await negativeVote(this.item.id, -this.weight, this.cost) } }, async claimGwei() { await claim(this.item.id) // transfers rewards to owner wallet }, }, data() { return { weight: 0, startWeight: 0, cost: 0, } }, created() { const getWeight = async () => { // calculate the net weight to be used with voting controls const posWeight = await currentWeight(this.item.id, true) const negWeight = await currentWeight(this.item.id, false) this.weight = posWeight - negWeight // keep track of the weight we started with this.startWeight = this.weight } getWeight() }, } </script>
This will by far be our largest and most complex component. Read through the code carefully when implementing it.
Our
RankedList
component should now look something like this depending on the data you posted.If you switch your MetaMask wallet to a different account and reload the page, you should see voting controls instead of the "Claim" button. Both positive and negative votes are displayed.
Interacting with the "Up"/"Down" buttons will reveal a submit section that allows you to quadratically vote on the item.
Notice the
640 gwei
fee, which is calculated from 8 * 8 * 10 gwei
where 10 gwei
is the voteCost
we defined in our contract.Clicking the "Submit Vote" button should allow you to confirm a transaction.
Styling the components with Tailwind CSS
Our last step will be styling our Vue components to make the app look prettier.
Configuration
We'll need the following packages to style our components with Tailwind CSS.
yarn add -D tailwindcss@npm:@tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9 postcss-loader
Create a file named
postcss.config.js
. This allows us to use tailwindcss
as a PostCSS plugin.module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }
Create a file named
tailwind.config.js
. The purge option removes any unused styles from our compiled CSS output when building in production.module.exports = { purge: ["./src/**/*.vue"], darkMode: false, theme: { extend: {}, }, variants: {}, plugins: [], }
Then add the following to
src/App.vue
. Note that this is a global stylesheet.<style lang="postcss" global> @tailwind base; @tailwind components; @tailwind utilities; </style>
Components
Now it's finally time to make our components look like part of a real app.
Edit
App.vue
to add a dark background to our app.<template> <main class="p-10 min-h-screen bg-gray-900"> <CreateItem /> <RankedList /> </main> </template>
Edit
CreateItem.vue
to make our form pop out more. Take note of the changes made to the imageHash
paragraph.<template> <form @submit.prevent="submit" class="bg-gray-300 p-10 w-1/2 mx-auto rounded"> <input type="text" v-model="title" placeholder="Title" required class="px-3 py-1 w-full mb-10" /> <br /> <input type="file" placeholder="Upload image" @input="uploadImage" required class="text-gray-300" /> <br /> <!-- top margin will separate displayed hash from file input when imageHash is defined --> <p class="mb-10 text-gray-300" :class="imageHash ? 'mt-3' : ''"> {{ imageHash || "" }} </p> <textarea v-model="description" placeholder="Description" required class="px-3 py-1 w-full mb-10" /> <br /> <input type="submit" value="Create Item" class=" block mx-auto px-5 py-3 bg-blue-600 hover:bg-blue-500 text-white cursor-pointer " /> </form> </template>
Edit
RankedList.vue
to add spacing between items and style our heading.<template> <section class="flex flex-col items-center gap-10"> <h1 class="text-2xl text-center text-white font-bold mt-10">Ranked List</h1> <Item v-for="item in items" :key="item.id" :item="item" /> </section> </template>
And finally, edit
Item.vue
. The layout of this component will change quite a bit, but the functionality is the same.<template> <div class="bg-gray-300 p-10 rounded flex items-center"> <img :src="`https://ipfs.io/ipfs/${item.imageHash}`" alt="item image" class="w-1/12 mr-10 rounded shadow" /> <div class="mr-5"> <button @click="upvote" class=" text-xl px-4 py-3 border border-b-2 border-black rounded hover:bg-black hover:text-white " > ↑ </button> <p class="text-lg text-center mt-2">{{ item.positiveWeight }}</p> </div> <div class="mr-10"> <button @click="downvote" class=" text-xl px-4 py-3 border border-b-2 border-black rounded hover:bg-black hover:text-white " > ↓ </button> <p class="text-lg text-center mt-2">{{ item.negativeWeight }}</p> </div> <div> <h2 class="text-xl font-bold mb-5">{{ item.title }}</h2> <p class="text-gray-600 mb-5">{{ item.description }}</p> </div> <div v-if="address() === item.owner" class="ml-auto"> <p class="mb-5 text-center"> Amount: {{ item.amount / 1_000_000_000 }} gwei </p> <button @click="claimGwei" class="block mx-auto px-5 py-3 bg-blue-600 hover:bg-blue-500 text-white" > Claim </button> </div> <div v-else-if="weight !== startWeight" class="ml-auto"> <p class="mb-5 text-center">Weight: {{ weight }}</p> <p class="mb-5 text-center">Cost: {{ cost / 1_000_000_000 }} gwei</p> <button @click="submitVote" class="block mx-auto px-5 py-3 bg-blue-600 hover:bg-blue-500 text-white" > Submit Vote </button> </div> </div> </template>
We're done! Our app should now have a much better appearance.
Conclusion
Congratulations! After completing this tutorial, you should now know how to create a smart contract in Solidity, interact with smart contracts using Web3, deploy contracts to the Polygon testnet and build a front-end with Vue/Tailwind. You are now on your way to becoming a full stack dApp developer.
About the author
I'm giraffekey, a free software developer interested in decentralized technologies. Feel free to connect with me on GitHub.
References
- Truffle docs: https://www.trufflesuite.com/docs/truffle/overview
- Polygon (Matic) docs: https://docs.polygon.technology/docs/develop/getting-started
- MetaMask docs: https://docs.metamask.io/guide/
- Web3 docs: https://web3js.readthedocs.io/en/v1.5.2/
- IPFS docs: https://docs.ipfs.io/concepts/what-is-ipfs/