Create a blockchain-based Skill Verification system

Learn how to implement your own skill verification system with a Polygon smart contract
IntermediateReactJavascript1.5 hours
Written by Hardik Agarwal


In this tutorial, you will learn how to build a skill verification system using blockchain from scratch and deploy it on the Polygon Mumbai test network. Before diving into the building part, let's first see why we need a skill verification system using blockchain.


This tutorial assumes that you have some beginner-level programming knowledge with Solidity, web3.js, React.js, and an understanding of basic blockchain principles.


We will need the following things on your PC to start with this tutorial rest of the things we will tell you when they are required.
Note: Check the reference section at the end of this document if you want to know more about these requirements.

Problem Statement

One of the most basic yet most important tasks for every firm is the verification of the candidate's qualifications and experience before hiring. With an increasing number of applying candidates especially in larger firms, verifying candidate qualifications and experience is becoming very time-consuming, and as a result is overlooked many times. This problem of candidates falsifying their information is an increasing concern for the HR department and while conventional skill verification systems are useful, they are still far from perfect to handle this.

How we plan to tackle the problem

Blockchain-based skill verification will be an ideal solution to this problem as it provides a transparent, trustworthy, and independent platform that also reduces the time spent on conducting competency checks. With blockchain, any user can enter their details regarding skills, certifications, and work experience and get those details verified by their respective company coworkers, managers, and team leaders with complete transparency.
Our solution provides every user with a unique id. It will be straightforward to verify the qualifications of any candidate, as each one of their skills will have its own list of linked certifications and endorsements. Skills will also get a verified badge once endorsed by the managers/employers of their current company.
New users and companies shall be cross verified by platforms such as LinkedIn via OAuth to add an additional verification layer. The creation of companies will also require at least two verified manager-level employees along with the other standard details. The users can add a company to their work experience only after getting their joining request approved by the company.


For this implementation, we shall be using React.js and Web3.js for the frontend of the web app and Solidity to create Ethereum compatible smart contracts, to be deployed on the Polygon network. By using the Polygon network, we are relying on the security of the Ethereum network while benefitting from low gas fees and high throughput of Polygon's layer 2 scaling at the same time. This will allow for a much more robust solution by increasing the number of peak allowable simultaneous users while maintaining a viable response time.
Here we are using React.js for the frontend but you can use any frontend framework of your choice.


We will first start with building the contracts using solidity and deploying them locally then we will move to the development of front end with react and connect to our contracts using web3 and at last deploying the contracts on the Polygon Mumbai network and for the deployment of frontend, we will show it using netlify.

Project Environment Setup

Setup project using Create React App

Create and setup the React project in the Decentraskill directory.
npx create-react-app Decentraskill cd Decentraskill

Install and setup required dependencies

Install the required node modules and setup the truffle project in the Decentraskill directory.
npm install -g truffle npm install web3 truffle init .
After following these steps, you should have a folder structure like:

Configure truffle-config.js

