An on-chain payroll system that lets an employer pay employees in a stablecoin, instantly and verifiably on Ethereum.
Explore the docs »
Report Bug
·
Request Feature
Table of Contents
Payroll Smart Contract is a Solidity project exploring how blockchain can be used to manage and execute payroll on-chain. An employer can register employees, fund the contract with a stablecoin, and trigger salary payments to every employee in a single transaction — with every action recorded on-chain and independently verifiable by anyone.
A key design decision: the contract enforces a payroll reserve. When deployed, the employer sets a fixed number of payroll cycles (i_reservedPayrollCycles) that must always remain available in the contract before any surplus can be withdrawn. This value is immutable, set once at deployment and never changeable afterward — so the employer can never quietly drain funds that employees are relying on. This reserve specifically restricts discretionary withdrawal by the owner — it does not block runPayroll() itself, which only requires enough balance to cover the current cycle. Otherwise, funds meant to guarantee future payroll could end up frozen, satisfying neither the reserve's purpose nor the current cycle's obligations.
This project is part of my ongoing Web3 developer portfolio, and also serves as the basis for my bachelor's thesis.
- No off-chain backend or database. Payment history isn't stored in contract state — every payroll run emits a
SalaryPaidevent per employee. Anyone (a frontend, a block explorer, an off-chain indexer) can reconstruct full payment history by reading past events, without the contract needing to maintain redundant storage. runPayroll()scales linearly with employee count (O(n)). Every employee paid in a single run adds real gas cost, since each payment is an individual token transfer. At a large enough employee count, a singlerunPayroll()call could theoretically exceed a block's gas limit. This is also a gas-cost tradeoff, not just a scalability one: in this push model, whoever callsrunPayroll()pays gas for every employee's payment in one transaction. A pull/claim-based model, where each employee withdraws their own accrued salary individually, would distribute both the gas cost and the block-size risk across every employee's own transaction instead of concentrating it entirely on one caller. This is a known, documented limitation for this project's scope. Production systems at scale (Sablier, Superfluid) typically use the pull model for exactly this reason.- Ownership uses a two-step transfer (
Ownable2Step). A single accidentaltransferOwnership()call to a wrong or unreachable address cannot lock the contract, since the new address must explicitly callacceptOwnership()before control actually changes hands. This does not protect against permanent loss of the current owner's private key — see Known Limitations below. runPayroll()is intentionally permissionless, gated by a minimum interval instead ofonlyOwner. Anyone can call it, but only once everyi_payrollIntervalseconds since the last successful run, tracked vias_lastPayrollTimestamp. This prevents an owner from indefinitely withholding payment by simply never calling the function, while the interval guard prevents the same cycle from being paid twice.s_lastPayrollTimestampis updated before the payment loop executes (Checks-Effects-Interactions) — this is what provides reentrancy protection for this specific function, since it's the one function in the contract not restricted byonlyOwner. Every other state-changing function remains owner-gated, which structurally blocks reentrancy there, since no employee address is ever the owner.
This section documents deliberate scope decisions and known tradeoffs, kept here intentionally rather than hidden — useful both as an honest engineering record and as supporting material for the accompanying thesis.
- Single-key owner is a point of failure.
Ownable2Stepprotects against an accidental transfer to the wrong address, but not against permanent loss of the current owner's private key. For real deployment, the owner address should be a multisig (e.g. a Gnosis Safe) rather than a single personal wallet — a code-level fix (Ownable2Step) does not replace an operational one (key custody practice). - The owner can bypass the payroll reserve by self-registering as an employee. Since
runPayroll()pays out based solely on the current employee list ands_totalSalaries, without distinguishing legitimate employees from the owner themselves, an owner could add their own address as an "employee" and extract funds viarunPayroll()thatwithdraw()'s reserve check would otherwise have blocked. This is not unique to smart contracts — any single administrator with full control over both the employee list and payment execution can create "ghost employees" in a traditional payroll system too. Proper mitigation requires separation of duties: a multisig owner, a timelock on employee list changes, or splitting "who can manage employees" from "who can trigger payment" into distinct roles. Out of scope for the current version; the clearest next step for hardening this contract. - No salary proration for employees added mid-cycle. An employee added at any point before
runPayroll()executes receives their full salary for that cycle — the contract does not track how much of the cycle they were actually registered for. This is a deliberate simplification: proration would require storing ajoinedTimestampper employee and calculating a partial amount per person during payroll execution, a real feature addition rather than a quick fix. Full salary regardless of join timing was chosen for this version; proration is a natural v2 candidate. payrollIntervalis a fixed duration in seconds, not calendar-aware. A "monthly" interval is implemented as a flat 30-day duration (2,592,000seconds), not an actual calendar month. Since real months vary from 28 to 31 days, a contract run consistently on a 30-day interval will slowly drift relative to a real calendar's 1st-of-the-month over time. Calendar-aware scheduling would require off-chain coordination or a more complex on-chain date library, out of scope here.- Missed payroll cycles are not paid retroactively. If
runPayroll()isn't called for longer than one interval — say, 60 days pass instead of 30 — the next successful call pays out exactly one cycle's worth, then resetss_lastPayrollTimestampto the current time. The cycle that was skipped in between is never paid; that pay simply disappears rather than accumulating. A correct fix would track how many full intervals have elapsed, paysalary × cyclesElapsedper employee, and advances_lastPayrollTimestampby whole intervals (not toblock.timestamp) to keep the schedule anchored rather than drifting forward every time a call is late. This wasn't built because it compounds two other documented limitations rather than existing in isolation: without proration, an employee added right before a large catch-up run would receive multiple cycles of pay for a fraction of the time worked, and the ghost-employee reserve bypass above becomes more severe if an owner can trigger one large catch-up payment instead of many small ones. Solving this properly means addressing it alongside proration and reserve enforcement, not as a standalone patch. - Interval guard requires waiting the full interval on a live network. Unlike local Foundry tests, which can warp time instantly with
vm.warp, a real deployment has no such shortcut —runPayroll()genuinely cannot be called until real time has passed. See Live Sepolia Deployments below for a separate demo deployment with a shortened interval, used specifically to demonstrate the full payroll cycle without a month-long wait.
Production configuration (30-day interval, 3 reserved cycles — realistic values for actual use):
- Payroll:
0xF9D4069037bAa86E9a145d7d2CaF3feD4528F096 - MockUSDC:
0x2ECB0a2db5bD6E42D9943C3EE9Cf9A4e17a7A18e
Demo configuration (1-day interval — exists purely so the full payroll cycle, including the interval guard, can be tested and demonstrated end-to-end without a real month-long wait):
- Payroll:
0x30cc1644fe776f95CFBf7c62c68cC69e1BDeE5E8 - MockUSDC:
0xE95cb6E641A7a6890DF341A0Fc90a483AB2f52B5
Both are real deployments on Sepolia, not simulations — every transaction is publicly verifiable.
- Clone the repo
git clone https://github.com/Giovidoh/payroll-smart-contract.git
cd payroll-smart-contract- Install dependencies
forge install- Build the project
forge build- Run the test suite
forge testmake install # install dependencies
make deploy-anvil # deploy locally
make deploy-sepolia # deploy to Sepolia (requires SEPOLIA_RPC_URL and ETHERSCAN_API_KEY in .env)The contract owner (the employer) can currently:
- Add a new employee —
addEmployee(address employeeAddress, uint256 salary) - Remove an existing employee —
removeEmployee(address employeeAddress) - Update an existing employee's salary —
updateSalary(address employeeAddress, uint256 newSalary) - View all registered employees —
getAllEmployees() - View the total salaries currently owed —
getTotalSalaries() - Check if an address is a registered employee —
getEmployeeExistence(address employeeAddress) - Deposit stablecoin funds into the contract —
deposit(uint256 amount)(requires anapprove()on the stablecoin beforehand) - Withdraw surplus funds, above the required payroll reserve —
withdraw(uint256 amount) - Check how much is currently available for withdrawal —
getAvailableAmountForWithdrawal() - Transfer ownership safely via a two-step process —
transferOwnership(address)followed by the new owner's ownacceptOwnership()call
Anyone can:
- Trigger a payroll run —
runPayroll()— once everyi_payrollIntervalseconds since the last successful run. Pays every registered employee their salary in a single transaction.
The owner and the concerned employee themselves can:
- View a specific employee's data —
getEmployee(address employeeAddress)
Payment history is available via emitted events (SalaryPaid, PayrollCompleted) rather than a dedicated getter — see Architecture Notes above.
forge testforge test -vvvforge coverageTests are organized by scope:
test/unit/— tests each function in isolation, including its dependencies where necessary (e.g.deposit/withdrawcalling into the mock stablecoin). Covers employee management (addEmployee,removeEmployee,updateSalary,getEmployee), fund management (deposit,withdraw), payroll execution (runPayroll, including the interval guard), and ownership transfer (Ownable2Step) — reverts, state changes, event emissions, access control, and boundary cases throughout.test/integration/— verifies multiple functions working together across a realistic sequence: adding employees, funding the contract, running payroll, updating salaries mid-cycle, removing and adding employees, running payroll again, and withdrawing surplus — with every balance independently checked against hand-calculated expected values at each step.
- Employee management —
addEmployee,removeEmployee(gas-efficient array + mapping storage, swap-and-pop removal) - Unit tests for employee management — 18 tests, full framework coverage (reverts, state changes, events, access control, boundaries)
-
updateSalary— update an existing employee's salary -
getEmployee— viewable by the owner and the employee themselves - Fund management —
deposit,withdraw(ERC-20 stablecoin), with a payroll reserve protecting a fixed number of future payroll cycles from withdrawal -
runPayroll— implemented and fully tested - Integration test suite — full lifecycle scenario, hand-verified
- Two-step ownership transfer (
Ownable2Step) — 4 tests, including proof that acceptance is required and the old owner genuinely loses access - Payroll interval guard —
runPayroll()is permissionless, gated by a minimum interval since the last run - Deployment to Sepolia testnet — production and demo configurations, both verified
- Catch-up payment logic for missed cycles — see Known Limitations
- Salary proration for employees added mid-cycle
See the open issues for a full list of proposed features and known issues.
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
Distributed under the MIT License. See LICENSE.txt for more information.
Giovanni - GitHub - @ICG_reborns - giovidoh@gmail.com
Project Link: https://github.com/Giovidoh/payroll-smart-contract
- Cyfrin Updraft — Solidity & Foundry fundamentals
- OpenZeppelin Contracts
- Best-README-Template