Human Resources, Payment Service Provider - Smart Contract: 4th year Blockchain coursework
A Smart Contract that implements a payment system that can register and terminate employees, with guarded privileges for the HR Manager, and automatically accumulates the wages of each employee, which an employee can withdraw at any time, in either USDC or ETH. Note that the contract is expected to come pre-loaded with sufficient USDC to make any relevant payments.
Chainlink was used as the Oracle for USDC -> WETH exchanges, and Uniswap was used as the AMM.
For this to matter the TEST environment variable must be set!
Make sure to set the following environment variables:
TEST- set this to something, anything (just checking it's not unset)
- you can
unset TESTif you want to deploy
TEST_FORK_URL- If you're testing, this should be point to the local anvil instance
- e.g.
http://localhost:8545
RPC_URL- the URL of the OP Mainnet RPC being used
- e.g.
https://mainnet.optimism.io/
OP_ADDR- The Optimism address you'll be deploying the contract from
PRIVATE_KEY- Your wallet's private key
ETHERSCAN_API_KEY- Your API key for Etherscan
- This is used to return the deployed contract address
Unless you already have a local anvil instance running, forge test will fail due to vm.createSelectFork not being able to access that RPC URL.
If you do, feel free to run forge create ... (to deploy the contract) and forge test (to run the tests), without needing to bother with the test script.
One thing to clarify beforehand is the ambiguous usage of the words registered, active, etc. in the spec. This is how I think it makes the most sense and the convention I've used in my code:
Registration State:
Unregistered -> Registered
Activity State (only for Registered):
Active <-> Unactive (Terminated)
So, an Employee is registered the moment they're added to the system, and currently there's no way to unregister an Employee (bad for GDPR, were this real). An Employee is automatically active when registered, but can be terminated (de-activated) and re-registered (re-activated) at any point, so long as they are in a valid state to have that done.
The asymmetry in the implementation of registerEmployee and terminateEmployee is the result of following the given specifications with this model in mind.
This allows me to store all the important information relating to an Employee in one place, so that data isn't scattered and paired with the mapping, this allows me to easily access all of an Employee's data from just their address.
struct Employee {
address emplAddr; // redundancy and will be 0 if uninitialised
uint256 preTerminationSalary; // keep track of accrued salary
uint256 weeklyUsdSalary; // to calculate their salary
uint256 lastWithdrawal; // starting point for new calculation
uint256 employedSince; // just for my records
uint256 terminatedAt; // also keeps track of who's still active
bool wantsETH; // track their desired currency
}mapping(address => Employee) public employees;This mapping just allows me to access an Employee's data struct using their address and keeps it in storage so that the data and all changes are stored on chain (for security and permanence).
Just a simple constructor, because the only thing I need to set is the address of the hrManager; I took this to be the deployer of the contract, because it makes sense that the hrManager should be an EOA, who can interact with this contract later to make the changes to employment that they want.
I have onlyManager and onlyEmployee modifiers that just restrict the usage of certain functions to the groups that should be able to use them. Checking the address against known valid users of that function, then permitting access or reverting with NotAuthorized errors.
This wasn't strictly necessary, as I tried to implement re-entrancy safe functions, but for the learning experience, and just in case, this modifier uses a lock (or mutex), which is locked on entry into a function and unlocked on exit.
This also prevents someone from using any other guarded function while still inside a guarded function, but I can't think of a valid reason that, for example, an employee would need to switch currencies while withdrawing their salary (especially because they're forced to withdraw the currency in their previous desired currency, anyways).
Can only be run by the manager.
This implements the interface given in IHumanResources.sol.
First, ensures that the employee that is trying to be registered is either entirely unregistered or is in the terminated state (else revert with EmployeeAlreadyRegistered).
If they're unregistered, a new (default) struct needs to be created for them. If they're registered but not active, then their existing struct can just have its fields updated to reset some values and update others.
Then increment the activeEmployeeCount, because that beats iterating through a map each time. And emit the corresponding event.
Can only be run by the manager.
This implements the interface given in IHumanResources.sol.
First, ensures that the employee we're trying to terminate is actually registered (else revert with EmployeeNotRegistered).
If they're currently active, they need to be de-activated/terminated, so we:
- set their
terminatedAttime to now - calculate their accumulated, unclaimed salary and make a note of that
- set their salary to zero (just in case a recalculation happens)
- making a note of their accrued salary counts as a withdrawal, so set that
- since next time a withdrawal is made, this amount is automatically added
- decrement the
activeEmployeeCount, since this employee is de-activated now
Finally, as long as the employee is valid/registered, we want the EmployeeTerminated event to be terminated.
Can only be run by an Employee.
This implements the interface given in IHumanResources.sol.
This is just a simple wrapper using the provided signature, that allows for external calls and has a re-entrancy guard added, since this is a highly-desirable function to attack.
The real logic is inside transferSalary, into which the desired recipient is passed, which is fine, because transferSalary is a private function that can't be called externally.
This function takes an employees address and transfers that employee's outstanding salary to them, in their desired currency. We have already established that they must be an employee because this is only called by withdrawSalary and switchCurrency, both of which use the onlyEmployee modifier.
An employee is loaded into memory (since we're about to do a lot of reads), then that employee's outstanding salary is calculated in USD. Note that if an Employee's salary is 0, the ETH functions are skipped over and the salaryInETH is just set to 0, this is because:
- It's unnecessary to spend extra gas accessing Oracle and AMM for 0 -> 0
- Uniswap also breaks if you try to swap 0 of a token for something
The inUSDC function just scales that down, since we're assuming 1 USD = 1 USDC, and USDC uses 6 decimals whereas we're using 18 decimals for USD.
The value of the salary in USDC is needed either way.
If the employee wants their salary in ETH, the inETH function is then called to get the expected amount of ETH to be received, before calling swapForETH to make that swap from USDC to ETH.
Once that's been calculated, the Employee's values for outstanding salary and timestamp of last withdrawal are reset, to keep track of the fact that this has been paid. This happens before payment to ensure re-entrancy safety.
Then depending on the Employee's preferences, sendETH/sendUSDC is called, which transfers them their salary. If this call fails, the function reverts, causing the changes in the values to be nullified, because if this transaction reverts, those changes are never written to the chain (i.e. storage), which automatically takes care of undoing the value reset from the previous step.
Finally, if all goes well, the SalaryWithdrawn event is emitted.
This uses Chainlink's AggregatorV3 and the address of the ETH_USD price feed, since we're assuming 1 USD = 1 USDC and WETH is pegged to ETH, so this gives the correct rate for this our case.
It gets the rate data from the latest round, the decimals used by that feed for robustness to changes (and because that's what the provided tests did), and calcuates the ideal amount of ETH one can get for the amount of USD owed to the employee.
This uses's Uniswap's SwapRouter (V3), where I'm just passing the address of that contract on the chain.
After checking that the contract has sufficient funds to make the swap, the uniSwapRouter is approved to take that the amount of USDC from this contract that is to be paid to the employee.
UniSwap's ExactInputSingle interface is then used to make the swap, where details are filled into a struct:
- tokens that are being traded
- a flat 3000 pool fee, as that's what I see most people using (0.3% right?)
- give the resulting WETH to this contract
- make it happen now with the amount of USDC available for the employee
- only accept 2% slippage
Then the swap is made, the event is emitted, and the WETH is withdrawn (using the WETH interface) as ETH into this contract.
The actual amount of WETH received is returned to the caller so that they know how much was actually received to pay the employee with.
First the contract is checked, to ensure it has sufficient funds in the relevant currency to make the transfer.
Then an ERC20 transfer is made for USDC, or a call with a value key is made for ETH.
Both of those functions return a success boolean one way or another, which is returned up to the caller to handle.
Can only be run by an Employee.
This implements the interface given in IHumanResources.sol.
This is also just a loose wrapper around transferSalary, except that it also toggles whether or not the employee wants ETH, and emits an event to signal the switching of the currency.
Note that transferring the salary is done first, as the spec requested that the salary withdrawn be withdrawn in the previously desired currency.
My guess is that this makes it easier to test, since you can switch currency and calculate precisely (within a local VM) how much salary has been accrued in that new currency, since the switch, if the switch forces a clearout of previous funds.
Can only be run by anyone.
This implement the interfaces given in IHumanResources.sol.
This effectively just wraps the calculateSalaryUSD function, using inETH or inUSDC to output the corresponding value based on the Employees preferred currency.
This just uses one simple equation based on an employees details to calculate the amount of salary that they're owed in USD:
-
$s_p$ = salary accrued as a result of terminations -
$t_1$ = current timestamp -
$t_0$ = timestamp of previous withdrawal -
$s_w$ = weekly salary in USD -
$1_w$ = number of seconds in a week
Can only be run by anyone.
These implement the interfaces given in IHumanResources.sol.
hrManager, getActiveEmployeeCount, and getEmployeeInfo are all very simple views that just return an existing internal attribute of the contract.
hrManager- returns the value ofhrManagerAddr- which is set immutably at deployment
getActiveEmployeeCount- returns value ofactiveEmployeeCount- which is constantly updated by the relevant functions
getEmployeeInfo- returnsweeklyUsdSalary,employedSince, andterminatedAt- these are all just elements of an Employee's struct
- this also just returns a tuple of 0s if the provided address is invalid
You may notice that little effort has been put into protecting the contract against malicious transactions made by the hrManager themselves.
My reasoning for this is very simply that the hrManager will have access to registerEmployee, wherein the hrManager could register themselves (another address, for example) as an employee, and give themselves an arbitrarily high weeklyUsdSalary, which they can validly withdraw anytime.
With this loophole being a part of the specifications for the project, there isn't much of a reason to worry about securing the rest of the contract against the hrManager, themselves, especially because they're the ones deploying it and have little incentive to mess it up (also because the USDC in the contract are likely to have been funded by them...).
In retrospect, I think it might have been a bit overkill to have added re-entrancy guards and made transferSalary re-entrancy safe by splitting the if-block.
My justification for this is that it would be better to have 2 layers of protection than to rely on the one, since I'm the one that coded this contract and know that transferSalary isn't called by anything other than REG-implementing functions, but were this an active codebase, it's entirely possible that transferSalary could be used elsewhere, in which case it's better to be safe than sorry.
Should you go back through the commit history, you'll find that I attempted to follow a TDD approach to this project, which I think I was doing a decent job of at the beginning, but as time went on and the deadline approached, that kind of went out the window in favour of faster development.