Replace the contents of your truffle-config.js file with the following code snippet:
module.exports = { // React only allows importing the built smart contract code (abis) from the src directory contracts_build_directory: path.join(__dirname, 'src/abis'), networks: { development: { host: '', // localhost port: 7545, // default port for ganache dev server network_id: '*', // matches any network id }, }, compilers: { solc: { version: '0.8.1', // solidity version }, }, };

Configure initial migrations

Update the 1_initial_migration.js file with the desired smart contract file name:
const Decentraskill = artifacts.require('Decentraskill'); // Deploys the smart contract "Decentraskill" module.exports = function (deployer) { deployer.deploy(Decentraskill); };

Smart Contracts in Solidity

Let us start by creating the Decentraskill.sol smart contract in the contracts folder. Here we first define the global/storage variables of the smart contracts. The first two lines define the smart contract license type and the compatible Solidity version respectively.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.1; // userid and company id is a unique natural number representing a account globally. contract Decentraskill { company[] public companies; user[] public employees; certificate[] public certifications; endorsment[] public endorsments; skill[] public skills; experience[] public experiences;
For the sign-in/signup process, we plan to use the LinkedIn OAuth as the first layer of security after which we shall map the email id of the user to their current wallet address in the smart contract. So every time the user tries to sign in, the user needs to sign in via the LinkedIn OAuth and also verify their wallet address.
// mapping of account's mail id with account's wallet address mapping(string => address) public email_to_address; // mapping of wallet address with account id mapping(address => uint256) public address_to_id; // mapping of wallet address with bool representing account status (Company/User) mapping(address => bool) public is_company;

Account Structure

Whenever a user signs up, there can be two kinds of accounts: A company account or a user account.
  1. Company Account
The company account shall be maintaining a list of current and previously working employees. This account shall have the permissions to add and remove any of the employees of the company and also promote an employee to the manager level.
struct company { uint256 id; //company id which is the index of id in the global company array string name; address wallet_address; uint256[] current_employees; uint256[] previous_employees; uint256[] requested_employees; }
  1. User Account
A user account contains all the skills, certifications, and work experience of the user. This account can be of a standard employee level or a manager level account. A manager-level account shall have similar privileges as the company account but only over the standard employee accounts. They do not have any authority over those of similar or higher-level accounts.
struct user { uint256 id; uint256 company_id; string name; address wallet_address; bool is_employed; bool is_manager; uint256 num_skill; uint256[] user_skills; uint256[] user_work_experience; } struct experience { string starting_date; string ending_date; string role; bool currently_working; uint256 company_id; bool is_approved; }

Sign Up Process

For the signup function, the contract takes in the user email, name, and account type (user/company). Depending on the account type, we add a new entry to the company global array or the employee's global array and then update the parameters of the newly created object. After the account is created, its id (index of the object in its respective global array) is linked with the email address and account type in the global mappings we have defined above.
Note: You must use the storage keyword for the newly created variable as Solidity does not support implicit conversion of the memory data location to storage data location for user-defined structs.
Note: We use calldata as a storage location for the input string as it results in lower gas fees compared to storage in memory.
function sign_up( string calldata email, string calldata name, string calldata acc_type // account type (Company/User) ) public { // first we check that account does not already exists require( email_to_address[email] == address(0), "error: user already exists!" ); email_to_address[email] = msg.sender; if (strcmp(acc_type, "user")) { // for user account type user storage new_user = employees.push(); // creates a new user and returns the reference to it = name; = employees.length - 1; // give account a unique user id new_user.wallet_address = msg.sender; address_to_id[msg.sender] =; new_user.user_skills = new uint256[](0); new_user.user_work_experience = new uint256[](0); } else { // for company account type company storage new_company = companies.push(); // creates a new company and returns a reference to it = name; = companies.length - 1; // give account a unique company id new_company.wallet_address = msg.sender; new_company.current_employees = new uint256[](0); new_company.previous_employees = new uint256[](0); address_to_id[msg.sender] =; is_company[msg.sender] = true; } }
As Solidity does not have any inbuilt string comparison function of its own, we need to create it on our own. To do this we first convert the string to bytes and then compare the hash of these resulting bytes created by the keccak256 function. These functions are "pure" as they do not view or modify any state variables.
function memcmp(bytes memory a, bytes memory b) internal pure returns (bool) { return (a.length == b.length) && (keccak256(a) == keccak256(b)); // Comapares the two hashes } function strcmp(string memory a, string memory b) // string comparison function internal pure returns (bool) { return memcmp(bytes(a), bytes(b)); }

Login Process

For the login function, the contract simply checks if the wallet address of the account is the same as the wallet address of the person trying to sign in. If the address matches, then it returns the account type (company/user).
Note: We use the view function modifier as the function does not modify the state (any global variables) and only "views" them.
function login(string calldata email) public view returns (string memory) { // checking the function caller's wallet address from global map containing email address mapped to wallet address require( msg.sender == email_to_address[email], "error: incorrect wallet address used for signing in" ); return (is_company[msg.sender]) ? "company" : "user"; // returns account type }

Updating a wallet address

We need to consider that a user might want to change the wallet address linked to their email/user id. To do this, all the user needs to do is just provide the new wallet address while connected to their current/previous wallet address.
function update_wallet_address(string calldata email, address new_address) public { require( email_to_address[email] == msg.sender, "error: function called from incorrect wallet address" ); email_to_address[email] = new_address; uint256 id = address_to_id[msg.sender]; address_to_id[msg.sender] = 0; address_to_id[new_address] = id; }

Skill Verification

Every single skill of the user shall be linked to the list of endorsements and certifications for that particular skill. These skills will only be marked as verified when a manager-level account of their current or previous companies endorse it. So whenever a potential employer visits their profile they will get a complete list of skills from which the verified ones can be easily located. These skills can be endorsed by any user and a distinctive tag shall be shown for the endorsements made by the user's coworkers and managers. These endorsements shall include a personalized review of the user's skill thus reducing the number of spam endorsements.
struct certificate { string url; string issue_date; string valid_till; string name; uint256 id; string issuer; } struct endorsment { uint256 endorser_id; string date; string comment; } struct skill { uint256 id; string name; bool verified; uint256[] skill_certifications; uint256[] skill_endorsements; }
For the functions used in the creation or updating of user data, only the linked user should be able to call them. To do this we create function modifiers that will allow us to reuse the necessary require statements in multiple functions, thus avoiding repetition of the same code.
modifier verifiedUser(uint256 user_id) { require(user_id == address_to_id[msg.sender]); _; }
For adding an experience to a particular user, the add_experiance function will take the user's id, employment starting date, and ending date, and employer id i.e company id. This function creates a new object in the experiences global array and adds its id in the user's user_work_experience array and the company's requested_employees array.
function add_experience( uint256 user_id, string calldata starting_date, string calldata ending_date, uint256 company_id ) public verifiedUser(user_id) { experience storage new_experience = experiences.push(); new_experience.company_id = company_id; new_experience.currently_working = false; new_experience.is_approved = false; new_experience.starting_date = starting_date; new_experience.role = role; new_experience.ending_date = ending_date; employees[user_id].user_work_experience.push(experiences.length - 1); companies[company_id].requested_employees.push(experiences.length - 1); }
For approving experience, the approve_experience function will take the experience id which is an id from the global experiences array, and a company id. First, the function will check that the person calling the function has the manager role in the given company, then it will make the is_approved boolean in the experiences list true.
function approve_experience(uint256 exp_id, uint256 company_id) public { require( (is_company[msg.sender] && companies[address_to_id[msg.sender]].id == experiences[exp_id].company_id) || (employees[address_to_id[msg.sender]].is_manager && employees[address_to_id[msg.sender]].company_id == experiences[exp_id].company_id), "error: approver should be the company account or a manager of the required company" ); uint256 i; experiences[exp_id].is_approved = true; for (i = 0; i < companies[company_id].requested_employees.length; i++) { if (companies[company_id].requested_employees[i] == exp_id) { companies[company_id].requested_employees[i] = 0; break; } } for (i = 0; i < companies[company_id].current_employees.length; i++) { if (companies[company_id].current_employees[i] == 0) { companies[company_id].requested_employees[i] = exp_id; break; } } if (i == companies[company_id].current_employees.length) companies[company_id].current_employees.push(exp_id); }
Now let's say an employee no longer works at a particular company - to remove the employee from the company's employee list we have two options:
  • Shift the list after removing the employee from the particular position. This method will be costly as it will require paying more gas fees.
  • An alternative is to change the employee id value to store a dummy user id in place, which can later be reused to store a new employee in that list. For this, we made a dummy user profile in the constructor which can be reused after it has been initialized (remember the constructor is called once when a Solidity smart contract is deployed).
constructor() { user storage dummy_user = employees.push(); = "dummy"; dummy_user.wallet_address = msg.sender; = 0; dummy_user.user_skills = new uint256[](0); dummy_user.user_work_experience = new uint256[](0); }
To approve a manager, the function approve_manager will take the employee id as input and verify that the account calling the function has a "company" account type. It will then make sure that this employee id is present in the company's "current employees" list. If these checks pass, it will give that employee a manager tag by setting its is_manager boolean to true.
function approve_manager(uint256 employee_id) public { require(is_company[msg.sender], "error: sender not a company account"); require( employees[employee_id].company_id == address_to_id[msg.sender], "error: user not of the same company" ); require( !(employees[employee_id].is_manager), "error: user is already a manager" ); employees[employee_id].is_manager = true; }
To add to their list of skills, a user will call the add_skill function to push the input skill into the skills list.
function add_skill(uint256 userid, string calldata skill_name) public verifiedUser(userid) { // the modifier that we created above skill storage new_skill = skills.push(); employees[userid].user_skills.push(skills.length - 1); = skill_name; new_skill.verified = false; new_skill.skill_certifications = new uint256[](0); new_skill.skill_endorsements = new uint256[](0); }
Similarly, we will make the add certifications function.
function add_certification( uint256 user_id, string calldata url, string calldata issue_date, string calldata valid_till, string calldata name, string calldata issuer, uint256 linked_skill_id ) public verifiedUser(user_id) { certificate storage new_certificate = certifications.push(); new_certificate.url = url; new_certificate.issue_date = issue_date; new_certificate.valid_till = valid_till; = name; = certifications.length - 1; new_certificate.issuer = issuer; skills[linked_skill_id].skill_certifications.push(; }
The endorse_skill function can be called by a manager, coworker or any user. To endorse someone, the endorsee must give a personalized comment about the person, this will help us in spam reductions of endorsements. If the endorsee is a manager in the user's current company this will also make the user's skill verified.
function endorse_skill( uint256 user_id, uint256 skill_id, string calldata endorsing_date, string calldata comment ) public { endorsment storage new_endorsemnt = endorsments.push(); new_endorsemnt.endorser_id = address_to_id[msg.sender]; new_endorsemnt.comment = comment; = endorsing_date; skills[skill_id].skill_endorsements.push(endorsments.length - 1); if (employees[address_to_id[msg.sender]].is_manager) { if ( employees[address_to_id[msg.sender]].company_id == employees[user_id].company_id ) { skills[skill_id].verified = true; } } }

Connecting frontend with smart contracts using web3

To connect the smart contract with the React.js frontend, we are going to be using Web3.js. We shall be storing all the important details which shall be reused in various components in a react state variable using the useState hook to persist object across rerenders.
import React, { useState, useEffect } from 'react'; import Web3 from 'web3'; import SmartContract from '../abis/Decentraskill.json'; const App = () => { const [state, setState] = useState({ web3: null, contract: null, email: '', account: '', accountId: '', signedIn: false, loaded: false, }); // ... };
We first need to initialize this state variable using the initWeb3 function. It will first check if the web3 object is injected by Metamask, and then use it to initialize a web3 instance. Using this instance, we can get the connected network info and get the correct smart contract ABIs. Then all of this data will be updated in the state variable with the useState React hook.
const initWeb3 = async () => { if (window.ethereum) { await window.ethereum.request({ method: 'eth_requestAccounts' }); try { const web3 = new Web3(window.ethereum); const account = (await web3.eth.getAccounts())[0]; const netId = await; const address = SmartContract.networks[netId].address; const contract = new web3.eth.Contract(SmartContract.abi, address); const accountId = await contract.methods.address_to_id(account).call(); setState({ ...state, web3, account, contract, accountId, loaded: true, }); console.log('setup complete'); } catch (e) { alert(e); } } else { alert('web3 not detected'); } };
Function to login the user
const login = async () => { try { const accountType = await state.contract.methods.login({ from: state.account, }); console.log('account type:', accountType); setState({ ...state, signedIn: true }); } catch (e) { console.error(e); } };
Function to sign up the user
const signUp = async () => { try { await state.contract.methods .sign_up(, 'name', 'user') .send({ from: state.account }); alert('signed up'); } catch (e) { console.error(e); } };
Function to request adding user to company
const requestCompany = async (startDate, endDate, role, companyId) => { try { await state.contract.methods.add_experience( state.accountId, startDate, endDate, role, companyId ); } catch (e) { console.error(e); } };
Function to approve an employee into the company
const approveEmployee = async (experienceId, companyId) => { try { await state.contract.methods.approve_experience(experienceId, companyId); } catch (e) { console.error(e); } };
Function to update the linked wallet address
const updateWallet = async (newAddress) => { try { await state.contract.methods.update_wallet_address(, newAddress); } catch (e) { console.error(e); } };
Function to approve a user as a manager
const approveManager = async (empId) => { try { await state.contract.methods.approve_manager(empId); } catch (e) { console.error(e); } };
Function to add a certificate
const addCertificate = async ( certUrl, issueDate, validTill, certName, issuer, linkedSkill ) => { try { await state.contract.methods.add_certification( state.accountId, certUrl, issueDate, validTill, certName, issuer, linkedSkill ); } catch (e) { console.error(e); } };
Function to add a skill
const addSkill = async (skillName) => { try { await state.contract.methods.add_skill(state.accountId, skillName); } catch (e) { console.error(e); } };
Function to endorse a skill
const endorseSkill = async (empId, skillId, comment) => { const date = new Date(); try { await state.contract.methods.endorse_skill( empId, skillId, `${date.getMonth()} ${date.getFullYear()}`, comment ); } catch (e) { console.error(e); } };

Deploying smart contacts

To a local network

To deploy the smart contracts locally, we need to check our truffle.config.js to make sure that we have the same port from ganache (the default is port 8545) in development and the root of our truffle project is set properly.
contracts_build_directory: path.join(__dirname, 'src/abis'), development: { host: '', // Localhost (default: none) port: 8545, // Standard Ethereum port (default: none) network_id: '*', // Any network (default: none) },
Make sure that ganache is running, then run the following commands to compile and deploy the smart contracts to the local development network.
truffle compile truffle migrate
Note: While compiling the smart contract, you may get an error something like this:
Compiler Error: Stack too deep when compiling inline assembly: Variable headStart is 1 slot(s) too deep inside the stack.
To resolve this error, you need to change the storage type of some input/output function parameters from calldata to memory. Read this article to know more.

To the Mumbai testnet

To deploy smart contracts in the Polygon Mumbai network we will use the services of the DataHub platform. In DataHub login using your email account, select Polygon from the available protocols and get your private RPC url.
Note: The user requires a unique API key to access their private DataHub URL.
While deploying to an actual network instead of the development network, we need to connect to our metamask account (using HDWalletProvider) to pay for the gas fees for deploying the contract.
This HDWalletProvider takes in 2 arguments:
URL: The RPC URL for connecting to the network. Although you can use a public RPC URL, it is recommended that you use private RPCs (from DataHub or Infura).
mnemonic: This is the secret recovery phrase of your metamask wallet that you can find under Advanced Settings in the security and privacy section.
Put both URL and mnemonic into a dotfile (.env) as the values of REACT_APP_POLYGON_MUMBAI_RPC_URL and REACT_APP_MNEMONIC respectively. Also remember to add the .env filename to your .gitignore file so that you won't expose your secrets accidentally.
const path = require('path'); require('dotenv').config(); const HDWalletProvider = require('@truffle/hdwallet-provider'); const url = process.env.REACT_APP_POLYGON_MUMBAI_RPC_URL; const mnemonic = process.env.REACT_APP_MNEMONIC; module.exports = { contracts_build_directory: path.join(__dirname, 'src/abis'), networks: { development: { host: '', // Localhost (default: none) port: 8545, // Standard Ethereum port (default: none) network_id: '*', // Any network (default: none) }, ganache: { host: '', port: 7545, network_id: '', }, matic: { provider: () => new HDWalletProvider(mnemonic, url), network_id: 80001, confirmations: 2, timeoutBlocks: 200, skipDryRun: true, }, }, compilers: { solc: { version: '0.8.1', optimizer: { enabled: true, runs: 200, }, }, }, plugins: ['truffle-plugin-verify'], };
Faucet: You will require some MATIC tokens in your Metamask wallet to pay the gas fees to deploy a contract on Mumbai. Obtain MATIC tokens from the official Polygon Faucet site:
Now, you are all set to run the deployment and get the contracts on to the Polygon network. Run the command:
truffle migrate --network matic

Frontend using Reactjs

So far we have successfully set up smart contracts and deployed them on the Polygon network and connected with our frontend using web3.js. The only thing we have left to do is make some great user interface screens. You can use any of your favorite frameworks for this part. We are going to use React. We are not going to be explaining this part as this is a bit out of scope for this article and there are various great resources out there about React. Still, If you come across some problems, we are providing you with the GitHub repository link (see below) and the wireframes for it, and if the problem still persists find us in the author's section.

Deploying frontend

For Deploying the frontend you can use any service of your choice. For React you can refer to the Netlify for React docs in the reference section.


Congratulations! After completing this tutorial, you should have a good understanding of how to create a dApp for a blockchain-based skill verification system and how to deploy it on Polygon.

Next Steps

Awesome guys, you have finally created a blockchain-based skill verification system on your own but you must not stop now. There is always room for improvements and innovation. following are some features that you can add to this platform of yours to make it better.
  1. All in One Recruitment Platform.
    • Job Listings
    • Salary insights to potential job finders.
    • AI-based personalized job recommendations to those who are Open to work.
  2. Skill Proficiency Test.
  3. Skill Rating System
    • Every skill has a score out of 10
    • At least one manager level endorsement results in a +3 score
    • At least one non-manager endorsement results in a +1 score
    • At least one certification results in a +2 score
    • Proficiency test results in up to +4 score depending on test results
  4. AI-Based HR System integration:
    • Automation of addition and removal of employees via company database.
    • Anonymous company review system for employees to give the company an understanding of the flaws in their departments.

About the Authors

Hardik Agarwal:
I am a tech-savvy pre-final year CSE student from India I am passionate about web dev and blockchain technologies. Feel free to connect with me on LinkedIn
Suryashankar Das:
I am a full-stack web developer and a blockchain enthusiast. I love exploring the latest technologies. Feel free to check out my profile at and connect with me on Twitter and LinkedIn.


Table of Contents