From c1256f0e1b96e78666fbf90d39559eef4b3970b2 Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Thu, 4 Apr 2019 23:18:17 -0300 Subject: [PATCH] Payroll: Polish payroll app (#744) * Fix payment overflow attack Remove MAX_ACCRUED_VALUE. Set amount to max int in case of overflow. * Revert preventing _addAccruedValue from reverting * Prevent owed amount in _paytokens from overflowing * Improve `assertThrow` to support promises as well * Split accrued value payments from regular payroll * Payroll: Use `uint256` when possible * Payroll: Move TODO comment to issue (see #742) * Payroll: Move auth to be the first modifier * Payroll: Keep forwarding functions together * Payroll: Include employee's enddate within their active period * Payroll: Add existence checks protecting getters * Payroll: Cleanup empty lines * Payroll: Polish initialize test file * Payroll: Polish add/remove employees test file * Payroll: Polish forward test file * Payroll: Polish allowed tokens test file * Payroll: Polish price feed test file * Payroll: Polish accrued value test file * Payroll: Polish modify employee test file * Payroll: Improve reimbursements tests * Payroll: Fix partial payday computation * Payroll: Polish payday test file * Payroll: Optimize employee removal costs * Payroll: Unify test files * Payroll: Remove unused mock contracts * Payroll: Handle last payroll date overflow * Payroll: Polish and add missing inline documentation * Payroll: Add missing test cases * Payroll: Sanity-check last payroll date for partial payrolls * Payroll: Fix inline doc wording Co-Authored-By: facuspagnuolo * Payroll: Fix mock timestamp helpers * Payroll: Re-add migrations contract * Payroll: Fix payroll date casting * Payroll: Split tests into separate files * Payroll: Remove name argument to add employees * Payroll: Contract improvements * Payroll: Tests improvements --- future-apps/payroll/README.md | 5 +- future-apps/payroll/contracts/Payroll.sol | 522 ++++---- .../payroll/contracts/test/TestImports.sol | 8 +- .../test/mocks/EtherTokenConstantMock.sol | 8 - .../contracts/test/mocks/PayrollMock.sol | 24 +- .../test/mocks/{feed => }/PriceFeedMock.sol | 0 .../payroll/contracts/test/mocks/Zombie.sol | 17 - .../test/mocks/feed/PriceFeedFailMock.sol | 22 - .../contracts/Payroll_add_employee.test.js | 266 ++++ .../contracts/Payroll_allowed_tokens.test.js | 163 +++ .../test/contracts/Payroll_forwarding.test.js | 158 +++ .../test/contracts/Payroll_gas_costs.test.js | 73 ++ .../contracts/Payroll_get_employee.test.js | 113 ++ .../test/contracts/Payroll_initialize.test.js | 72 ++ .../contracts/Payroll_modify_employee.test.js | 223 ++++ .../test/contracts/Payroll_payday.test.js | 1103 +++++++++++++++++ .../contracts/Payroll_reimbursements.test.js | 693 +++++++++++ .../test/contracts/Payroll_settings.test.js | 148 +++ .../Payroll_terminate_employee.test.js | 323 +++++ .../Payroll_token_allocations.test.js | 319 +++++ future-apps/payroll/test/helpers.js | 112 -- future-apps/payroll/test/helpers/events.js | 9 + future-apps/payroll/test/helpers/numbers.js | 20 + future-apps/payroll/test/helpers/setup.js | 107 ++ .../payroll/test/payroll_accrued_value.js | 521 -------- .../test/payroll_add_remove_employees.js | 221 ---- .../payroll/test/payroll_allowed_tokens.js | 94 -- future-apps/payroll/test/payroll_forward.js | 90 -- future-apps/payroll/test/payroll_gascosts.js | 85 -- .../payroll/test/payroll_initialize.js | 96 -- .../payroll/test/payroll_modify_employees.js | 146 --- future-apps/payroll/test/payroll_no_init.js | 115 -- future-apps/payroll/test/payroll_payday.js | 299 ----- future-apps/payroll/test/payroll_pricefeed.js | 118 -- shared/test-helpers/assertThrow.js | 2 +- 35 files changed, 4073 insertions(+), 2222 deletions(-) delete mode 100644 future-apps/payroll/contracts/test/mocks/EtherTokenConstantMock.sol rename future-apps/payroll/contracts/test/mocks/{feed => }/PriceFeedMock.sol (100%) delete mode 100644 future-apps/payroll/contracts/test/mocks/Zombie.sol delete mode 100644 future-apps/payroll/contracts/test/mocks/feed/PriceFeedFailMock.sol create mode 100644 future-apps/payroll/test/contracts/Payroll_add_employee.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_allowed_tokens.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_forwarding.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_gas_costs.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_get_employee.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_initialize.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_modify_employee.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_payday.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_reimbursements.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_settings.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_terminate_employee.test.js create mode 100644 future-apps/payroll/test/contracts/Payroll_token_allocations.test.js delete mode 100644 future-apps/payroll/test/helpers.js create mode 100644 future-apps/payroll/test/helpers/events.js create mode 100644 future-apps/payroll/test/helpers/numbers.js create mode 100644 future-apps/payroll/test/helpers/setup.js delete mode 100644 future-apps/payroll/test/payroll_accrued_value.js delete mode 100644 future-apps/payroll/test/payroll_add_remove_employees.js delete mode 100644 future-apps/payroll/test/payroll_allowed_tokens.js delete mode 100644 future-apps/payroll/test/payroll_forward.js delete mode 100644 future-apps/payroll/test/payroll_gascosts.js delete mode 100644 future-apps/payroll/test/payroll_initialize.js delete mode 100644 future-apps/payroll/test/payroll_modify_employees.js delete mode 100644 future-apps/payroll/test/payroll_no_init.js delete mode 100644 future-apps/payroll/test/payroll_payday.js delete mode 100644 future-apps/payroll/test/payroll_pricefeed.js diff --git a/future-apps/payroll/README.md b/future-apps/payroll/README.md index 5fd9c2bee9..1647063ebf 100644 --- a/future-apps/payroll/README.md +++ b/future-apps/payroll/README.md @@ -29,9 +29,8 @@ Set the exchange rate for an allowed token against the Payroll denomination toke #### Add employee Three options can be used: ``` -payroll.addEmployee(address accountAddress, uint256 initialYearlyDenominationSalary) -payroll.addEmployeeWithName(address accountAddress, uint256 initialYearlyDenominationSalary, string name) -payroll.addEmployeeWithNameAndStartDate(address accountAddress, uint256 initialYearlyDenominationSalary, string name, uint256 startDate) +payroll.addEmployee(address accountAddress, uint256 initialYearlyDenominationSalary, string role, uint256 startDate) +payroll.addEmployeeNow(address accountAddress, uint256 initialYearlyDenominationSalary, string role) ``` Add employee to the organization. Start date is used as the initial payment day. If it's not provided, the date of the transaction will be used. It needs `ADD_EMPLOYEE_ROLE`. diff --git a/future-apps/payroll/contracts/Payroll.sol b/future-apps/payroll/contracts/Payroll.sol index 8a67aa0f92..bf3aff4442 100644 --- a/future-apps/payroll/contracts/Payroll.sol +++ b/future-apps/payroll/contracts/Payroll.sol @@ -9,9 +9,9 @@ import "@aragon/os/contracts/common/IForwarder.sol"; import "@aragon/os/contracts/lib/math/SafeMath.sol"; import "@aragon/os/contracts/lib/math/SafeMath64.sol"; import "@aragon/os/contracts/lib/math/SafeMath8.sol"; +import "@aragon/os/contracts/common/Uint256Helpers.sol"; import "@aragon/ppf-contracts/contracts/IFeed.sol"; - import "@aragon/apps-finance/contracts/Finance.sol"; @@ -21,7 +21,6 @@ import "@aragon/apps-finance/contracts/Finance.sol"; contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { using SafeMath for uint256; using SafeMath64 for uint64; - using SafeMath8 for uint8; bytes32 constant public ADD_EMPLOYEE_ROLE = keccak256("ADD_EMPLOYEE_ROLE"); bytes32 constant public TERMINATE_EMPLOYEE_ROLE = keccak256("TERMINATE_EMPLOYEE_ROLE"); @@ -32,10 +31,11 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { bytes32 constant public MODIFY_RATE_EXPIRY_ROLE = keccak256("MODIFY_RATE_EXPIRY_ROLE"); uint128 internal constant ONE = 10 ** 18; // 10^18 is considered 1 in the price feed to allow for decimal calculations + uint256 internal constant MAX_ALLOWED_TOKENS = 20; // for loop in `payday()` uses ~270k gas per token uint256 internal constant MAX_UINT256 = uint256(-1); uint64 internal constant MAX_UINT64 = uint64(-1); - uint8 internal constant MAX_ALLOWED_TOKENS = 20; // for loop in `payday()` uses ~260k gas per available token + string private constant ERROR_EMPLOYEE_DOESNT_EXIST = "PAYROLL_EMPLOYEE_DOESNT_EXIST"; string private constant ERROR_NON_ACTIVE_EMPLOYEE = "PAYROLL_NON_ACTIVE_EMPLOYEE"; string private constant ERROR_EMPLOYEE_DOES_NOT_MATCH = "PAYROLL_EMPLOYEE_DOES_NOT_MATCH"; string private constant ERROR_FINANCE_NOT_CONTRACT = "PAYROLL_FINANCE_NOT_CONTRACT"; @@ -53,10 +53,12 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { string private constant ERROR_EXPIRY_TIME_TOO_SHORT = "PAYROLL_EXPIRY_TIME_TOO_SHORT"; string private constant ERROR_EXCHANGE_RATE_ZERO = "PAYROLL_EXCHANGE_RATE_ZERO"; string private constant ERROR_PAST_TERMINATION_DATE = "PAYROLL_PAST_TERMINATION_DATE"; + string private constant ERROR_LAST_PAYROLL_DATE_TOO_BIG = "PAYROLL_LAST_DATE_TOO_BIG"; + string private constant ERROR_INVALID_REQUESTED_AMOUNT = "PAYROLL_INVALID_REQUESTED_AMT"; struct Employee { address accountAddress; // unique, but can be changed over time - mapping(address => uint8) allocation; + mapping(address => uint256) allocation; uint256 denominationTokenSalary; // per second in denomination Token uint256 accruedValue; uint64 lastPayroll; @@ -64,27 +66,18 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { } Finance public finance; - address public denominationToken; IFeed public feed; + address public denominationToken; uint64 public rateExpiryTime; - mapping(uint256 => Employee) private employees; // employee ID -> employee // Employees start at index 1, to allow us to use employees[0] to check for non-existent address - // mappings with employeeIds uint256 public nextEmployee; - mapping(address => uint256) private employeeIds; // employee address -> employee ID - mapping(address => bool) private allowedTokens; + mapping(address => uint256) internal employeeIds; // employee address -> employee ID + mapping(uint256 => Employee) internal employees; // employee ID -> employee + mapping(address => bool) internal allowedTokens; address[] internal allowedTokensArray; event AddAllowedToken(address token); - event AddEmployee( - uint256 indexed employeeId, - address indexed accountAddress, - uint256 initialDenominationSalary, - string name, - string role, - uint64 startDate - ); event SetEmployeeSalary(uint256 indexed employeeId, uint256 denominationSalary); event AddEmployeeAccruedValue(uint256 indexed employeeId, uint256 amount); event TerminateEmployee(uint256 indexed employeeId, address indexed accountAddress, uint64 endDate); @@ -93,41 +86,51 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { event SendPayment(address indexed employee, address indexed token, uint256 amount, string reference); event SetPriceFeed(address indexed feed); event SetRateExpiryTime(uint64 time); + event AddEmployee( + uint256 indexed employeeId, + address indexed accountAddress, + uint256 initialDenominationSalary, + string role, + uint64 startDate + ); - modifier employeeActive(uint256 employeeId) { - Employee storage employee = employees[employeeId]; - // Check employee exists and is active - require(employeeIds[employee.accountAddress] != 0 && employee.endDate > getTimestamp64(), ERROR_NON_ACTIVE_EMPLOYEE); + // Check employee exists by address + modifier employeeAddressExists(address _accountAddress) { + require(_employeeExists(_accountAddress), ERROR_EMPLOYEE_DOESNT_EXIST); _; } + // Check employee exists by ID + modifier employeeIdExists(uint256 _employeeId) { + require(_employeeExists(_employeeId), ERROR_EMPLOYEE_DOESNT_EXIST); + _; + } + + // Check employee exists and is still active + modifier employeeActive(uint256 _employeeId) { + require(_employeeExists(_employeeId) && _isEmployeeActive(_employeeId), ERROR_NON_ACTIVE_EMPLOYEE); + _; + } + + // Check employee exists and the sender matches modifier employeeMatches { - // Check employee exists (and matches sender) require(employees[employeeIds[msg.sender]].accountAddress == msg.sender, ERROR_EMPLOYEE_DOES_NOT_MATCH); _; } /** - * @notice Initialize Payroll app for Finance at `_finance` and price feed at `priceFeed`, setting denomination token to `_token.symbol(): string` and exchange rate expiry time to `@transformTime(_rateExpiryTime)`. + * @notice Initialize Payroll app for Finance at `_finance` and price feed at `_priceFeed`, setting denomination token to `_token.symbol(): string` and exchange rate expiry time to `@transformTime(_rateExpiryTime)` * @param _finance Address of the Finance app this Payroll will rely on (non-changeable) * @param _denominationToken Address of the denomination token * @param _priceFeed Address of the price feed - * @param _rateExpiryTime Exchange rate expiry time, in seconds - */ - function initialize( - Finance _finance, - address _denominationToken, - IFeed _priceFeed, - uint64 _rateExpiryTime - ) - external - onlyInit - { + * @param _rateExpiryTime Exchange rate expiry time in seconds + */ + function initialize(Finance _finance, address _denominationToken, IFeed _priceFeed, uint64 _rateExpiryTime) external onlyInit { require(isContract(_finance), ERROR_FINANCE_NOT_CONTRACT); initialized(); - // Reserve the first employee index as an unused index to check null address mappings + // Employees start at index 1, to allow us to use employees[0] to check for non-existent address nextEmployee = 1; finance = _finance; denominationToken = _denominationToken; @@ -136,118 +139,80 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { } /** - * @notice Sets the price feed for exchange rates to `_feed`. - * @param _feed Address of the price feed + * @notice Set the price feed for exchange rates to `_feed` + * @param _feed Address of the new price feed instance */ function setPriceFeed(IFeed _feed) external authP(CHANGE_PRICE_FEED_ROLE, arr(_feed, feed)) { _setPriceFeed(_feed); } /** - * @dev Set the exchange rate expiry time, in seconds. Exchange rates older than it won't be accepted for payments. - * @notice Sets the exchange rate expiry time to `@transformTime(_time)`. + * @notice Set the exchange rate expiry time to `@transformTime(_time)` + * @dev Set the exchange rate expiry time in seconds. Exchange rates older than it won't be accepted for payments * @param _time The expiration time in seconds for exchange rates */ - function setRateExpiryTime(uint64 _time) - external - authP(MODIFY_RATE_EXPIRY_ROLE, arr(uint256(_time), uint256(rateExpiryTime))) - { + function setRateExpiryTime(uint64 _time) external authP(MODIFY_RATE_EXPIRY_ROLE, arr(uint256(_time), uint256(rateExpiryTime))) { _setRateExpiryTime(_time); } /** * @notice Add `_allowedToken` to the set of allowed tokens - * @param _allowedToken New token allowed for payment + * @param _allowedToken New token address to be allowed for payments */ function addAllowedToken(address _allowedToken) external authP(ALLOWED_TOKENS_MANAGER_ROLE, arr(_allowedToken)) { require(!allowedTokens[_allowedToken], ERROR_TOKEN_ALREADY_ALLOWED); require(allowedTokensArray.length < MAX_ALLOWED_TOKENS, ERROR_MAX_ALLOWED_TOKENS); + allowedTokens[_allowedToken] = true; allowedTokensArray.push(_allowedToken); emit AddAllowedToken(_allowedToken); } - /* - * TODO: removeFromAllowedTokens. It wouldn't be trivial, as employees - * should modifiy their allocation. They should be notified and their - * last allocation date should be reset. - */ - /** - * @notice Add employee `_name` with address `_accountAddress` to Payroll with a salary of `_initialDenominationSalary` per second. + * @notice Add employee with address `_accountAddress` to Payroll with a salary of `_initialDenominationSalary` per second * @param _accountAddress Employee's address to receive payroll * @param _initialDenominationSalary Employee's salary, per second in denomination token - * @param _name Employee's name * @param _role Employee's role */ - function addEmployee( - address _accountAddress, - uint256 _initialDenominationSalary, - string _name, - string _role - ) + function addEmployeeNow(address _accountAddress, uint256 _initialDenominationSalary, string _role) external authP(ADD_EMPLOYEE_ROLE, arr(_accountAddress, _initialDenominationSalary, getTimestamp64())) { - _addEmployee( - _accountAddress, - _initialDenominationSalary, - _name, - _role, - getTimestamp64() - ); + _addEmployee(_accountAddress, _initialDenominationSalary, _role, getTimestamp64()); } /** - * @notice Add employee `_name` with address `_accountAddress` to Payroll with a salary of `_initialDenominationSalary` per second, starting on `_startDate`. + * @notice Add employee with address `_accountAddress` to Payroll with a salary of `_initialDenominationSalary` per second, starting on `@transformTime(_startDate)` * @param _accountAddress Employee's address to receive payroll * @param _initialDenominationSalary Employee's salary, per second in denomination token - * @param _name Employee's name * @param _role Employee's role - * @param _startDate Employee's starting date (it actually sets their initial lastPayroll value) - */ - function addEmployee( - address _accountAddress, - uint256 _initialDenominationSalary, - string _name, - string _role, - uint64 _startDate - ) + * @param _startDate Employee's starting timestamp in seconds (it actually sets their initial lastPayroll value) + */ + function addEmployee(address _accountAddress, uint256 _initialDenominationSalary, string _role, uint64 _startDate) external authP(ADD_EMPLOYEE_ROLE, arr(_accountAddress, _initialDenominationSalary, _startDate)) { - _addEmployee( - _accountAddress, - _initialDenominationSalary, - _name, - _role, - _startDate - ); + _addEmployee(_accountAddress, _initialDenominationSalary, _role, _startDate); } /** - * @notice Set employee #`_employeeId`'s annual salary to `_denominationSalary` per second. + * @notice Set employee #`_employeeId`'s annual salary to `_denominationSalary` per second * @param _employeeId Employee's identifier * @param _denominationSalary Employee's new salary, per second in denomination token */ - function setEmployeeSalary( - uint256 _employeeId, - uint256 _denominationSalary - ) + function setEmployeeSalary(uint256 _employeeId, uint256 _denominationSalary) external - employeeActive(_employeeId) authP(SET_EMPLOYEE_SALARY_ROLE, arr(_employeeId, _denominationSalary)) + employeeActive(_employeeId) { - uint64 timestamp = getTimestamp64(); - // Add owed salary to employee's accrued value - uint256 owed = _getOwedSalary(_employeeId, timestamp); + uint256 owed = _getOwedSalary(_employeeId); _addAccruedValue(_employeeId, owed); // Update employee to track the new salary and payment date Employee storage employee = employees[_employeeId]; - employee.lastPayroll = timestamp; + employee.lastPayroll = getTimestamp64(); employee.denominationTokenSalary = _denominationSalary; emit SetEmployeeSalary(_employeeId, _denominationSalary); @@ -257,70 +222,61 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { * @notice Terminate employee #`_employeeId` * @param _employeeId Employee's identifier */ - function terminateEmployeeNow( - uint256 _employeeId - ) + function terminateEmployeeNow(uint256 _employeeId) external - employeeActive(_employeeId) authP(TERMINATE_EMPLOYEE_ROLE, arr(_employeeId)) + employeeActive(_employeeId) { - _terminateEmployee(_employeeId, getTimestamp64()); + _terminateEmployeeAt(_employeeId, getTimestamp64()); } /** * @notice Terminate employee #`_employeeId` on `@formatDate(_endDate)` * @param _employeeId Employee's identifier - * @param _endDate Termination date + * @param _endDate Termination timestamp in seconds */ - function terminateEmployee( - uint256 _employeeId, - uint64 _endDate - ) + function terminateEmployee(uint256 _employeeId, uint64 _endDate) external - employeeActive(_employeeId) authP(TERMINATE_EMPLOYEE_ROLE, arr(_employeeId)) + employeeActive(_employeeId) { - _terminateEmployee(_employeeId, _endDate); + _terminateEmployeeAt(_employeeId, _endDate); } /** - * @notice Adds `_amount` to accrued value for employee #`_employeeId` + * @notice Add `_amount` to accrued value for employee #`_employeeId` * @param _employeeId Employee's identifier - * @param _amount Amount to add + * @param _amount Amount to be added to the employee's accrued value */ - function addAccruedValue( - uint256 _employeeId, - uint256 _amount - ) + function addAccruedValue(uint256 _employeeId, uint256 _amount) external - employeeActive(_employeeId) authP(ADD_ACCRUED_VALUE_ROLE, arr(_employeeId, _amount)) + employeeActive(_employeeId) { _addAccruedValue(_employeeId, _amount); } /** - * @notice Set token distribution for payments to an employee (the caller). + * @notice Set token distribution for payments to an employee (the caller) * @dev Initialization check is implicitly provided by `employeeMatches()` as new employees can - * only be added via `addEmployee(),` which requires initialization. + * only be added via `addEmployee(),` which requires initialization * @param _tokens Array with the tokens to receive, they must belong to allowed tokens for employee - * @param _distribution Array (correlated to tokens) with the proportions (integers summing to 100) + * @param _distribution Array, correlated to tokens, with their corresponding proportions (integers summing to 100) */ - function determineAllocation(address[] _tokens, uint8[] _distribution) external employeeMatches { + function determineAllocation(address[] _tokens, uint256[] _distribution) external employeeMatches { // Check arrays match require(_tokens.length == _distribution.length, ERROR_TOKEN_ALLOCATION_MISMATCH); Employee storage employee = employees[employeeIds[msg.sender]]; - // Delete previous allocation - for (uint32 j = 0; j < allowedTokensArray.length; j++) { + for (uint256 j = 0; j < allowedTokensArray.length; j++) { delete employee.allocation[allowedTokensArray[j]]; } // Check distribution sums to 100 - uint8 sum = 0; - for (uint32 i = 0; i < _distribution.length; i++) { + uint256 sum = 0; + for (uint256 i = 0; i < _distribution.length; i++) { // Check token is allowed require(allowedTokens[_tokens[i]], ERROR_NO_ALLOWED_TOKEN); // Set distribution @@ -333,63 +289,59 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { } /** - * @dev Withdraw payment by employee (the caller). The specified amount capped to the one owed will be transferred. + * @notice Withdraw a portion of your own payroll + * @dev Withdraw employee's payroll (the caller). The specified amount, capped at the total amount owed, will be transferred. * Initialization check is implicitly provided by `employeeMatches()` as new employees can - * only be added via `addEmployee(),` which requires initialization. - * @notice Withdraw your own payroll. - * @param _amount Amount of owed salary requested. Must be less or equal than total owed so far. + * only be added via `addEmployee(),` which requires initialization + * @param _amount Amount of owed salary requested. Must be less or equal than total owed so far */ function partialPayday(uint256 _amount) external employeeMatches { - bool somethingPaid = _payday(employeeIds[msg.sender], _amount); - require(somethingPaid, ERROR_NOTHING_PAID); + _payday(employeeIds[msg.sender], _amount); } /** - * @dev Withdraw payment by employee (the caller). The amount owed since last call will be transferred. + * @notice Withdraw all of your own payroll + * @dev Withdraw employee's payroll (the caller). The amount owed since last call will be transferred. * Initialization check is implicitly provided by `employeeMatches()` as new employees can - * only be added via `addEmployee(),` which requires initialization. - * @notice Withdraw your own payroll. + * only be added via `addEmployee(),` which requires initialization */ function payday() external employeeMatches { - bool somethingPaid = _payday(employeeIds[msg.sender], 0); - require(somethingPaid, ERROR_NOTHING_PAID); + _payday(employeeIds[msg.sender], 0); } /** - * @dev Withdraw accrued value by employee (the caller). The specified amount capped to the one owed will be transferred. + * @notice Withdraw a portion of your own accrued value + * @dev Withdraw employee's accrued value (the caller). The specified amount, capped at the total amount owed, will be transferred. * Initialization check is implicitly provided by `employeeMatches()` as new employees can - * only be added via `addEmployee(),` which requires initialization. - * @notice Withdraw your own payroll. - * @param _amount Amount of accrued value requested. Must be less or equal than total amount so far. + * only be added via `addEmployee(),` which requires initialization + * @param _amount Amount of accrued value requested. Must be less or equal than total amount so far */ function partialReimburse(uint256 _amount) external employeeMatches { - bool somethingPaid = _reimburse(employeeIds[msg.sender], _amount); - require(somethingPaid, ERROR_NOTHING_PAID); + _reimburse(employeeIds[msg.sender], _amount); } /** - * @dev Withdraw accrued value by employee (the caller). The amount owed since last call will be transferred. + * @notice Withdraw all your own accrued value + * @dev Withdraw employee's accrued value (the caller). The amount owed since last call will be transferred. * Initialization check is implicitly provided by `employeeMatches()` as new employees can - * only be added via `addEmployee(),` which requires initialization. - * @notice Withdraw your own accrued value. + * only be added via `addEmployee(),` which requires initialization */ function reimburse() external employeeMatches { - bool somethingPaid = _reimburse(employeeIds[msg.sender], 0); - require(somethingPaid, ERROR_NOTHING_PAID); + _reimburse(employeeIds[msg.sender], 0); } /** + * @notice Change your employee account address to `_newAddress` * @dev Change employee's account address. Must be called by employee from their registered address. * Initialization check is implicitly provided by `employeeMatches()` as new employees can - * only be added via `addEmployee(),` which requires initialization. - * @notice Change your employee account address to `_newAddress` - * @param _newAddress New address to receive payments + * only be added via `addEmployee(),` which requires initialization + * @param _newAddress New address to receive payments for the requesting employee */ function changeAddressByEmployee(address _newAddress) external employeeMatches { // Check address is non-null require(_newAddress != address(0), ERROR_EMPLOYEE_NULL_ADDRESS); // Check address isn't already being used - require(employeeIds[_newAddress] == 0, ERROR_EMPLOYEE_ALREADY_EXIST); + require(!_employeeExists(_newAddress), ERROR_EMPLOYEE_ALREADY_EXIST); uint256 employeeId = employeeIds[msg.sender]; Employee storage employee = employees[employeeId]; @@ -402,10 +354,38 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { emit ChangeAddressByEmployee(employeeId, oldAddress, _newAddress); } + // Forwarding fns + + /** + * @dev IForwarder interface conformance. Tells whether the payroll is a forwarder or not + * @return Always true + */ function isForwarder() external pure returns (bool) { return true; } + /** + * @dev IForwarder interface conformance. Forwards any employee action + * @param _evmScript Script being executed + */ + function forward(bytes _evmScript) public { + require(canForward(msg.sender, _evmScript), ERROR_NO_FORWARD); + bytes memory input = new bytes(0); // TODO: Consider input for this + address[] memory blacklist = new address[](1); + blacklist[0] = address(finance); + runScript(_evmScript, input, blacklist); + } + + /** + * @dev IForwarder interface conformance. Tells whether a given address can forward actions or not + * @param _sender Address of the account willing to forward an action + * @return True if the given address is an employee, false otherwise + */ + function canForward(address _sender, bytes) public view returns (bool) { + // Check employee exists (and matches) + return (employees[employeeIds[_sender]].accountAddress == _sender); + } + // Getter fns /** @@ -420,6 +400,7 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { function getEmployeeByAddress(address _accountAddress) public view + employeeAddressExists(_accountAddress) returns ( uint256 employeeId, uint256 denominationSalary, @@ -450,6 +431,7 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { function getEmployee(uint256 _employeeId) public view + employeeIdExists(_employeeId) returns ( address accountAddress, uint256 denominationSalary, @@ -468,57 +450,40 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { } /** - * @notice Get payment proportion for an employee and a token + * @notice Get an employee's payment allocation for a token * @param _employeeId Employee's identifier - * @param _token Payment token - * @return Employee's payment allocation for token + * @param _token Token to query the payment allocation for + * @return Employee's payment allocation for the token being queried */ - function getAllocation(uint256 _employeeId, address _token) public view returns (uint8 allocation) { + function getAllocation(uint256 _employeeId, address _token) public view employeeIdExists(_employeeId) returns (uint256 allocation) { return employees[_employeeId].allocation[_token]; } /** * @dev Check if a token is allowed to be used in this app - * @param _token Token to check - * @return True if it's in the list of allowed tokens, false otherwise + * @param _token Address of the token to be checked + * @return True if the given token is allowed, false otherwise */ - function isTokenAllowed(address _token) public view returns (bool) { + function isTokenAllowed(address _token) public view isInitialized returns (bool) { return allowedTokens[_token]; } - /** - * @dev IForwarder interface conformance. Forwards any employee action. - * @param _evmScript script being executed - */ - function forward(bytes _evmScript) public { - require(canForward(msg.sender, _evmScript), ERROR_NO_FORWARD); - bytes memory input = new bytes(0); // TODO: Consider input for this - address[] memory blacklist = new address[](1); - blacklist[0] = address(finance); - runScript(_evmScript, input, blacklist); - } - - function canForward(address _sender, bytes) public view returns (bool) { - // Check employee exists (and matches) - return (employees[employeeIds[_sender]].accountAddress == _sender); - } - // Internal fns - function _addEmployee( - address _accountAddress, - uint256 _initialDenominationSalary, - string _name, - string _role, - uint64 _startDate - ) - internal - { + /** + * @notice Add a new employee to Payroll + * @param _accountAddress Employee's address to receive payroll + * @param _initialDenominationSalary Employee's salary, per second in denomination token + * @param _role Employee's role + * @param _startDate Employee's starting timestamp in seconds + */ + function _addEmployee(address _accountAddress, uint256 _initialDenominationSalary, string _role, uint64 _startDate) internal { + // Check address is non-null + require(_accountAddress != address(0), ERROR_EMPLOYEE_NULL_ADDRESS); // Check address isn't already being used - require(employeeIds[_accountAddress] == 0, ERROR_EMPLOYEE_ALREADY_EXIST); + require(!_employeeExists(_accountAddress), ERROR_EMPLOYEE_ALREADY_EXIST); uint256 employeeId = nextEmployee++; - Employee storage employee = employees[employeeId]; employee.accountAddress = _accountAddress; employee.denominationTokenSalary = _initialDenominationSalary; @@ -528,28 +493,34 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { // Create IDs mapping employeeIds[_accountAddress] = employeeId; - emit AddEmployee( - employeeId, - _accountAddress, - _initialDenominationSalary, - _name, - _role, - _startDate - ); + emit AddEmployee(employeeId, _accountAddress, _initialDenominationSalary, _role, _startDate); } + /** + * @dev Add a requested amount to the accrued value for a given employee + * @param _employeeId Employee's identifier + * @param _amount Amount be added to the employee's accrued value + */ function _addAccruedValue(uint256 _employeeId, uint256 _amount) internal { employees[_employeeId].accruedValue = employees[_employeeId].accruedValue.add(_amount); emit AddEmployeeAccruedValue(_employeeId, _amount); } + /** + * @dev Set the price feed address used for exchange rates + * @param _feed Address of the new price feed instance + */ function _setPriceFeed(IFeed _feed) internal { require(isContract(_feed), ERROR_FEED_NOT_CONTRACT); feed = _feed; emit SetPriceFeed(feed); } + /** + * @dev Set the exchange rate expiry time in seconds. Exchange rates older than it won't be accepted when making payments + * @param _time The expiration time in seconds for exchange rates + */ function _setRateExpiryTime(uint64 _time) internal { // Require a sane minimum for the rate expiry time // (1 min == ~4 block window to mine both a pricefeed update and a payout) @@ -559,61 +530,56 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { } /** - * @dev Send requested amount of the salary to the employee. - * @param _employeeId Employee's identifier. - * @param _requestedAmount Amount of owed salary requested. Must be less or equal than total owed so far. - * @return True if something has been paid. + * @dev Send the requested amount of salary to the employee + * @param _employeeId Employee's identifier + * @param _requestedAmount Amount of owed salary requested (must be less than or equal to total owed salary). Using `0` will request all available salary + * @return True if something has been paid */ function _payday(uint256 _employeeId, uint256 _requestedAmount) internal returns (bool somethingPaid) { Employee storage employee = employees[_employeeId]; - // Get the min of current date and termination date - uint64 timestamp = getTimestamp64(); - uint64 date = employee.endDate < timestamp ? employee.endDate : timestamp; - // Compute amount to be payed - uint256 owedAmount = _getOwedSalary(_employeeId, date); - if (owedAmount == 0 || owedAmount < _requestedAmount) { - return false; - } + uint256 owedAmount = _getOwedSalary(_employeeId); + require(owedAmount > 0, ERROR_NOTHING_PAID); + require(owedAmount >= _requestedAmount, ERROR_INVALID_REQUESTED_AMOUNT); uint256 payingAmount = _requestedAmount > 0 ? _requestedAmount : owedAmount; // Execute payment - employee.lastPayroll = timestamp; - somethingPaid = _transferTokensAmount(_employeeId, payingAmount, "Payroll"); + employee.lastPayroll = (payingAmount == owedAmount) ? getTimestamp64() : _getLastPayroll(_employeeId, payingAmount); + require(_transferTokensAmount(_employeeId, payingAmount, "Payroll"), ERROR_NOTHING_PAID); // Try removing employee - _tryRemovingEmployee(_employeeId, date); + _removeEmployeeIfTerminatedAndPaidOut(_employeeId); } /** - * @dev Send requested amount of the accrued value to the employee. - * @param _employeeId Employee's identifier. - * @param _requestedAmount Amount of accrued value requested. Must be less or equal than total amount so far. - * @return True if something has been paid. + * @dev Send the requested amount of accrued value to the employee + * @param _employeeId Employee's identifier + * @param _requestedAmount Amount of accrued value requested (must be less than or equal to the total accrued amount). Using `0` will request all accrued value + * @return True if something has been paid */ function _reimburse(uint256 _employeeId, uint256 _requestedAmount) internal returns (bool somethingPaid) { Employee storage employee = employees[_employeeId]; // Compute amount to be payed - if (employee.accruedValue == 0 || employee.accruedValue < _requestedAmount) { - return false; - } + require(employee.accruedValue > 0, ERROR_NOTHING_PAID); + require(employee.accruedValue >= _requestedAmount, ERROR_INVALID_REQUESTED_AMOUNT); uint256 payingAmount = _requestedAmount > 0 ? _requestedAmount : employee.accruedValue; // Execute payment employee.accruedValue = employee.accruedValue.sub(payingAmount); - somethingPaid = _transferTokensAmount(_employeeId, payingAmount, "Reimbursement"); - - // Get the min of current date and termination date - uint64 timestamp = getTimestamp64(); - uint64 date = employee.endDate < timestamp ? employee.endDate : timestamp; + require(_transferTokensAmount(_employeeId, payingAmount, "Reimbursement"), ERROR_NOTHING_PAID); // Try removing employee - _tryRemovingEmployee(_employeeId, date); + _removeEmployeeIfTerminatedAndPaidOut(_employeeId); } - function _terminateEmployee(uint256 _employeeId, uint64 _endDate) internal { + /** + * @dev Set the end date of an employee + * @param _employeeId Employee's identifier to set the end date of + * @param _endDate Date timestamp in seconds to be set as the end date of the employee + */ + function _terminateEmployeeAt(uint256 _employeeId, uint64 _endDate) internal { // Prevent past termination dates require(_endDate >= getTimestamp64(), ERROR_PAST_TERMINATION_DATE); @@ -623,42 +589,70 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { emit TerminateEmployee(_employeeId, employee.accountAddress, _endDate); } - function _getOwedSalary(uint256 _employeeId, uint64 _date) internal view returns (uint256) { + /** + * @dev Calculate the new last payroll date for an employee based on an requested payment amount + * @param _employeeId Employee's identifier + * @param _payedAmount Amount payed to the employee + * @return The new last payroll timestamp in seconds based on the requested payment amount + */ + function _getLastPayroll(uint256 _employeeId, uint256 _payedAmount) internal view returns (uint64) { Employee storage employee = employees[_employeeId]; + uint256 timeDiff = _payedAmount.div(employee.denominationTokenSalary); + + // This function is only called from _payday, where we make sure that _payedAmount is lower than or equal to the + // total owed amount, that is obtained from _getOwedSalary, which does exactly the opposite calculation: + // multiplying the employee's salary by an uint64 number of seconds. Therefore, timeDiff will always fit in 64. + // Nevertheless, we are performing a sanity check at the end to ensure the computed last payroll timestamp + // is not greater than the current timestamp. + + uint256 lastPayrollDate = uint256(employee.lastPayroll).add(timeDiff); + require(lastPayrollDate <= getTimestamp(), ERROR_LAST_PAYROLL_DATE_TOO_BIG); + return uint64(lastPayrollDate); + } + + /** + * @dev Get amount of owed salary for a given employee since their last payroll + * @param _employeeId Employee's identifier + * @return Total amount of owed salary for the requested employee since their last payroll + */ + function _getOwedSalary(uint256 _employeeId) internal view returns (uint256) { + Employee storage employee = employees[_employeeId]; + + // Get the min of current date and termination date + uint64 date = _isEmployeeActive(_employeeId) ? getTimestamp64() : employee.endDate; + // Make sure we don't revert if we try to get the owed salary for an employee whose start // date is in the future (necessary in case we need to change their salary before their start date) - if (_date <= employee.lastPayroll) { + if (date <= employee.lastPayroll) { return 0; } - // Get time that has gone by (seconds) - // No need to use safe math as the underflow was covered by the previous check - uint64 time = _date - employee.lastPayroll; + // Get time diff in seconds, no need to use safe math as the underflow was covered by the previous check + uint64 timeDiff = date - employee.lastPayroll; + uint256 salary = employee.denominationTokenSalary; + uint256 result = salary * uint256(timeDiff); - // if the result would overflow, set it to max int - uint256 result = employee.denominationTokenSalary * time; - if (result / time != employee.denominationTokenSalary) { + // Return max int if the result overflows + if (result / timeDiff != salary) { return MAX_UINT256; } - return result; } /** - * @dev Gets token exchange rate for a token based on the denomination token. + * @dev Get token exchange rate for a token based on the denomination token * @param _token Token * @return ONE if _token is denominationToken or 0 if the exchange rate isn't recent enough */ function _getExchangeRate(address _token) internal view returns (uint128) { - uint128 xrt; - uint64 when; - // Denomination token has always exchange rate of 1 if (_token == denominationToken) { return ONE; } + uint128 xrt; + uint64 when; (xrt, when) = feed.get(denominationToken, _token); // Check the price feed is recent enough @@ -672,11 +666,11 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { /** * @dev Loop over tokens to send requested amount to the employee * @param _employeeId Employee's identifier - * @param _totalAmount Total amount to be transferred to the employee distributed through the setup tokens allocation. - * @param _reference String detailing payment reason. - * @return True if there was at least one token transfer. + * @param _totalAmount Total amount to be transferred to the employee distributed in accordance to the employee's token allocation + * @param _reference String detailing payment reason + * @return True if there was at least one token transfer */ - function _transferTokensAmount(uint256 _employeeId, uint256 _totalAmount, string _reference) private returns (bool somethingPaid) { + function _transferTokensAmount(uint256 _employeeId, uint256 _totalAmount, string _reference) internal returns (bool somethingPaid) { Employee storage employee = employees[_employeeId]; for (uint256 i = 0; i < allowedTokensArray.length; i++) { address token = allowedTokensArray[i]; @@ -687,29 +681,61 @@ contract Payroll is EtherTokenConstant, IForwarder, IsContract, AragonApp { uint256 tokenAmount = _totalAmount.mul(exchangeRate).mul(employee.allocation[token]); // Divide by 100 for the allocation and by ONE for the exchange rate tokenAmount = tokenAmount / (100 * ONE); - finance.newPayment(token, employee.accountAddress, tokenAmount, 0, 0, 1, _reference); - emit SendPayment(employee.accountAddress, token, tokenAmount, _reference); + address employeeAddress = employee.accountAddress; + finance.newPayment(token, employeeAddress, tokenAmount, 0, 0, 1, _reference); + emit SendPayment(employeeAddress, token, tokenAmount, _reference); somethingPaid = true; } } } /** - * @dev Try removing employee if there are no pending payments and has reached employee's end date. + * @dev Try removing employee if there are no pending payments and has reached employee's end date * @param _employeeId Employee's identifier - * @param _date Date timestamp used to evaluate if the employee can be removed from the payroll. */ - function _tryRemovingEmployee(uint256 _employeeId, uint64 _date) private { + function _removeEmployeeIfTerminatedAndPaidOut(uint256 _employeeId) internal { Employee storage employee = employees[_employeeId]; - bool hasReachedEndDate = employee.endDate <= _date; - bool isOwedSalary = _getOwedSalary(_employeeId, _date) > 0; - bool isOwedAccruedValue = employees[_employeeId].accruedValue > 0; - bool areNoPendingPayments = !isOwedSalary && !isOwedAccruedValue; - - if (hasReachedEndDate && areNoPendingPayments) { - delete employeeIds[employees[_employeeId].accountAddress]; - delete employees[_employeeId]; + if (employee.endDate > getTimestamp64()) { + return; + } + if (_getOwedSalary(_employeeId) > 0) { + return; } + if (employee.accruedValue > 0) { + return; + } + + delete employeeIds[employee.accountAddress]; + delete employees[_employeeId]; + } + + /** + * @dev Tell whether an employee is registered in this Payroll or not + * @param _accountAddress Address of the employee to query the existence of + * @return True if the given address belongs to a registered employee, false otherwise + */ + function _employeeExists(address _accountAddress) internal returns (bool) { + return employeeIds[_accountAddress] != uint256(0); + } + + /** + * @dev Tell whether an employee is registered in this Payroll or not + * @param _employeeId Employee's identifier + * @return True if the employee is registered in this Payroll, false otherwise + */ + function _employeeExists(uint256 _employeeId) internal returns (bool) { + Employee storage employee = employees[_employeeId]; + return employee.accountAddress != address(0); + } + + /** + * @dev Tell whether an employee is still active or not + * @param _employeeId Employee's identifier + * @return True if the employee's end date has not been reached yet, false otherwise + */ + function _isEmployeeActive(uint256 _employeeId) internal returns (bool) { + Employee storage employee = employees[_employeeId]; + return employee.endDate >= getTimestamp64(); } } diff --git a/future-apps/payroll/contracts/test/TestImports.sol b/future-apps/payroll/contracts/test/TestImports.sol index 10c90e04fa..e321eae9a3 100644 --- a/future-apps/payroll/contracts/test/TestImports.sol +++ b/future-apps/payroll/contracts/test/TestImports.sol @@ -1,12 +1,12 @@ pragma solidity 0.4.24; -import "@aragon/apps-vault/contracts/Vault.sol"; -import "@aragon/os/contracts/factory/EVMScriptRegistryFactory.sol"; -import "@aragon/os/contracts/factory/DAOFactory.sol"; import "@aragon/os/contracts/acl/ACL.sol"; +import "@aragon/os/contracts/factory/DAOFactory.sol"; +import "@aragon/os/contracts/factory/EVMScriptRegistryFactory.sol"; -import "@aragon/apps-shared-migrations/contracts/Migrations.sol"; +import "@aragon/apps-vault/contracts/Vault.sol"; import "@aragon/apps-shared-minime/contracts/MiniMeToken.sol"; +import "@aragon/apps-shared-migrations/contracts/Migrations.sol"; contract TestImports { diff --git a/future-apps/payroll/contracts/test/mocks/EtherTokenConstantMock.sol b/future-apps/payroll/contracts/test/mocks/EtherTokenConstantMock.sol deleted file mode 100644 index 048457da69..0000000000 --- a/future-apps/payroll/contracts/test/mocks/EtherTokenConstantMock.sol +++ /dev/null @@ -1,8 +0,0 @@ -pragma solidity 0.4.24; - -import "@aragon/os/contracts/common/EtherTokenConstant.sol"; - - -contract EtherTokenConstantMock is EtherTokenConstant { - function getETHConstant() external pure returns (address) { return ETH; } -} diff --git a/future-apps/payroll/contracts/test/mocks/PayrollMock.sol b/future-apps/payroll/contracts/test/mocks/PayrollMock.sol index db509b6fcb..369f07e31d 100644 --- a/future-apps/payroll/contracts/test/mocks/PayrollMock.sol +++ b/future-apps/payroll/contracts/test/mocks/PayrollMock.sol @@ -6,33 +6,13 @@ import "../../Payroll.sol"; contract PayrollMock is Payroll { uint64 private _mockTime = uint64(now); - /* Ugly hack to work around this issue: - * https://github.com/trufflesuite/truffle/issues/569 - * https://github.com/trufflesuite/truffle/issues/737 - */ - function addEmployeeShort( - address _accountAddress, - uint256 _initialDenominationSalary, - string _name, - string _role - ) - external - { - _addEmployee( - _accountAddress, - _initialDenominationSalary, - _name, - _role, - getTimestamp64() - ); - } - function mockUpdateTimestamp() public { _mockTime = uint64(now); } function mockSetTimestamp(uint64 i) public { _mockTime = i; } function mockAddTimestamp(uint64 i) public { _mockTime += i; require(_mockTime >= i); } function getTimestampPublic() public view returns (uint64) { return _mockTime; } function getMaxAccruedValue() public view returns (uint256) { return MAX_UINT256; } - function getMaxAllowedTokens() public view returns (uint8) { return MAX_ALLOWED_TOKENS; } + function getMaxAllowedTokens() public view returns (uint256) { return MAX_ALLOWED_TOKENS; } function getAllowedTokensArrayLength() public view returns (uint256) { return allowedTokensArray.length; } + function getTimestamp() internal view returns (uint256) { return uint256(_mockTime); } function getTimestamp64() internal view returns (uint64) { return _mockTime; } } diff --git a/future-apps/payroll/contracts/test/mocks/feed/PriceFeedMock.sol b/future-apps/payroll/contracts/test/mocks/PriceFeedMock.sol similarity index 100% rename from future-apps/payroll/contracts/test/mocks/feed/PriceFeedMock.sol rename to future-apps/payroll/contracts/test/mocks/PriceFeedMock.sol diff --git a/future-apps/payroll/contracts/test/mocks/Zombie.sol b/future-apps/payroll/contracts/test/mocks/Zombie.sol deleted file mode 100644 index 31b8c4b31f..0000000000 --- a/future-apps/payroll/contracts/test/mocks/Zombie.sol +++ /dev/null @@ -1,17 +0,0 @@ -pragma solidity 0.4.24; - - -contract Zombie { - address public owner; - - constructor(address _owner) public { - owner = _owner; - } - - function() public payable {} - - function escapeHatch() public { - selfdestruct(owner); - } - -} diff --git a/future-apps/payroll/contracts/test/mocks/feed/PriceFeedFailMock.sol b/future-apps/payroll/contracts/test/mocks/feed/PriceFeedFailMock.sol deleted file mode 100644 index 9368eca192..0000000000 --- a/future-apps/payroll/contracts/test/mocks/feed/PriceFeedFailMock.sol +++ /dev/null @@ -1,22 +0,0 @@ -pragma solidity ^0.4.24; - -import "@aragon/ppf-contracts/contracts/IFeed.sol"; - - -contract PriceFeedFailMock is IFeed { - event PriceFeedFailLogSetRate (address sender, address token, uint128 value); - - function get(address base, address quote) external view returns (uint128 xrt, uint64 when) { - if (base == address(0)) { - return (2*10**18, 0); - } - - emit PriceFeedFailLogSetRate(msg.sender, quote, xrt); - - return (0, uint64(now)); - } - - function setRate(address pr, address token, uint256 rate) public { - } - -} diff --git a/future-apps/payroll/test/contracts/Payroll_add_employee.test.js b/future-apps/payroll/test/contracts/Payroll_add_employee.test.js new file mode 100644 index 0000000000..17f0886126 --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_add_employee.test.js @@ -0,0 +1,266 @@ +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { getEvents, getEventArgument } = require('../helpers/events') +const { maxUint64, annualSalaryPerSecond } = require('../helpers/numbers')(web3) +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +contract('Payroll employees addition', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + const currentTimestamp = async () => payroll.getTimestampPublic() + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('addEmployeeNow', () => { + const role = 'Boss' + const salary = annualSalaryPerSecond(100000, TOKEN_DECIMALS) + + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender has permissions to add employees', () => { + const from = owner + let receipt, employeeId + + context('when the employee has not been added yet', () => { + let receipt, employeeId + + context('when the employee address is not the zero address', () => { + const address = employee + + beforeEach('add employee', async () => { + receipt = await payroll.addEmployeeNow(address, salary, role, { from }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId').toString() + }) + + it('starts with ID 1', async () => { + assert.equal(employeeId, 1, 'first employee ID should be 1') + }) + + it('adds a new employee and emits an event', async () => { + const [address] = await payroll.getEmployee(employeeId) + assert.equal(address, employee, 'employee address does not match') + + const events = getEvents(receipt, 'AddEmployee'); + assert.equal(events.length, 1, 'number of AddEmployee events does not match') + + const event = events[0].args + assert.equal(event.employeeId, employeeId, 'employee id does not match') + assert.equal(event.role, role, 'employee role does not match') + assert.equal(event.accountAddress, employee, 'employee address does not match') + assert.equal(event.startDate.toString(), (await currentTimestamp()).toString(), 'employee start date does not match') + assert.equal(event.initialDenominationSalary.toString(), salary.toString(), 'employee salary does not match') + }) + + it('can add another employee', async () => { + const anotherRole = 'Manager' + const anotherSalary = annualSalaryPerSecond(120000, TOKEN_DECIMALS) + + const receipt = await payroll.addEmployeeNow(anotherEmployee, anotherSalary, anotherRole) + const anotherEmployeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + + const events = getEvents(receipt, 'AddEmployee'); + assert.equal(events.length, 1, 'number of AddEmployee events does not match') + + const event = events[0].args + assert.equal(event.employeeId, anotherEmployeeId, 'employee id does not match') + assert.equal(event.role, anotherRole, 'employee role does not match') + assert.equal(event.accountAddress, anotherEmployee, 'employee address does not match') + assert.equal(event.startDate.toString(), (await currentTimestamp()).toString(), 'employee start date does not match') + assert.equal(event.initialDenominationSalary.toString(), anotherSalary.toString(), 'employee salary does not match') + + const [address, employeeSalary, accruedValue, lastPayroll, endDate] = await payroll.getEmployee(anotherEmployeeId) + assert.equal(address, anotherEmployee, 'Employee account does not match') + assert.equal(accruedValue, 0, 'Employee accrued value does not match') + assert.equal(employeeSalary.toString(), anotherSalary.toString(), 'Employee salary does not match') + assert.equal(lastPayroll.toString(), (await currentTimestamp()).toString(), 'last payroll should match') + assert.equal(endDate.toString(), maxUint64(), 'last payroll should match') + }) + }) + + context('when the employee address is not the zero address', () => { + const address = ZERO_ADDRESS + + it('reverts', async () => { + await assertRevert(payroll.addEmployeeNow(address, salary, role, { from }), 'PAYROLL_EMPLOYEE_NULL_ADDRESS') + }) + }) + }) + + context('when the employee has already been added', () => { + beforeEach('add employee', async () => { + await payroll.addEmployeeNow(employee, salary, role, { from }) + }) + + it('reverts', async () => { + await assertRevert(payroll.addEmployeeNow(employee, salary, role, { from }), 'PAYROLL_EMPLOYEE_ALREADY_EXIST') + }) + }) + }) + + context('when the sender does not have permissions to add employees', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.addEmployeeNow(employee, salary, role, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.addEmployeeNow(employee, salary, role, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('addEmployee', () => { + const role = 'Boss' + const salary = annualSalaryPerSecond(100000, TOKEN_DECIMALS) + + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender has permissions to add employees', () => { + const from = owner + let receipt, employeeId + + context('when the employee has not been added yet', () => { + let receipt, employeeId + + const itHandlesAddingNewEmployeesProperly = startDate => { + context('when the employee address is not the zero address', () => { + const address = employee + + beforeEach('add employee', async () => { + receipt = await payroll.addEmployee(address, salary, role, startDate, { from }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId').toString() + }) + + it('starts with ID 1', async () => { + assert.equal(employeeId, 1, 'first employee ID should be 1') + }) + + it('adds a new employee and emits an event', async () => { + const [address] = await payroll.getEmployee(employeeId) + assert.equal(address, employee, 'employee address does not match') + + const events = getEvents(receipt, 'AddEmployee'); + assert.equal(events.length, 1, 'number of AddEmployee events does not match') + + const event = events[0].args + assert.equal(event.employeeId, employeeId, 'employee id does not match') + assert.equal(event.role, role, 'employee role does not match') + assert.equal(event.accountAddress, employee, 'employee address does not match') + assert.equal(event.startDate.toString(), startDate, 'employee start date does not match') + assert.equal(event.initialDenominationSalary.toString(), salary.toString(), 'employee salary does not match') + }) + + it('can add another employee', async () => { + const anotherRole = 'Manager' + const anotherSalary = annualSalaryPerSecond(120000, TOKEN_DECIMALS) + + const receipt = await payroll.addEmployee(anotherEmployee, anotherSalary, anotherRole, startDate) + const anotherEmployeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + + const events = getEvents(receipt, 'AddEmployee'); + assert.equal(events.length, 1, 'number of AddEmployee events does not match') + + const event = events[0].args + assert.equal(event.employeeId, anotherEmployeeId, 'employee id does not match') + assert.equal(event.role, anotherRole, 'employee role does not match') + assert.equal(event.accountAddress, anotherEmployee, 'employee address does not match') + assert.equal(event.startDate.toString(), startDate, 'employee start date does not match') + assert.equal(event.initialDenominationSalary.toString(), anotherSalary.toString(), 'employee salary does not match') + + const [address, employeeSalary, accruedValue, lastPayroll, endDate] = await payroll.getEmployee(anotherEmployeeId) + assert.equal(address, anotherEmployee, 'Employee account does not match') + assert.equal(accruedValue, 0, 'Employee accrued value does not match') + assert.equal(employeeSalary.toString(), anotherSalary.toString(), 'Employee salary does not match') + assert.equal(lastPayroll.toString(), startDate.toString(), 'last payroll should match') + assert.equal(endDate.toString(), maxUint64(), 'last payroll should match') + }) + }) + + context('when the employee address is not the zero address', () => { + const address = ZERO_ADDRESS + + it('reverts', async () => { + await assertRevert(payroll.addEmployee(address, salary, role, startDate, { from }), 'PAYROLL_EMPLOYEE_NULL_ADDRESS') + }) + }) + } + + context('when the given end date is in the past ', () => { + const startDate = NOW - TWO_MONTHS + + itHandlesAddingNewEmployeesProperly(startDate) + }) + + context('when the given end date is in the future', () => { + const startDate = NOW + TWO_MONTHS + + itHandlesAddingNewEmployeesProperly(startDate) + }) + }) + + context('when the employee has already been added', () => { + beforeEach('add employee', async () => { + await payroll.addEmployee(employee, salary, role, NOW, { from }) + }) + + context('when the given end date is in the past ', () => { + const startDate = NOW - TWO_MONTHS + + it('reverts', async () => { + await assertRevert(payroll.addEmployee(employee, salary, role, startDate, { from }), 'PAYROLL_EMPLOYEE_ALREADY_EXIST') + }) + }) + + context('when the given end date is in the future', () => { + const startDate = NOW + TWO_MONTHS + + it('reverts', async () => { + await assertRevert(payroll.addEmployee(employee, salary, role, startDate, { from }), 'PAYROLL_EMPLOYEE_ALREADY_EXIST') + }) + }) + }) + }) + + context('when the sender does not have permissions to add employees', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.addEmployee(employee, salary, role, NOW, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.addEmployee(employee, salary, role, NOW, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_allowed_tokens.test.js b/future-apps/payroll/test/contracts/Payroll_allowed_tokens.test.js new file mode 100644 index 0000000000..e42992ce92 --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_allowed_tokens.test.js @@ -0,0 +1,163 @@ +const { getEvent } = require('../helpers/events') +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +const MAX_GAS_USED = 6.5e6 +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +contract('Payroll allowed tokens,', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('addAllowedToken', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender has permissions', () => { + const from = owner + + context('when it does not reach the maximum amount allowed', () => { + it('can allow a token', async () => { + const receipt = await payroll.addAllowedToken(denominationToken.address, { from }) + + const event = getEvent(receipt, 'AddAllowedToken') + assert.equal(event.token, denominationToken.address, 'denomination token address should match') + + assert.equal(await payroll.getAllowedTokensArrayLength(), 1, 'allowed tokens length does not match') + assert(await payroll.isTokenAllowed(denominationToken.address), 'denomination token should be allowed') + }) + + it('can allow a the zero address', async () => { + const receipt = await payroll.addAllowedToken(ZERO_ADDRESS, { from }) + + const event = getEvent(receipt, 'AddAllowedToken') + assert.equal(event.token, ZERO_ADDRESS, 'denomination token address should match') + + assert.equal(await payroll.getAllowedTokensArrayLength(), 1, 'allowed tokens length does not match') + assert(await payroll.isTokenAllowed(ZERO_ADDRESS), 'zero address token should be allowed') + }) + + it('can allow multiple tokens', async () => { + const erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 1', 18) + const erc20Token2 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 2', 16) + + await payroll.addAllowedToken(denominationToken.address, { from }) + await payroll.addAllowedToken(erc20Token1.address, { from }) + await payroll.addAllowedToken(erc20Token2.address, { from }) + + assert.equal(await payroll.getAllowedTokensArrayLength(), 3, 'allowed tokens length does not match') + assert(await payroll.isTokenAllowed(denominationToken.address), 'denomination token should be allowed') + assert(await payroll.isTokenAllowed(erc20Token1.address), 'ERC20 token 1 should be allowed') + assert(await payroll.isTokenAllowed(erc20Token2.address), 'ERC20 token 2 should be allowed') + }) + }) + + context('when it reaches the maximum amount allowed', () => { + let tokenAddresses = [], MAX_ALLOWED_TOKENS + + before('deploy multiple tokens', async () => { + MAX_ALLOWED_TOKENS = (await payroll.getMaxAllowedTokens()).valueOf() + for (let i = 0; i < MAX_ALLOWED_TOKENS; i++) { + const token = await deployErc20TokenAndDeposit(owner, finance, vault, `Token ${i}`, 18); + tokenAddresses.push(token.address) + } + }) + + beforeEach('allow tokens and add employee', async () => { + await Promise.all(tokenAddresses.map(address => payroll.addAllowedToken(address, { from: owner }))) + assert.equal(await payroll.getAllowedTokensArrayLength(), MAX_ALLOWED_TOKENS, 'amount of allowed tokens does not match') + + await payroll.addEmployee(employee, 100000, 'Boss', NOW - ONE_MONTH, { from: owner }) + }) + + it('can not add one more token', async () => { + const erc20Token = await deployErc20TokenAndDeposit(owner, finance, vault, 'Extra token', 18) + + await assertRevert(payroll.addAllowedToken(erc20Token.address), 'PAYROLL_MAX_ALLOWED_TOKENS') + }) + + it('does not run out of gas to payout salary', async () => { + const allocations = tokenAddresses.map(() => 100 / MAX_ALLOWED_TOKENS) + + const allocationTx = await payroll.determineAllocation(tokenAddresses, allocations, { from: employee }) + assert.isBelow(allocationTx.receipt.cumulativeGasUsed, MAX_GAS_USED, 'Too much gas consumed for allocation') + + const paydayTx = await payroll.payday({ from: employee }) + assert.isBelow(paydayTx.receipt.cumulativeGasUsed, MAX_GAS_USED, 'Too much gas consumed for payday') + }) + }) + }) + + context('when the sender does not have permissions', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.addAllowedToken(denominationToken.address, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.addAllowedToken(denominationToken.address, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('isTokenAllowed', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the given token is not the zero address', () => { + context('when the requested token was allowed', () => { + beforeEach('allow denomination token', async () => { + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + }) + + it('returns true', async () => { + assert(await payroll.isTokenAllowed(denominationToken.address), 'token should be allowed') + }) + }) + + context('when the requested token was not allowed yet', () => { + it('returns false', async () => { + assert.isFalse(await payroll.isTokenAllowed(denominationToken.address), 'token should not be allowed') + }) + }) + }) + + context('when the given token is the zero address', () => { + it('returns false', async () => { + assert.isFalse(await payroll.isTokenAllowed(ZERO_ADDRESS), 'token should not be allowed') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.isTokenAllowed(denominationToken.address), 'INIT_NOT_INITIALIZED') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_forwarding.test.js b/future-apps/payroll/test/contracts/Payroll_forwarding.test.js new file mode 100644 index 0000000000..50a63b75e4 --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_forwarding.test.js @@ -0,0 +1,158 @@ +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { encodeCallScript } = require('@aragon/test-helpers/evmScript') +const { getEventArgument } = require('../helpers/events') +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +const ExecutionTarget = artifacts.require('ExecutionTarget') + +contract('Payroll forwarding,', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('isForwarder', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + it('returns true', async () => { + assert(await payroll.isForwarder(), 'should be a forwarder') + }) + }) + + context('when it has not been initialized yet', function () { + it('returns true', async () => { + assert(await payroll.isForwarder(), 'should be a forwarder') + }) + }) + }) + + describe('canForward', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender is an employee', () => { + let employeeId + const sender = employee + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, 100000, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId').toString() + }) + + context('when the employee was not terminated', () => { + it('returns true', async () => { + assert(await payroll.canForward(sender, '0x'), 'sender should be able to forward') + }) + }) + + context('when the employee was already terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + await payroll.mockAddTimestamp(ONE_MONTH + 1) + }) + + it('returns true', async () => { + assert(await payroll.canForward(sender, '0x'), 'sender should be able to forward') + }) + }) + }) + + context('when the sender is not an employee', () => { + const sender = anyone + + it('returns false', async () => { + assert.isFalse(await payroll.canForward(sender, '0x'), 'sender should not be able to forward') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('returns false', async () => { + assert.isFalse(await payroll.canForward(employee, '0x'), 'sender should not be able to forward') + }) + }) + }) + + describe('forward', () => { + let executionTarget, script + + beforeEach('build script', async () => { + executionTarget = await ExecutionTarget.new() + const action = { to: executionTarget.address, calldata: executionTarget.contract.execute.getData() } + script = encodeCallScript([action]) + }) + + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender is an employee', () => { + let employeeId + const from = employee + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, 100000, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId').toString() + }) + + context('when the employee was not terminated', () => { + it('executes the given script', async () => { + await payroll.forward(script, { from }) + + assert.equal(await executionTarget.counter(), 1, 'should have received execution calls') + }) + }) + + context('when the employee was already terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + await payroll.mockAddTimestamp(ONE_MONTH + 1) + }) + + it('executes the given script', async () => { + await payroll.forward(script, { from }) + + assert.equal(await executionTarget.counter(), 1, 'should have received execution calls') + }) + }) + }) + + context('when the sender is not an employee', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.forward(script, { from }), 'PAYROLL_NO_FORWARD') + + assert.equal(await executionTarget.counter(), 0, 'should not have received execution calls') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.forward(script, { from: employee }), 'PAYROLL_NO_FORWARD') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_gas_costs.test.js b/future-apps/payroll/test/contracts/Payroll_gas_costs.test.js new file mode 100644 index 0000000000..b3da215d1f --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_gas_costs.test.js @@ -0,0 +1,73 @@ +const { annualSalaryPerSecond } = require('../helpers/numbers')(web3) +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +contract('Payroll gas costs', ([owner, employee, anotherEmployee]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('gas costs', () => { + let erc20Token1, erc20Token2 + + before('deploy tokens', async () => { + erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 1', 16) + erc20Token2 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 2', 18) + }) + + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + await payroll.mockSetTimestamp(NOW) + + const startDate = NOW - ONE_MONTH + const salary = annualSalaryPerSecond(100000, TOKEN_DECIMALS) + await payroll.addEmployee(employee, salary, 'Boss', startDate) + await payroll.addEmployee(anotherEmployee, salary, 'Manager', startDate) + }) + + context('when there are not allowed tokens yet', function () { + it('expends ~314k gas for a single allowed token', async () => { + await payroll.addAllowedToken(denominationToken.address) + await payroll.determineAllocation([denominationToken.address], [100], { from: employee }) + + const { receipt: { cumulativeGasUsed } } = await payroll.payday({ from: employee }) + + assert.isBelow(cumulativeGasUsed, 317000, 'payout gas cost for a single allowed token should be ~314k') + }) + }) + + context('when there are some allowed tokens', function () { + beforeEach('allow tokens', async () => { + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + await payroll.addAllowedToken(erc20Token1.address, { from: owner }) + await payroll.addAllowedToken(erc20Token2.address, { from: owner }) + }) + + it('expends ~270k gas per allowed token', async () => { + await payroll.determineAllocation([denominationToken.address, erc20Token1.address], [60, 40], { from: employee }) + const { receipt: { cumulativeGasUsed: employeePayoutGasUsed } } = await payroll.payday({ from: employee }) + + await payroll.determineAllocation([denominationToken.address, erc20Token1.address, erc20Token2.address], [65, 25, 10], { from: anotherEmployee }) + const { receipt: { cumulativeGasUsed: anotherEmployeePayoutGasUsed } } = await payroll.payday({ from: anotherEmployee }) + + const gasPerAllowedToken = anotherEmployeePayoutGasUsed - employeePayoutGasUsed + assert.isBelow(gasPerAllowedToken, 280000, 'payout gas cost increment per allowed token should be ~270k') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_get_employee.test.js b/future-apps/payroll/test/contracts/Payroll_get_employee.test.js new file mode 100644 index 0000000000..56825866ab --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_get_employee.test.js @@ -0,0 +1,113 @@ +const { maxUint64 } = require('../helpers/numbers')(web3) +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { getEventArgument } = require('../helpers/events') +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +contract('Payroll employee getters', ([owner, employee]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + const currentTimestamp = async () => payroll.getTimestampPublic() + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('getEmployee', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the given id exists', () => { + let employeeId + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, 1000, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId').toString() + }) + + it('adds a new employee', async () => { + const [address, salary, accruedValue, lastPayroll, endDate] = await payroll.getEmployee(employeeId) + + assert.equal(address, employee, 'employee address does not match') + assert.equal(accruedValue, 0, 'Employee accrued value does not match') + assert.equal(salary.toString(), 1000, 'Employee salary does not match') + assert.equal(lastPayroll.toString(), (await currentTimestamp()).toString(), 'last payroll should match') + assert.equal(endDate.toString(), maxUint64(), 'last payroll should match') + }) + }) + + context('when the given id does not exist', () => { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + }) + + context('when it has not been initialized yet', function () { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + }) + + describe('getEmployeeByAddress', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the given address exists', () => { + let employeeId + const address = employee + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, 1000, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + it('adds a new employee', async () => { + const [id, salary, accruedValue, lastPayroll, endDate] = await payroll.getEmployeeByAddress(address) + + assert.equal(id.toString(), employeeId.toString(), 'employee id does not match') + assert.equal(salary.toString(), 1000, 'employee salary does not match') + assert.equal(accruedValue.toString(), 0, 'employee accrued value does not match') + assert.equal(lastPayroll.toString(), (await currentTimestamp()).toString(), 'last payroll should match') + assert.equal(endDate.toString(), maxUint64(), 'last payroll should match') + }) + }) + + context('when the given id does not exist', () => { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.getEmployeeByAddress(employee), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_initialize.test.js b/future-apps/payroll/test/contracts/Payroll_initialize.test.js new file mode 100644 index 0000000000..1b85dc3dde --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_initialize.test.js @@ -0,0 +1,72 @@ +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +contract('Payroll initialization', ([owner]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('initialize', function () { + const from = owner + + it('cannot initialize the base app', async () => { + assert(await payrollBase.isPetrified(), 'base payroll app should be petrified') + await assertRevert(payrollBase.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from }), 'INIT_ALREADY_INITIALIZED') + }) + + context('when it has not been initialized yet', function () { + it('can be initialized with a zero denomination token', async () => { + payroll.initialize(finance.address, ZERO_ADDRESS, priceFeed.address, RATE_EXPIRATION_TIME, { from }) + assert.equal(await payroll.denominationToken(), ZERO_ADDRESS, 'denomination token does not match') + }) + + it('reverts when passing an expiration time lower than or equal to a minute', async () => { + const ONE_MINUTE = 60 + await assertRevert(payroll.initialize(finance.address, denominationToken.address, priceFeed.address, ONE_MINUTE, { from }), 'PAYROLL_EXPIRY_TIME_TOO_SHORT') + }) + + it('reverts when passing an invalid finance instance', async () => { + await assertRevert(payroll.initialize(ZERO_ADDRESS, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from }), 'PAYROLL_FINANCE_NOT_CONTRACT') + }) + + it('reverts when passing an invalid feed instance', async () => { + await assertRevert(payroll.initialize(finance.address, denominationToken.address, ZERO_ADDRESS, RATE_EXPIRATION_TIME, { from }), 'PAYROLL_FEED_NOT_CONTRACT') + }) + }) + + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from }) + }) + + it('cannot be initialized again', async () => { + await assertRevert(payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from }), 'INIT_ALREADY_INITIALIZED') + }) + + it('has a price feed instance, a finance instance, a denomination token and a rate expiration time', async () => { + assert.equal(await payroll.feed(), priceFeed.address, 'feed address does not match') + assert.equal(await payroll.finance(), finance.address, 'finance address should match') + assert.equal(await payroll.denominationToken(), denominationToken.address, 'denomination token does not match') + assert.equal(await payroll.rateExpiryTime(), RATE_EXPIRATION_TIME, 'rate expiration time does not match') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_modify_employee.test.js b/future-apps/payroll/test/contracts/Payroll_modify_employee.test.js new file mode 100644 index 0000000000..6fafc7afd3 --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_modify_employee.test.js @@ -0,0 +1,223 @@ +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { annualSalaryPerSecond } = require('../helpers/numbers')(web3) +const { getEvents, getEventArgument } = require('../helpers/events') +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +contract('Payroll employees modification', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('setEmployeeSalary', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender has permissions', () => { + const from = owner + + context('when the given employee exists', () => { + let employeeId + const previousSalary = annualSalaryPerSecond(100000, TOKEN_DECIMALS) + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, previousSalary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + context('when the given employee is active', () => { + + const itSetsSalarySuccessfully = newSalary => { + it('changes the salary of the employee', async () => { + await payroll.setEmployeeSalary(employeeId, newSalary, { from }) + + const salary = (await payroll.getEmployee(employeeId))[1] + assert.equal(salary.toString(), newSalary, 'accrued value does not match') + }) + + it('adds previous owed salary to the accrued value', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + + await payroll.setEmployeeSalary(employeeId, newSalary, { from }) + await payroll.mockAddTimestamp(ONE_MONTH) + + const accruedValue = (await payroll.getEmployee(employeeId))[2] + assert.equal(accruedValue.toString(), previousSalary * ONE_MONTH, 'accrued value does not match') + }) + + it('emits an event', async () => { + const receipt = await payroll.setEmployeeSalary(employeeId, newSalary, { from }) + + const events = getEvents(receipt, 'SetEmployeeSalary') + assert.equal(events.length, 1, 'number of SetEmployeeSalary emitted events does not match') + assert.equal(events[0].args.employeeId.toString(), employeeId, 'employee id does not match') + assert.equal(events[0].args.denominationSalary.toString(), newSalary, 'salary does not match') + }) + } + + context('when the given value greater than zero', () => { + const newSalary = 1000 + + itSetsSalarySuccessfully(newSalary) + }) + + context('when the given value is zero', () => { + const newSalary = 0 + + itSetsSalarySuccessfully(newSalary) + }) + }) + + context('when the given employee is not active', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + it('reverts', async () => { + await assertRevert(payroll.setEmployeeSalary(employeeId, 1000, { from }), 'PAYROLL_NON_ACTIVE_EMPLOYEE') + }) + }) + }) + + context('when the given employee does not exist', async () => { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.setEmployeeSalary(employeeId, 1000, { from }), 'PAYROLL_NON_ACTIVE_EMPLOYEE') + }) + }) + }) + + context('when the sender does not have permissions', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.setEmployeeSalary(0, 1000, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when it has not been initialized yet', function () { + const employeeId = 0 + const salary = 10000 + + it('reverts', async () => { + await assertRevert(payroll.setEmployeeSalary(employeeId, salary, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('changeAddressByEmployee', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender is an employee', () => { + const from = employee + let employeeId + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, 1000, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + const itHandlesChangingEmployeeAddressSuccessfully = () => { + context('when the given address is a plain new address', () => { + const newAddress = anyone + + it('changes the address of the employee', async () => { + await payroll.changeAddressByEmployee(newAddress, { from }) + + const [address] = await payroll.getEmployee(employeeId) + assert.equal(address, newAddress, 'employee address does not match') + }) + + it('emits an event', async () => { + const receipt = await payroll.changeAddressByEmployee(newAddress, { from }) + + const events = getEvents(receipt, 'ChangeAddressByEmployee') + assert.equal(events.length, 1, 'number of ChangeAddressByEmployee emitted events does not match') + assert.equal(events[0].args.employeeId.toString(), employeeId, 'employee id does not match') + assert.equal(events[0].args.oldAddress, employee, 'previous address does not match') + assert.equal(events[0].args.newAddress, newAddress, 'new address does not match') + }) + }) + + context('when the given address is the same address', () => { + const newAddress = employee + + it('reverts', async () => { + await assertRevert(payroll.changeAddressByEmployee(newAddress, { from }), 'PAYROLL_EMPLOYEE_ALREADY_EXIST') + }) + }) + + context('when the given address belongs to another employee', () => { + beforeEach('add another employee', async () => { + await payroll.addEmployeeNow(anotherEmployee, 1000, 'Boss', { from: owner }) + }) + + it('reverts', async () => { + await assertRevert(payroll.changeAddressByEmployee(anotherEmployee, { from }), 'PAYROLL_EMPLOYEE_ALREADY_EXIST') + }) + }) + + context('when the given address is the zero address', () => { + const newAddress = ZERO_ADDRESS + + it('reverts', async () => { + await assertRevert(payroll.changeAddressByEmployee(newAddress, { from }), 'PAYROLL_EMPLOYEE_NULL_ADDRESS') + }) + }) + } + + context('when the given employee is active', () => { + itHandlesChangingEmployeeAddressSuccessfully() + }) + + context('when the given employee is not active', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + itHandlesChangingEmployeeAddressSuccessfully() + }) + }) + + context('when the sender is not an employee', async () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.changeAddressByEmployee(anotherEmployee, { from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.changeAddressByEmployee(anotherEmployee, { from: anyone }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_payday.test.js b/future-apps/payroll/test/contracts/Payroll_payday.test.js new file mode 100644 index 0000000000..d2e6edc031 --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_payday.test.js @@ -0,0 +1,1103 @@ +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { getEventArgument } = require('../helpers/events') +const { bn, bigExp, maxUint256 } = require('../helpers/numbers')(web3) +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +contract('Payroll payday', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const PCT_ONE = bigExp(1, 18) + const TOKEN_DECIMALS = 18 + + const currentTimestamp = async () => payroll.getTimestampPublic() + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('payday', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender is an employee', () => { + let employeeId + const from = employee + + context('when the employee has a reasonable salary', () => { + const salary = 10000 + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + context('when the employee has already set some token allocations', () => { + const denominationTokenAllocation = 80 + const anotherTokenAllocation = 20 + + beforeEach('set tokens allocation', async () => { + await payroll.addAllowedToken(anotherToken.address, { from: owner }) + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + await payroll.determineAllocation([denominationToken.address, anotherToken.address], [denominationTokenAllocation, anotherTokenAllocation], { from }) + }) + + context('when the employee has some pending salary', () => { + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + const assertTransferredAmounts = () => { + const expectedOwedAmount = salary * ONE_MONTH + const expectedDenominationTokenAmount = Math.round(expectedOwedAmount * denominationTokenAllocation / 100) + const expectedAnotherTokenAmount = Math.round(expectedOwedAmount * anotherTokenAllocation / 100) + + it('transfers the owed salary', async () => { + const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) + const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) + + await payroll.payday({ from }) + + const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) + assert.equal(currentDenominationTokenBalance.toString(), previousDenominationTokenBalance.plus(expectedDenominationTokenAmount).toString(), 'current denomination token balance does not match') + + const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const expectedAnotherTokenBalance = anotherTokenRate.mul(expectedAnotherTokenAmount).plus(previousAnotherTokenBalance) + assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') + }) + + it('emits one event per allocated token', async () => { + const receipt = await payroll.payday({ from }) + + const events = receipt.logs.filter(l => l.event === 'SendPayment') + assert.equal(events.length, 2, 'should have emitted two events') + + const denominationTokenEvent = events.find(e => e.args.token === denominationToken.address).args + assert.equal(denominationTokenEvent.employee, employee, 'employee address does not match') + assert.equal(denominationTokenEvent.token, denominationToken.address, 'denomination token address does not match') + assert.equal(denominationTokenEvent.amount.toString(), expectedDenominationTokenAmount, 'payment amount does not match') + assert.equal(denominationTokenEvent.reference, 'Payroll', 'payment reference does not match') + + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const anotherTokenEvent = events.find(e => e.args.token === anotherToken.address).args + assert.equal(anotherTokenEvent.employee, employee, 'employee address does not match') + assert.equal(anotherTokenEvent.token, anotherToken.address, 'token address does not match') + assert.equal(anotherTokenEvent.amount.div(anotherTokenRate).toString(), expectedAnotherTokenAmount, 'payment amount does not match') + assert.equal(anotherTokenEvent.reference, 'Payroll', 'payment reference does not match') + }) + + it('can be called multiple times between periods of time', async () => { + // terminate employee in the future to ensure we can request payroll multiple times + await payroll.terminateEmployee(employeeId, NOW + TWO_MONTHS + TWO_MONTHS, { from: owner }) + + const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) + const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) + + await payroll.payday({ from }) + + await payroll.mockAddTimestamp(ONE_MONTH) + await priceFeed.mockAddTimestamp(ONE_MONTH) + await payroll.payday({ from }) + + const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) + const expectedDenominationTokenBalance = previousDenominationTokenBalance.plus(expectedDenominationTokenAmount * 2) + assert.equal(currentDenominationTokenBalance.toString(), expectedDenominationTokenBalance.toString(), 'current denomination token balance does not match') + + const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const expectedAnotherTokenBalance = anotherTokenRate.mul(expectedAnotherTokenAmount * 2).plus(previousAnotherTokenBalance) + assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') + }) + } + + const assertEmployeeIsUpdated = () => { + it('updates the last payroll date', async () => { + await payroll.payday({ from }) + + const lastPayrollDate = (await payroll.getEmployee(employeeId))[3] + assert.equal(lastPayrollDate.toString(), (await currentTimestamp()).toString(), 'last payroll date does not match') + }) + + it('does not remove the employee', async () => { + await payroll.payday({ from }) + + const [address, employeeSalary] = await payroll.getEmployee(employeeId) + + assert.equal(address, employee, 'employee address does not match') + assert.equal(employeeSalary, salary, 'employee salary does not match') + }) + } + + const itHandlesPaydayProperly = () => { + context('when exchange rates are not expired', () => { + assertTransferredAmounts() + assertEmployeeIsUpdated() + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.payday({ from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + } + + context('when the employee has some pending reimbursements', () => { + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, 1000, { from: owner }) + }) + + context('when the employee is not terminated', () => { + itHandlesPaydayProperly() + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesPaydayProperly() + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the employee is not terminated', () => { + itHandlesPaydayProperly() + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + context('when exchange rates are not expired', () => { + assertTransferredAmounts() + + it('removes the employee', async () => { + await payroll.payday({ from }) + + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.payday({ from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + }) + }) + }) + + context('when the employee does not have pending salary', () => { + it('reverts', async () => { + await assertRevert(payroll.payday({ from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + + context('when the employee did not set any token allocations yet', () => { + context('when the employee has some pending salary', () => { + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + it('reverts', async () => { + await assertRevert(payroll.payday({ from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the employee does not have pending salary', () => { + it('reverts', async () => { + await assertRevert(payroll.payday({ from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + }) + + const itReverts = reason => { + it('reverts', async () => { + await assertRevert(payroll.payday({ from }), reason) + }) + } + + const itRevertsHandlingExpiredRates = (nonExpiredRatesReason, expiredRatesReason) => { + context('when exchange rates are not expired', () => { + itReverts(nonExpiredRatesReason) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + itReverts(expiredRatesReason) + }) + } + + const itRevertsToWithdrawPayroll = (nonExpiredRatesReason, expiredRatesReason) => { + context('when the employee has some pending reimbursements', () => { + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, 1000, { from: owner }) + }) + + context('when the employee is not terminated', () => { + itRevertsHandlingExpiredRates(nonExpiredRatesReason, expiredRatesReason) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itRevertsHandlingExpiredRates(nonExpiredRatesReason, expiredRatesReason) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the employee is not terminated', () => { + itRevertsHandlingExpiredRates(nonExpiredRatesReason, expiredRatesReason) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itRevertsHandlingExpiredRates(nonExpiredRatesReason, expiredRatesReason) + }) + }) + } + + const itRevertsAnyAttemptToWithdrawPayroll = (nonExpiredRatesReason, expiredRatesReason) => { + context('when the employee has already set some token allocations', () => { + const denominationTokenAllocation = 80 + const anotherTokenAllocation = 20 + + beforeEach('set tokens allocation', async () => { + await payroll.addAllowedToken(anotherToken.address, { from: owner }) + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + await payroll.determineAllocation([denominationToken.address, anotherToken.address], [denominationTokenAllocation, anotherTokenAllocation], { from }) + }) + + context('when the employee has some pending salary', () => { + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + itRevertsToWithdrawPayroll(nonExpiredRatesReason, expiredRatesReason) + }) + + context('when the employee does not have pending salary', () => { + itRevertsToWithdrawPayroll('PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the employee did not set any token allocations yet', () => { + context('when the employee has some pending salary', () => { + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + itRevertsToWithdrawPayroll('PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + + context('when the employee does not have pending salary', () => { + itRevertsToWithdrawPayroll('PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + }) + } + + context('when the employee has a zero salary', () => { + const salary = 0 + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + itRevertsAnyAttemptToWithdrawPayroll('PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + + context('when the employee has a huge salary', () => { + const salary = maxUint256() + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + itRevertsAnyAttemptToWithdrawPayroll('MATH_MUL_OVERFLOW', 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + + context('when the sender is not an employee', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.payday({ from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.payday({ from: employee }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + + describe('partialPayday', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender is an employee', () => { + let employeeId + const from = employee + + context('when the employee has a reasonable salary', () => { + const salary = 100000 + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + context('when the employee has already set some token allocations', () => { + const denominationTokenAllocation = 80 + const anotherTokenAllocation = 20 + + beforeEach('set tokens allocation', async () => { + await payroll.addAllowedToken(anotherToken.address, { from: owner }) + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + await payroll.determineAllocation([denominationToken.address, anotherToken.address], [denominationTokenAllocation, anotherTokenAllocation], { from }) + }) + + context('when the employee has some pending salary', () => { + const owedSalary = salary * ONE_MONTH + + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + const assertTransferredAmounts = (requestedAmount, expectedRequestedAmount = requestedAmount) => { + const requestedDenominationTokenAmount = Math.round(expectedRequestedAmount * denominationTokenAllocation / 100) + const requestedAnotherTokenAmount = Math.round(expectedRequestedAmount * anotherTokenAllocation / 100) + + it('transfers the requested salary amount', async () => { + const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) + const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) + + await payroll.partialPayday(requestedAmount, { from }) + + const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) + const expectedDenominationTokenBalance = previousDenominationTokenBalance.plus(requestedDenominationTokenAmount); + assert.equal(currentDenominationTokenBalance.toString(), expectedDenominationTokenBalance.toString(), 'current denomination token balance does not match') + + const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const expectedAnotherTokenBalance = anotherTokenRate.mul(requestedAnotherTokenAmount).plus(previousAnotherTokenBalance).trunc() + assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') + }) + + it('emits one event per allocated token', async () => { + const receipt = await payroll.partialPayday(requestedAmount, { from }) + + const events = receipt.logs.filter(l => l.event === 'SendPayment') + assert.equal(events.length, 2, 'should have emitted two events') + + const denominationTokenEvent = events.find(e => e.args.token === denominationToken.address).args + assert.equal(denominationTokenEvent.employee, employee, 'employee address does not match') + assert.equal(denominationTokenEvent.token, denominationToken.address, 'denomination token address does not match') + assert.equal(denominationTokenEvent.amount.toString(), requestedDenominationTokenAmount, 'payment amount does not match') + assert.equal(denominationTokenEvent.reference, 'Payroll', 'payment reference does not match') + + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const anotherTokenEvent = events.find(e => e.args.token === anotherToken.address).args + assert.equal(anotherTokenEvent.employee, employee, 'employee address does not match') + assert.equal(anotherTokenEvent.token, anotherToken.address, 'token address does not match') + assert.equal(anotherTokenEvent.amount.div(anotherTokenRate).trunc().toString(), Math.round(requestedAnotherTokenAmount), 'payment amount does not match') + assert.equal(anotherTokenEvent.reference, 'Payroll', 'payment reference does not match') + }) + + it('can be called multiple times between periods of time', async () => { + // terminate employee in the future to ensure we can request payroll multiple times + await payroll.terminateEmployee(employeeId, NOW + TWO_MONTHS + TWO_MONTHS, { from: owner }) + + const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) + const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) + + await payroll.partialPayday(requestedAmount, { from }) + + await payroll.mockAddTimestamp(ONE_MONTH) + await priceFeed.mockAddTimestamp(ONE_MONTH) + await payroll.partialPayday(requestedAmount, { from }) + + const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) + const expectedDenominationTokenBalance = previousDenominationTokenBalance.plus(requestedDenominationTokenAmount * 2) + assert.equal(currentDenominationTokenBalance.toString(), expectedDenominationTokenBalance.toString(), 'current denomination token balance does not match') + + const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const expectedAnotherTokenBalance = anotherTokenRate.mul(requestedAnotherTokenAmount * 2).plus(previousAnotherTokenBalance) + assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') + }) + } + + const assertEmployeeIsUpdated = (requestedAmount, expectedRequestedAmount) => { + it('updates the last payroll date', async () => { + const previousPayrollDate = (await payroll.getEmployee(employeeId))[3] + const expectedLastPayrollDate = previousPayrollDate.plus(Math.floor(expectedRequestedAmount / salary)) + + await payroll.partialPayday(requestedAmount, { from }) + + const lastPayrollDate = (await payroll.getEmployee(employeeId))[3] + assert.equal(lastPayrollDate.toString(), expectedLastPayrollDate.toString(), 'last payroll date does not match') + }) + + it('does not remove the employee', async () => { + await payroll.partialPayday(requestedAmount, { from }) + + const [address, employeeSalary] = await payroll.getEmployee(employeeId) + + assert.equal(address, employee, 'employee address does not match') + assert.equal(employeeSalary, salary, 'employee salary does not match') + }) + } + + const itHandlesPayrollProperly = (requestedAmount, expectedRequestedAmount = requestedAmount) => { + context('when exchange rates are not expired', () => { + assertTransferredAmounts(requestedAmount, expectedRequestedAmount) + assertEmployeeIsUpdated(requestedAmount, expectedRequestedAmount) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + } + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + context('when the employee has some pending reimbursements', () => { + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, 1000, { from: owner }) + }) + + context('when the employee is not terminated', () => { + itHandlesPayrollProperly(requestedAmount, owedSalary) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesPayrollProperly(requestedAmount, owedSalary) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the employee is not terminated', () => { + itHandlesPayrollProperly(requestedAmount, owedSalary) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + context('when exchange rates are not expired', () => { + assertTransferredAmounts(requestedAmount, owedSalary) + + it('removes the employee', async () => { + await payroll.partialPayday(requestedAmount, { from }) + + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + }) + }) + }) + + context('when the requested amount is less than the total owed salary', () => { + const requestedAmount = owedSalary - 10 + + context('when the employee has some pending reimbursements', () => { + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, 1000, { from: owner }) + }) + + context('when the employee is not terminated', () => { + itHandlesPayrollProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesPayrollProperly(requestedAmount) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the employee is not terminated', () => { + itHandlesPayrollProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesPayrollProperly(requestedAmount) + }) + }) + }) + + context('when the requested amount is equal to the total owed salary', () => { + const requestedAmount = owedSalary + + context('when the employee has some pending reimbursements', () => { + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, 1000, { from: owner }) + }) + + context('when the employee is not terminated', () => { + itHandlesPayrollProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesPayrollProperly(requestedAmount) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the employee is not terminated', () => { + itHandlesPayrollProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + context('when exchange rates are not expired', () => { + assertTransferredAmounts(requestedAmount) + + it('removes the employee', async () => { + await payroll.partialPayday(requestedAmount, { from }) + + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + }) + }) + }) + + context('when the requested amount is greater than the total owed salary', () => { + const requestedAmount = owedSalary + 1 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_INVALID_REQUESTED_AMT') + }) + }) + }) + + context('when the employee does not have pending salary', () => { + context('when the requested amount is greater than zero', () => { + const requestedAmount = 100 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + }) + + context('when the employee did not set any token allocations yet', () => { + context('when the employee has some pending salary', () => { + const owedSalary = salary * ONE_MONTH + + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + context('when the requested amount is less than the total owed salary', () => { + const requestedAmount = owedSalary - 10 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the requested amount is equal to the total owed salary', () => { + const requestedAmount = owedSalary + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + + context('when the employee does not have pending salary', () => { + context('when the requested amount is greater than zero', () => { + const requestedAmount = 100 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + }) + }) + + const itReverts = (requestedAmount, reason) => { + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, {from}), reason) + }) + } + + const itRevertsHandlingExpiredRates = (requestedAmount, nonExpiredRatesReason, expiredRatesReason) => { + context('when exchange rates are not expired', () => { + itReverts(requestedAmount, nonExpiredRatesReason) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + itReverts(requestedAmount, expiredRatesReason) + }) + } + + const itRevertsToWithdrawPartialPayroll = (requestedAmount, nonExpiredRatesReason, expiredRatesReason) => { + context('when the employee has some pending reimbursements', () => { + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, 1000, {from: owner}) + }) + + context('when the employee is not terminated', () => { + itRevertsHandlingExpiredRates(requestedAmount, nonExpiredRatesReason, expiredRatesReason) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, {from: owner}) + }) + + itRevertsHandlingExpiredRates(requestedAmount, nonExpiredRatesReason, expiredRatesReason) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the employee is not terminated', () => { + itRevertsHandlingExpiredRates(requestedAmount, nonExpiredRatesReason, expiredRatesReason) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, {from: owner}) + }) + + itRevertsHandlingExpiredRates(requestedAmount, nonExpiredRatesReason, expiredRatesReason) + }) + }) + } + + context('when the employee has a zero salary', () => { + const salary = 0 + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + const itRevertsAnyAttemptToWithdrawPartialPayroll = () => { + context('when the employee has some pending salary', () => { + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + context('when the requested amount is greater than zero', () => { + const requestedAmount = 10000 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the employee does not have pending salary', () => { + context('when the requested amount is greater than zero', () => { + const requestedAmount = 1000 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + }) + } + + context('when the employee has already set some token allocations', () => { + const denominationTokenAllocation = 80 + const anotherTokenAllocation = 20 + + beforeEach('set tokens allocation', async () => { + await payroll.addAllowedToken(anotherToken.address, {from: owner}) + await payroll.addAllowedToken(denominationToken.address, {from: owner}) + await payroll.determineAllocation([denominationToken.address, anotherToken.address], [denominationTokenAllocation, anotherTokenAllocation], {from}) + }) + + itRevertsAnyAttemptToWithdrawPartialPayroll() + }) + + context('when the employee did not set any token allocations yet', () => { + itRevertsAnyAttemptToWithdrawPartialPayroll() + }) + }) + + context('when the employee has a huge salary', () => { + const salary = maxUint256() + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + context('when the employee has already set some token allocations', () => { + const denominationTokenAllocation = 80 + const anotherTokenAllocation = 20 + + beforeEach('set tokens allocation', async () => { + await payroll.addAllowedToken(anotherToken.address, {from: owner}) + await payroll.addAllowedToken(denominationToken.address, {from: owner}) + await payroll.determineAllocation([denominationToken.address, anotherToken.address], [denominationTokenAllocation, anotherTokenAllocation], {from}) + }) + + context('when the employee has some pending salary', () => { + const owedSalary = maxUint256() + + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'MATH_MUL_OVERFLOW', 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + + context('when the requested amount is less than the total owed salary', () => { + const requestedAmount = 10000 + + const assertTransferredAmounts = requestedAmount => { + const requestedDenominationTokenAmount = Math.round(requestedAmount * denominationTokenAllocation / 100) + const requestedAnotherTokenAmount = Math.round(requestedAmount * anotherTokenAllocation / 100) + + it('transfers the requested salary amount', async () => { + const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) + const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) + + await payroll.partialPayday(requestedAmount, { from }) + + const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) + const expectedDenominationTokenBalance = previousDenominationTokenBalance.plus(requestedDenominationTokenAmount); + assert.equal(currentDenominationTokenBalance.toString(), expectedDenominationTokenBalance.toString(), 'current denomination token balance does not match') + + const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const expectedAnotherTokenBalance = anotherTokenRate.mul(requestedAnotherTokenAmount).plus(previousAnotherTokenBalance).trunc() + assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') + }) + + it('emits one event per allocated token', async () => { + const receipt = await payroll.partialPayday(requestedAmount, { from }) + + const events = receipt.logs.filter(l => l.event === 'SendPayment') + assert.equal(events.length, 2, 'should have emitted two events') + + const denominationTokenEvent = events.find(e => e.args.token === denominationToken.address).args + assert.equal(denominationTokenEvent.employee, employee, 'employee address does not match') + assert.equal(denominationTokenEvent.token, denominationToken.address, 'denomination token address does not match') + assert.equal(denominationTokenEvent.amount.toString(), requestedDenominationTokenAmount, 'payment amount does not match') + assert.equal(denominationTokenEvent.reference, 'Payroll', 'payment reference does not match') + + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const anotherTokenEvent = events.find(e => e.args.token === anotherToken.address).args + assert.equal(anotherTokenEvent.employee, employee, 'employee address does not match') + assert.equal(anotherTokenEvent.token, anotherToken.address, 'token address does not match') + assert.equal(anotherTokenEvent.amount.div(anotherTokenRate).trunc().toString(), Math.round(requestedAnotherTokenAmount), 'payment amount does not match') + assert.equal(anotherTokenEvent.reference, 'Payroll', 'payment reference does not match') + }) + + it('can be called multiple times between periods of time', async () => { + // terminate employee in the future to ensure we can request payroll multiple times + await payroll.terminateEmployee(employeeId, NOW + TWO_MONTHS + TWO_MONTHS, { from: owner }) + + const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) + const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) + + await payroll.partialPayday(requestedAmount, { from }) + + await payroll.mockAddTimestamp(ONE_MONTH) + await priceFeed.mockAddTimestamp(ONE_MONTH) + await payroll.partialPayday(requestedAmount, { from }) + + const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) + const expectedDenominationTokenBalance = previousDenominationTokenBalance.plus(requestedDenominationTokenAmount * 2) + assert.equal(currentDenominationTokenBalance.toString(), expectedDenominationTokenBalance.toString(), 'current denomination token balance does not match') + + const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const expectedAnotherTokenBalance = anotherTokenRate.mul(requestedAnotherTokenAmount * 2).plus(previousAnotherTokenBalance) + assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') + }) + } + + const assertEmployeeIsUpdated = requestedAmount => { + it('updates the last payroll date', async () => { + const previousPayrollDate = (await payroll.getEmployee(employeeId))[3] + const expectedLastPayrollDate = previousPayrollDate.plus(Math.floor(bn(requestedAmount).div(salary))) + + await payroll.partialPayday(requestedAmount, { from }) + + const lastPayrollDate = (await payroll.getEmployee(employeeId))[3] + assert.equal(lastPayrollDate.toString(), expectedLastPayrollDate.toString(), 'last payroll date does not match') + }) + + it('does not remove the employee', async () => { + await payroll.partialPayday(requestedAmount, { from }) + + const [address, employeeSalary] = await payroll.getEmployee(employeeId) + + assert.equal(address, employee, 'employee address does not match') + assert.equal(employeeSalary.toString(), salary.toString()) + }) + } + + const itHandlesPayrollProperly = requestedAmount => { + context('when exchange rates are not expired', () => { + assertTransferredAmounts(requestedAmount) + assertEmployeeIsUpdated(requestedAmount) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + } + + context('when the employee has some pending reimbursements', () => { + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, 1000, { from: owner }) + }) + + context('when the employee is not terminated', () => { + itHandlesPayrollProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesPayrollProperly(requestedAmount) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the employee is not terminated', () => { + itHandlesPayrollProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesPayrollProperly(requestedAmount) + }) + }) + }) + + context('when the requested amount is equal to the total owed salary', () => { + const requestedAmount = owedSalary + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'MATH_MUL_OVERFLOW', 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + + context('when the employee does not have pending salary', () => { + context('when the requested amount is greater than zero', () => { + const requestedAmount = 100 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + }) + }) + + context('when the employee did not set any token allocations yet', () => { + context('when the employee has some pending salary', () => { + const owedSalary = maxUint256() + + beforeEach('accumulate some pending salary', async () => { + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + + context('when the requested amount is less than the total owed salary', () => { + const requestedAmount = 10000 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + + context('when the requested amount is equal to the total owed salary', () => { + const requestedAmount = owedSalary + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the employee does not have pending salary', () => { + context('when the requested amount is greater than zero', () => { + const requestedAmount = 100 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + itRevertsToWithdrawPartialPayroll(requestedAmount, 'PAYROLL_NOTHING_PAID', 'PAYROLL_NOTHING_PAID') + }) + }) + }) + }) + }) + + context('when the sender is not an employee', () => { + const from = anyone + + context('when the requested amount is greater than zero', () => { + const requestedAmount = 100 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + }) + + context('when it has not been initialized yet', function () { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialPayday(requestedAmount, { from: employee }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_reimbursements.test.js b/future-apps/payroll/test/contracts/Payroll_reimbursements.test.js new file mode 100644 index 0000000000..0b516de1c3 --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_reimbursements.test.js @@ -0,0 +1,693 @@ +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { getEvents, getEventArgument } = require('../helpers/events') +const { bigExp, maxUint256 } = require('../helpers/numbers')(web3) +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +contract('Payroll reimbursements', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const PCT_ONE = bigExp(1, 18) + const TOKEN_DECIMALS = 18 + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('addAccruedValue', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender has permissions', () => { + const from = owner + + context('when the given employee exists', () => { + let employeeId + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, 1000, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + context('when the given employee is active', () => { + + const itAddsAccruedValueSuccessfully = value => { + it('adds requested accrued value', async () => { + await payroll.addAccruedValue(employeeId, value, { from }) + + const accruedValue = (await payroll.getEmployee(employeeId))[2] + assert.equal(accruedValue, value, 'accrued value does not match') + }) + + it('emits an event', async () => { + const receipt = await payroll.addAccruedValue(employeeId, value, { from }) + + const events = getEvents(receipt, 'AddEmployeeAccruedValue') + assert.equal(events.length, 1, 'number of AddEmployeeAccruedValue emitted events does not match') + assert.equal(events[0].args.employeeId.toString(), employeeId, 'employee id does not match') + assert.equal(events[0].args.amount.toString(), value, 'accrued value does not match') + }) + } + + context('when the given value greater than zero', () => { + const value = 1000 + + itAddsAccruedValueSuccessfully(value) + }) + + context('when the given value is zero', () => { + const value = 0 + + itAddsAccruedValueSuccessfully(value) + }) + + context('when the given value way greater than zero', () => { + const value = maxUint256() + + it('reverts', async () => { + await payroll.addAccruedValue(employeeId, 1, { from }) + + await assertRevert(payroll.addAccruedValue(employeeId, value, { from }), 'MATH_ADD_OVERFLOW') + }) + }) + }) + + context('when the given employee is not active', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + it('reverts', async () => { + await assertRevert(payroll.addAccruedValue(employeeId, 1000, { from }), 'PAYROLL_NON_ACTIVE_EMPLOYEE') + }) + }) + }) + + context('when the given employee does not exist', async () => { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.addAccruedValue(employeeId, 1000, { from }), 'PAYROLL_NON_ACTIVE_EMPLOYEE') + }) + }) + }) + + context('when the sender does not have permissions', () => { + const from = anyone + const value = 1000 + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.addAccruedValue(employeeId, value, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when it has not been initialized yet', function () { + const value = 10000 + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.addAccruedValue(employeeId, value, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('reimburse', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender is an employee', () => { + const from = employee + let employeeId, salary = 1000 + + beforeEach('add employee and accumulate some salary', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + context('when the employee has already set some token allocations', () => { + beforeEach('set tokens allocation', async () => { + await payroll.addAllowedToken(anotherToken.address, { from: owner }) + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + await payroll.determineAllocation([denominationToken.address, anotherToken.address], [80, 20], { from }) + }) + + context('when the employee has some pending reimbursements', () => { + const accruedValue = 100 + + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) + await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) + }) + + const assertTransferredAmounts = () => { + it('transfers all the pending reimbursements', async () => { + const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) + const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) + + await payroll.reimburse({ from }) + + const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) + assert.equal(currentDenominationTokenBalance.toString(), previousDenominationTokenBalance.plus(80).toString(), 'current denomination token balance does not match') + + const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const expectedAnotherTokenBalance = anotherTokenRate.mul(20).plus(previousAnotherTokenBalance) + assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') + }) + + it('emits one event per allocated token', async () => { + const receipt = await payroll.reimburse({ from }) + + const events = receipt.logs.filter(l => l.event === 'SendPayment') + assert.equal(events.length, 2, 'should have emitted two events') + + const denominationTokenEvent = events.find(e => e.args.token === denominationToken.address).args + assert.equal(denominationTokenEvent.employee, employee, 'employee address does not match') + assert.equal(denominationTokenEvent.token, denominationToken.address, 'denomination token address does not match') + assert.equal(denominationTokenEvent.amount.toString(), 80, 'payment amount does not match') + assert.equal(denominationTokenEvent.reference, 'Reimbursement', 'payment reference does not match') + + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const anotherTokenEvent = events.find(e => e.args.token === anotherToken.address).args + assert.equal(anotherTokenEvent.employee, employee, 'employee address does not match') + assert.equal(anotherTokenEvent.token, anotherToken.address, 'token address does not match') + assert.equal(anotherTokenEvent.amount.div(anotherTokenRate).toString(), 20, 'payment amount does not match') + assert.equal(anotherTokenEvent.reference, 'Reimbursement', 'payment reference does not match') + }) + } + + const assertEmployeeIsNotRemoved = () => { + it('does not remove the employee and resets the accrued value', async () => { + await payroll.reimburse({ from }) + + const [address, employeeSalary, accruedValue] = await payroll.getEmployee(employeeId) + + assert.equal(address, employee, 'employee address does not match') + assert.equal(employeeSalary, salary, 'employee salary does not match') + assert.equal(accruedValue, 0, 'accrued value should be zero') + }) + } + + const itHandlesReimbursementsProperly = () => { + context('when exchange rates are not expired', () => { + assertTransferredAmounts() + assertEmployeeIsNotRemoved() + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.reimburse({ from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + } + + context('when the employee has some pending salary', () => { + context('when the employee is not terminated', () => { + itHandlesReimbursementsProperly() + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesReimbursementsProperly() + }) + }) + + context('when the employee does not have pending salary', () => { + beforeEach('cash out pending salary', async () => { + await payroll.payday({ from }) + }) + + context('when the employee is not terminated', () => { + itHandlesReimbursementsProperly() + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + context('when exchange rates are not expired', () => { + assertTransferredAmounts() + + it('removes the employee', async () => { + await payroll.reimburse({ from }) + + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.reimburse({ from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + }) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + it('reverts', async () => { + await assertRevert(payroll.reimburse({ from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + + context('when the employee did not set any token allocations yet', () => { + context('when the employee has some pending reimbursements', () => { + const accruedValue = 50 + + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) + await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) + }) + + it('reverts', async () => { + await assertRevert(payroll.reimburse({ from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the employee does not have pending reimbursements', () => { + it('reverts', async () => { + await assertRevert(payroll.reimburse({ from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + }) + + context('when the sender is not an employee', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.reimburse({ from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.reimburse({ from: employee }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + + describe('partialReimburse', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender is an employee', () => { + const from = employee + let employeeId, salary = 1000 + + beforeEach('add employee and accumulate some salary', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + context('when the employee has already set some token allocations', () => { + const denominationTokenAllocation = 80 + const anotherTokenAllocation = 20 + + beforeEach('set tokens allocation', async () => { + await payroll.addAllowedToken(anotherToken.address, { from: owner }) + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + await payroll.determineAllocation([denominationToken.address, anotherToken.address], [denominationTokenAllocation, anotherTokenAllocation], { from }) + }) + + context('when the employee has some pending reimbursements', () => { + const accruedValue = 100 + + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) + await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) + }) + + const assertTransferredAmounts = (requestedAmount, expectedRequestedAmount = requestedAmount) => { + const requestedDenominationTokenAmount = parseInt(expectedRequestedAmount * denominationTokenAllocation / 100) + const requestedAnotherTokenAmount = expectedRequestedAmount * anotherTokenAllocation / 100 + + it('transfers all the pending reimbursements', async () => { + const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) + const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) + + await payroll.partialReimburse(requestedAmount, { from }) + + const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) + const expectedDenominationTokenBalance = previousDenominationTokenBalance.plus(requestedDenominationTokenAmount); + assert.equal(currentDenominationTokenBalance.toString(), expectedDenominationTokenBalance.toString(), 'current denomination token balance does not match') + + const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const expectedAnotherTokenBalance = anotherTokenRate.mul(requestedAnotherTokenAmount).plus(previousAnotherTokenBalance).trunc() + assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') + }) + + it('emits one event per allocated token', async () => { + const receipt = await payroll.partialReimburse(requestedAmount, { from }) + + const events = receipt.logs.filter(l => l.event === 'SendPayment') + assert.equal(events.length, 2, 'should have emitted two events') + + const denominationTokenEvent = events.find(e => e.args.token === denominationToken.address).args + assert.equal(denominationTokenEvent.employee, employee, 'employee address does not match') + assert.equal(denominationTokenEvent.token, denominationToken.address, 'denomination token address does not match') + assert.equal(denominationTokenEvent.amount.toString(), requestedDenominationTokenAmount, 'payment amount does not match') + assert.equal(denominationTokenEvent.reference, 'Reimbursement', 'payment reference does not match') + + const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) + const anotherTokenEvent = events.find(e => e.args.token === anotherToken.address).args + assert.equal(anotherTokenEvent.employee, employee, 'employee address does not match') + assert.equal(anotherTokenEvent.token, anotherToken.address, 'token address does not match') + assert.equal(anotherTokenEvent.amount.div(anotherTokenRate).trunc().toString(), parseInt(requestedAnotherTokenAmount), 'payment amount does not match') + assert.equal(anotherTokenEvent.reference, 'Reimbursement', 'payment reference does not match') + }) + } + + const assertEmployeeIsNotRemoved = (requestedAmount, expectedRequestedAmount = requestedAmount) => { + it('does not remove the employee and resets the accrued value', async () => { + const currentAccruedValue = (await payroll.getEmployee(employeeId))[2] + await payroll.partialReimburse(requestedAmount, { from }) + + const [address, employeeSalary, accruedValue] = await payroll.getEmployee(employeeId) + + assert.equal(address, employee, 'employee address does not match') + assert.equal(employeeSalary, salary, 'employee salary does not match') + assert.equal(currentAccruedValue.minus(expectedRequestedAmount).toString(), accruedValue.toString(), 'accrued value does not match') + }) + } + + const itHandlesReimbursementsProperly = (requestedAmount, expectedRequestedAmount = requestedAmount) => { + context('when exchange rates are not expired', () => { + assertTransferredAmounts(requestedAmount, expectedRequestedAmount) + assertEmployeeIsNotRemoved(requestedAmount, expectedRequestedAmount) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + } + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + context('when the employee has some pending salary', () => { + context('when the employee is not terminated', () => { + itHandlesReimbursementsProperly(requestedAmount, accruedValue) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesReimbursementsProperly(requestedAmount, accruedValue) + }) + }) + + context('when the employee does not have pending salary', () => { + beforeEach('cash out pending salary', async () => { + await payroll.payday({ from }) + }) + + context('when the employee is not terminated', () => { + itHandlesReimbursementsProperly(requestedAmount, accruedValue) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + context('when exchange rates are not expired', () => { + assertTransferredAmounts(requestedAmount, accruedValue) + + it('removes the employee', async () => { + await payroll.partialReimburse(requestedAmount, { from }) + + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + }) + }) + }) + + context('when the requested amount is less than the total accrued value', () => { + const requestedAmount = accruedValue - 1 + + context('when the employee has some pending salary', () => { + context('when the employee is not terminated', () => { + itHandlesReimbursementsProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesReimbursementsProperly(requestedAmount) + }) + }) + + context('when the employee does not have pending salary', () => { + beforeEach('cash out pending salary', async () => { + await payroll.payday({ from }) + }) + + context('when the employee is not terminated', () => { + itHandlesReimbursementsProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesReimbursementsProperly(requestedAmount) + }) + }) + }) + + context('when the requested amount is equal to the total accrued value', () => { + const requestedAmount = accruedValue + + context('when the employee has some pending salary', () => { + context('when the employee is not terminated', () => { + itHandlesReimbursementsProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + itHandlesReimbursementsProperly(requestedAmount) + }) + }) + + context('when the employee does not have pending salary', () => { + beforeEach('cash out pending salary', async () => { + await payroll.payday({ from }) + }) + + context('when the employee is not terminated', () => { + itHandlesReimbursementsProperly(requestedAmount) + }) + + context('when the employee is terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + }) + + context('when exchange rates are not expired', () => { + assertTransferredAmounts(requestedAmount) + + it('removes the employee', async () => { + await payroll.partialReimburse(requestedAmount, { from }) + + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + + context('when exchange rates are expired', () => { + beforeEach('expire exchange rates', async () => { + await priceFeed.mockSetTimestamp(NOW - TWO_MONTHS) + }) + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_EXCHANGE_RATE_ZERO') + }) + }) + }) + }) + }) + + context('when the requested amount is greater than the total accrued value', () => { + const requestedAmount = accruedValue + 1 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_INVALID_REQUESTED_AMT') + }) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the requested amount is greater than zero', () => { + const requestedAmount = 100 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + }) + + context('when the employee did not set any token allocations yet', () => { + context('when the employee has some pending reimbursements', () => { + const accruedValue = 100 + + beforeEach('add accrued value', async () => { + await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) + await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the requested amount is less than the total accrued value', () => { + const requestedAmount = accruedValue - 1 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the requested amount is equal to the total accrued value', () => { + const requestedAmount = accruedValue + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the requested amount is greater than the total accrued value', () => { + const requestedAmount = accruedValue + 1 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_INVALID_REQUESTED_AMT') + }) + }) + }) + + context('when the employee does not have pending reimbursements', () => { + context('when the requested amount is greater than zero', () => { + const requestedAmount = 100 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') + }) + }) + }) + }) + }) + + context('when the sender is not an employee', () => { + const from = anyone + + context('when the requested amount is greater than zero', () => { + const requestedAmount = 100 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + + context('when the requested amount is zero', () => { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + }) + + context('when it has not been initialized yet', function () { + const requestedAmount = 0 + + it('reverts', async () => { + await assertRevert(payroll.partialReimburse(requestedAmount, { from: employee }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_settings.test.js b/future-apps/payroll/test/contracts/Payroll_settings.test.js new file mode 100644 index 0000000000..4f994dc9fd --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_settings.test.js @@ -0,0 +1,148 @@ +const { getEvents } = require('../helpers/events') +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +const PriceFeed = artifacts.require('PriceFeedMock') +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +contract('Payroll settings', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('setPriceFeed', () => { + let newFeedAddress + + beforeEach('deploy new feed', async () => { + newFeedAddress = (await PriceFeed.new()).address + }) + + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender has permissions', async () => { + const from = owner + + context('when the given address is a contract', async () => { + it('updates the feed address', async () => { + await payroll.setPriceFeed(newFeedAddress, { from }) + + assert.equal(await payroll.feed(), newFeedAddress, 'feed address does not match') + }) + + it('emits an event', async () => { + const receipt = await payroll.setPriceFeed(newFeedAddress, { from }) + + const events = getEvents(receipt, 'SetPriceFeed') + assert.equal(events.length, 1, 'number of SetPriceFeed emitted events does not match') + assert.equal(events[0].args.feed, newFeedAddress, 'feed address does not match') + }) + }) + + context('when the given address is not a contract', async () => { + it('reverts', async () => { + await assertRevert(payroll.setPriceFeed(anyone, { from }), 'PAYROLL_FEED_NOT_CONTRACT') + }) + }) + + context('when the given address is the zero address', async () => { + it('reverts', async () => { + await assertRevert(payroll.setPriceFeed(ZERO_ADDRESS, { from }), 'PAYROLL_FEED_NOT_CONTRACT') + }) + }) + }) + + context('when the sender does not have permissions', async () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.setPriceFeed(newFeedAddress, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.setPriceFeed(newFeedAddress, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('setRateExpiryTime', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the sender has permissions', async () => { + const from = owner + + context('when the given time is more than a minute', async () => { + const expirationTime = 61 + + it('updates the expiration time', async () => { + await payroll.setRateExpiryTime(expirationTime, { from }) + + assert.equal((await payroll.rateExpiryTime()).toString(), expirationTime, 'rate expiration time does not match') + }) + + it('emits an event', async () => { + const receipt = await payroll.setRateExpiryTime(expirationTime, { from }) + + const events = getEvents(receipt, 'SetRateExpiryTime') + assert.equal(events.length, 1, 'number of SetRateExpiryTime emitted events does not match') + assert.equal(events[0].args.time.toString(), expirationTime, 'rate expiration time does not match') + }) + }) + + context('when the given expiration time is one minute', async () => { + const expirationTime = 60 + + it('reverts', async () => { + await assertRevert(payroll.setRateExpiryTime(expirationTime, { from }), 'PAYROLL_EXPIRY_TIME_TOO_SHORT') + }) + }) + + context('when the given expiration time is less than a minute', async () => { + const expirationTime = 40 + + it('reverts', async () => { + await assertRevert(payroll.setRateExpiryTime(expirationTime, { from }), 'PAYROLL_EXPIRY_TIME_TOO_SHORT') + }) + }) + }) + + context('when the sender does not have permissions', async () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.setRateExpiryTime(1000, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.setRateExpiryTime(1000, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_terminate_employee.test.js b/future-apps/payroll/test/contracts/Payroll_terminate_employee.test.js new file mode 100644 index 0000000000..7ebbd3d411 --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_terminate_employee.test.js @@ -0,0 +1,323 @@ +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { getEvents, getEventArgument } = require('../helpers/events') +const { bn, maxUint64, annualSalaryPerSecond } = require('../helpers/numbers')(web3) +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +contract('Payroll employees termination', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + const currentTimestamp = async () => payroll.getTimestampPublic() + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('terminateEmployeeNow', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the given employee id exists', () => { + let employeeId + const salary = annualSalaryPerSecond(100000, TOKEN_DECIMALS) + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId').toString() + }) + + context('when the sender has permissions to terminate employees', () => { + const from = owner + + context('when the employee was not terminated', () => { + beforeEach('allow denomination token', async () => { + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + }) + + it('sets the end date of the employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from }) + + const endDate = (await payroll.getEmployee(employeeId))[4] + assert.equal(endDate.toString(), (await currentTimestamp()).toString(), 'employee end date does not match') + }) + + it('emits an event', async () => { + const receipt = await payroll.terminateEmployeeNow(employeeId, { from }) + + const events = getEvents(receipt, 'TerminateEmployee') + assert.equal(events.length, 1, 'number of TerminateEmployee events does not match') + + const event = events[0].args + assert.equal(event.employeeId.toString(), employeeId, 'employee id does not match') + assert.equal(event.accountAddress, employee, 'employee address does not match') + assert.equal(event.endDate.toString(), (await currentTimestamp()).toString(), 'employee end date does not match') + }) + + it('does not reset the owed salary nor the accrued value of the employee', async () => { + const previousBalance = await denominationToken.balanceOf(employee) + await payroll.determineAllocation([denominationToken.address], [100], { from: employee }) + + // Accrue some salary and extras + await payroll.mockAddTimestamp(ONE_MONTH) + const owedSalary = salary.mul(ONE_MONTH) + const accruedValue = 1000 + await payroll.addAccruedValue(employeeId, accruedValue, { from: owner }) + + // Terminate employee and travel some time in the future + await payroll.terminateEmployeeNow(employeeId, { from }) + await payroll.mockAddTimestamp(ONE_MONTH) + + // Request owed money + await payroll.payday({ from: employee }) + await payroll.reimburse({ from: employee }) + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + + const currentBalance = await denominationToken.balanceOf(employee) + const expectedCurrentBalance = previousBalance.plus(owedSalary).plus(accruedValue) + assert.equal(currentBalance.toString(), expectedCurrentBalance.toString(), 'current balance does not match') + }) + + it('can re-add a removed employee', async () => { + await payroll.determineAllocation([denominationToken.address], [100], { from: employee }) + await payroll.mockAddTimestamp(ONE_MONTH) + + // Terminate employee and travel some time in the future + await payroll.terminateEmployeeNow(employeeId, { from }) + await payroll.mockAddTimestamp(ONE_MONTH) + + // Request owed money + await payroll.payday({ from: employee }) + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + + // Add employee back + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + const newEmployeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + + const [address, employeeSalary, accruedValue, lastPayroll, endDate] = await payroll.getEmployee(newEmployeeId) + assert.equal(address, employee, 'Employee account does not match') + assert.equal(employeeSalary.toString(), salary.toString(), 'employee salary does not match') + assert.equal(lastPayroll.toString(), (await currentTimestamp()).toString(), 'employee last payroll date does not match') + assert.equal(accruedValue.toString(), 0, 'employee accrued value does not match') + assert.equal(endDate.toString(), maxUint64(), 'employee end date does not match') + }) + }) + + context('when the employee was already terminated', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from }) + await payroll.mockAddTimestamp(ONE_MONTH + 1) + }) + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployeeNow(employeeId, { from }), 'PAYROLL_NON_ACTIVE_EMPLOYEE') + }) + }) + }) + + context('when the sender does not have permissions to terminate employees', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployeeNow(employeeId, { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when the given employee id does not exist', () => { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployeeNow(employeeId, { from: owner }), 'PAYROLL_NON_ACTIVE_EMPLOYEE') + }) + }) + }) + + context('when it has not been initialized yet', function () { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployeeNow(employeeId, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) + + describe('terminateEmployee', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the given employee id exists', () => { + let employeeId + const salary = annualSalaryPerSecond(100000, TOKEN_DECIMALS) + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId').toString() + }) + + context('when the sender has permissions to terminate employees', () => { + const from = owner + + context('when the employee was not terminated', () => { + let endDate + + beforeEach('allowed denomination token', async () => { + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + }) + + context('when the given end date is in the future ', () => { + beforeEach('set future end date', async () => { + endDate = (await currentTimestamp()).plus(ONE_MONTH) + }) + + it('sets the end date of the employee', async () => { + await payroll.terminateEmployee(employeeId, endDate, { from }) + + const date = (await payroll.getEmployee(employeeId))[4] + assert.equal(date.toString(), endDate.toString(), 'employee end date does not match') + }) + + it('emits an event', async () => { + const receipt = await payroll.terminateEmployee(employeeId, endDate, { from }) + + const events = getEvents(receipt, 'TerminateEmployee') + assert.equal(events.length, 1, 'number of TerminateEmployee events does not match') + + const event = events[0].args + assert.equal(event.employeeId.toString(), employeeId, 'employee id does not match') + assert.equal(event.accountAddress, employee, 'employee address does not match') + assert.equal(event.endDate.toString(), endDate.toString(), 'employee end date does not match') + }) + + it('does not reset the owed salary nor the accrued value of the employee', async () => { + const previousBalance = await denominationToken.balanceOf(employee) + await payroll.determineAllocation([denominationToken.address], [100], { from: employee }) + + // Accrue some salary and extras + await payroll.mockAddTimestamp(ONE_MONTH) + const owedSalary = salary.times(ONE_MONTH) + const accruedValue = 1000 + await payroll.addAccruedValue(employeeId, accruedValue, { from: owner }) + + // Terminate employee and travel some time in the future + await payroll.terminateEmployee(employeeId, endDate, { from }) + await payroll.mockAddTimestamp(ONE_MONTH) + + // Request owed money + await payroll.payday({ from: employee }) + await payroll.reimburse({ from: employee }) + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + + const currentBalance = await denominationToken.balanceOf(employee) + const expectedCurrentBalance = previousBalance.plus(owedSalary).plus(accruedValue) + assert.equal(currentBalance.toString(), expectedCurrentBalance.toString(), 'current balance does not match') + }) + + it('can re-add a removed employee', async () => { + await payroll.determineAllocation([denominationToken.address], [100], { from: employee }) + await payroll.mockAddTimestamp(ONE_MONTH) + + // Terminate employee and travel some time in the future + await payroll.terminateEmployee(employeeId, endDate, { from }) + await payroll.mockAddTimestamp(ONE_MONTH) + + // Request owed money + await payroll.payday({ from: employee }) + await assertRevert(payroll.getEmployee(employeeId), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + + // Add employee back + const receipt = await payroll.addEmployeeNow(employee, salary, 'Boss') + const newEmployeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + + const [address, employeeSalary, accruedValue, lastPayroll, date] = await payroll.getEmployee(newEmployeeId) + assert.equal(address, employee, 'Employee account does not match') + assert.equal(employeeSalary.toString(), salary.toString(), 'employee salary does not match') + assert.equal(lastPayroll.toString(), (await currentTimestamp()).toString(), 'employee last payroll date does not match') + assert.equal(accruedValue.toString(), 0, 'employee accrued value does not match') + assert.equal(date.toString(), maxUint64(), 'employee end date does not match') + }) + }) + + context('when the given end date is in the past', () => { + beforeEach('set future end date', async () => { + endDate = await currentTimestamp() + await payroll.mockAddTimestamp(ONE_MONTH + 1) + }) + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployee(employeeId, endDate, { from }), 'PAYROLL_PAST_TERMINATION_DATE') + }) + }) + }) + + context('when the employee end date was already set', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployee(employeeId, (await currentTimestamp()).plus(ONE_MONTH), { from }) + }) + + context('when the previous end date was not reached yet', () => { + it('changes the employee end date', async () => { + const newEndDate = bn(await currentTimestamp()).plus(ONE_MONTH * 2) + await payroll.terminateEmployee(employeeId, newEndDate, { from }) + + const endDate = (await payroll.getEmployee(employeeId))[4] + assert.equal(endDate.toString(), newEndDate.toString(), 'employee end date does not match') + }) + }) + + context('when the previous end date was reached', () => { + beforeEach('travel in the future', async () => { + await payroll.mockAddTimestamp(ONE_MONTH + 1) + }) + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployee(employeeId, await currentTimestamp(), { from }), 'PAYROLL_NON_ACTIVE_EMPLOYEE') + }) + }) + }) + }) + + context('when the sender does not have permissions to terminate employees', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployee(employeeId, await currentTimestamp(), { from }), 'APP_AUTH_FAILED') + }) + }) + }) + + context('when the given employee id does not exist', () => { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployee(employeeId, await currentTimestamp(), { from: owner }), 'PAYROLL_NON_ACTIVE_EMPLOYEE') + }) + }) + }) + + context('when it has not been initialized yet', function () { + const employeeId = 0 + const endDate = NOW + ONE_MONTH + + it('reverts', async () => { + await assertRevert(payroll.terminateEmployee(employeeId, endDate, { from: owner }), 'APP_AUTH_FAILED') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/contracts/Payroll_token_allocations.test.js b/future-apps/payroll/test/contracts/Payroll_token_allocations.test.js new file mode 100644 index 0000000000..7208cd9b96 --- /dev/null +++ b/future-apps/payroll/test/contracts/Payroll_token_allocations.test.js @@ -0,0 +1,319 @@ +const { assertRevert } = require('@aragon/test-helpers/assertThrow') +const { getEvents, getEventArgument } = require('../helpers/events') +const { deployErc20TokenAndDeposit, deployContracts, createPayrollInstance, mockTimestamps } = require('../helpers/setup.js')(artifacts, web3) + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +contract('Payroll token allocations', ([owner, employee, anotherEmployee, anyone]) => { + let dao, payroll, payrollBase, finance, vault, priceFeed, denominationToken, anotherToken + + const NOW = 1553703809 // random fixed timestamp in seconds + const ONE_MONTH = 60 * 60 * 24 * 31 + const TWO_MONTHS = ONE_MONTH * 2 + const RATE_EXPIRATION_TIME = TWO_MONTHS + + const TOKEN_DECIMALS = 18 + + before('setup base apps and tokens', async () => { + ({ dao, finance, vault, priceFeed, payrollBase } = await deployContracts(owner)) + anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Another token', TOKEN_DECIMALS) + denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Denomination Token', TOKEN_DECIMALS) + }) + + beforeEach('setup payroll instance', async () => { + payroll = await createPayrollInstance(dao, payrollBase, owner) + await mockTimestamps(payroll, priceFeed, NOW) + }) + + describe('determineAllocation', () => { + const tokenAddresses = [] + + before('deploy some tokens', async () => { + const token1 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 1', 14) + const token2 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 2', 14) + const token3 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 3', 14) + tokenAddresses.push(token1.address, token2.address, token3.address) + }) + + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + beforeEach('allow multiple tokens', async () => { + await Promise.all(tokenAddresses.map(address => payroll.addAllowedToken(address, { from: owner }))) + }) + + context('when the employee exists', () => { + const from = employee + let employeeId + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, 100000, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + const itShouldHandleAllocationsProperly = () => { + context('when the amount of tokens and allocations match', () => { + context('when the given list is not empty', () => { + context('when all the given tokens are allowed', () => { + context('when the allocations add up to 100', () => { + + const itDeterminesAllocationsProperly = allocations => { + context('when there was no previous allocation', () => { + it('persists requested allocation', async () => { + const receipt = await payroll.determineAllocation(tokenAddresses, allocations, { from }) + + const events = getEvents(receipt, 'DetermineAllocation') + assert.equal(events.length, 1, 'number of emitted DetermineAllocation events does not match') + assert.equal(events[0].args.employee, employee, 'employee address should match') + assert.equal(events[0].args.employeeId.toString(), employeeId, 'employee id should match') + + for (const tokenAddress of tokenAddresses) { + const expectedAllocation = allocations[tokenAddresses.indexOf(tokenAddress)] + assert.equal(await payroll.getAllocation(employeeId, tokenAddress), expectedAllocation, 'token allocation does not match') + } + + assert.equal(await payroll.getAllocation(employeeId, anyone), 0, 'token allocation should be zero') + }) + }) + + context('when there was a previous allocation', () => { + let token + + beforeEach('submit previous allocation', async () => { + token = await deployErc20TokenAndDeposit(owner, finance, vault, 'Previous Token', 18) + await payroll.addAllowedToken(token.address, { from: owner }) + + await payroll.determineAllocation([token.address], [100], { from }) + assert.equal(await payroll.getAllocation(employeeId, token.address), 100) + + for (const tokenAddress of tokenAddresses) { + assert.equal(await payroll.getAllocation(employeeId, tokenAddress), 0, 'token allocation does not match') + } + }) + + it('replaces previous allocation for the requested one', async () => { + await payroll.determineAllocation(tokenAddresses, allocations, { from }) + + for (const tokenAddress of tokenAddresses) { + const expectedAllocation = allocations[tokenAddresses.indexOf(tokenAddress)] + assert.equal(await payroll.getAllocation(employeeId, tokenAddress), expectedAllocation, 'token allocation does not match') + } + + assert.equal(await payroll.getAllocation(employeeId, token.address), 0) + }) + }) + } + + context('when the allocation list does not include zero values', () => { + const allocations = [10, 20, 70] + + itDeterminesAllocationsProperly(allocations) + }) + + context('when the allocation list includes zero values', () => { + const allocations = [90, 10, 0] + + itDeterminesAllocationsProperly(allocations) + }) + }) + + context('when the allocations add up less than 100', () => { + const allocations = [10, 20, 69] + + it('reverts', async () => { + await assertRevert(payroll.determineAllocation(tokenAddresses, allocations, { from }), 'PAYROLL_DISTRIBUTION_NO_COMPLETE') + }) + }) + + context('when the allocations add up more than 100', () => { + const allocations = [10, 20, 71] + + it('reverts', async () => { + await assertRevert(payroll.determineAllocation(tokenAddresses, allocations, { from }), 'PAYROLL_DISTRIBUTION_NO_COMPLETE') + }) + }) + }) + + context('when at least one token of the list is not allowed', () => { + let notAllowedToken + + beforeEach('deploy new token', async () => { + notAllowedToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'Not-allowed token', 14) + }) + + it('reverts', async () => { + const addresses = [...tokenAddresses, notAllowedToken.address] + const allocations = [10, 20, 30, 40] + + await assertRevert(payroll.determineAllocation(addresses, allocations, { from }), 'PAYROLL_NO_ALLOWED_TOKEN') + }) + }) + }) + + context('when the given list is empty', () => { + const addresses = [], allocations = [] + + it('reverts', async () => { + await assertRevert(payroll.determineAllocation(addresses, allocations, { from }), 'PAYROLL_DISTRIBUTION_NO_COMPLETE') + }) + }) + }) + + context('when the amount of tokens and allocations do not match', () => { + it('reverts', async () => { + const allocations = [100] + const addresses = [...tokenAddresses, anyone] + + await assertRevert(payroll.determineAllocation(addresses, allocations, { from }), 'PAYROLL_TOKEN_ALLOCATION_MISMATCH') + }) + }) + } + + context('when the employee is active', () => { + itShouldHandleAllocationsProperly() + }) + + context('when the employee is not active', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + itShouldHandleAllocationsProperly() + }) + }) + + context('when the employee does not exist', () => { + const from = anyone + + it('reverts', async () => { + await assertRevert(payroll.determineAllocation(tokenAddresses, [100, 0, 0], { from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + + context('when it has not been initialized yet', function () { + it('reverts', async () => { + await assertRevert(payroll.determineAllocation(tokenAddresses, [10, 20, 70], { from: employee }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') + }) + }) + }) + + describe('getAllocation', () => { + context('when it has already been initialized', function () { + beforeEach('initialize payroll app', async () => { + await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, RATE_EXPIRATION_TIME, { from: owner }) + }) + + context('when the employee exists', () => { + let employeeId + + beforeEach('add employee', async () => { + const receipt = await payroll.addEmployeeNow(employee, 100000, 'Boss', { from: owner }) + employeeId = getEventArgument(receipt, 'AddEmployee', 'employeeId') + }) + + const itShouldAnswerAllocationsProperly = () => { + context('when the given token is not the zero address', () => { + context('when the given token was allowed', () => { + beforeEach('allow denomination token', async () => { + await payroll.addAllowedToken(denominationToken.address, { from: owner }) + }) + + context('when the given token was picked by the employee', () => { + beforeEach('determine allocation', async () => { + await payroll.determineAllocation([denominationToken.address], [100], { from: employee }) + }) + + it('tells its corresponding allocation', async () => { + const allocation = await payroll.getAllocation(employeeId, denominationToken.address) + assert.equal(allocation.toString(), 100, 'token allocation does not match') + }) + }) + + context('when the given token was not picked by the employee', () => { + it('returns 0', async () => { + const allocation = await payroll.getAllocation(employeeId, denominationToken.address) + assert.equal(allocation.toString(), 0, 'token allocation should be zero') + }) + }) + }) + + context('when the given token was not allowed', () => { + it('returns 0', async () => { + const allocation = await payroll.getAllocation(employeeId, denominationToken.address) + assert.equal(allocation.toString(), 0, 'token allocation should be zero') + }) + }) + }) + + context('when the given token is the zero address', () => { + const token = ZERO_ADDRESS + + context('when the given token was allowed', () => { + beforeEach('allow denomination token', async () => { + await payroll.addAllowedToken(token, { from: owner }) + }) + + context('when the given token was picked by the employee', () => { + beforeEach('determine allocation', async () => { + await payroll.determineAllocation([token], [100], { from: employee }) + }) + + it('tells its corresponding allocation', async () => { + const allocation = await payroll.getAllocation(employeeId, token) + assert.equal(allocation.toString(), 100, 'token allocation does not match') + }) + }) + + context('when the given token was not picked by the employee', () => { + it('returns 0', async () => { + const allocation = await payroll.getAllocation(employeeId, token) + assert.equal(allocation.toString(), 0, 'token allocation should be zero') + }) + }) + }) + + context('when the given token was not allowed', () => { + it('returns 0', async () => { + const allocation = await payroll.getAllocation(employeeId, token) + assert.equal(allocation.toString(), 0, 'token allocation should be zero') + }) + }) + }) + } + + context('when the employee is active', () => { + itShouldAnswerAllocationsProperly() + }) + + context('when the employee is not active', () => { + beforeEach('terminate employee', async () => { + await payroll.terminateEmployeeNow(employeeId, { from: owner }) + await payroll.mockAddTimestamp(ONE_MONTH) + }) + + itShouldAnswerAllocationsProperly() + }) + }) + + context('when the employee does not exist', () => { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.getAllocation(employeeId, denominationToken.address), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + }) + + context('when it has not been initialized yet', function () { + const employeeId = 0 + + it('reverts', async () => { + await assertRevert(payroll.getAllocation(employeeId, denominationToken.address), 'PAYROLL_EMPLOYEE_DOESNT_EXIST') + }) + }) + }) +}) diff --git a/future-apps/payroll/test/helpers.js b/future-apps/payroll/test/helpers.js deleted file mode 100644 index e8b5ce84a5..0000000000 --- a/future-apps/payroll/test/helpers.js +++ /dev/null @@ -1,112 +0,0 @@ -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => { return receipt.logs.filter(l => l.event == event)[0].args[arg] } - -const ETH = '0x0' -const SECONDS_IN_A_YEAR = 31557600 // 365.25 days - -module.exports = (owner) => ({ - async deployErc20TokenAndDeposit (sender, finance, vault, name="ERC20Token", decimals=18) { - const token = await getContract('MiniMeToken').new("0x0", "0x0", 0, name, decimals, 'E20', true) // dummy parameters for minime - const amount = new web3.BigNumber(10**18).times(new web3.BigNumber(10**decimals)) - await token.generateTokens(sender, amount) - await token.approve(finance.address, amount, {from: sender}) - await finance.deposit(token.address, amount, "Initial deployment", {from: sender}) - return token - }, - - async addAllowedTokens (payroll, tokens) { - const currencies = [ETH].concat(tokens.map(c => c.address)) - await Promise.all(currencies.map(token => payroll.addAllowedToken(token))) - }, - - async getTimePassed (payroll, employeeId) { - const employee = await payroll.getEmployee.call(employeeId) - const lastPayroll = employee[3] - const currentTime = await payroll.getTimestampPublic.call() - - return currentTime - lastPayroll - }, - - async redistributeEth (accounts, finance) { - const amount = 10 - const split = 4 - // transfer ETH to owner - for (let i = 1; i < split; i++) - await web3.eth.sendTransaction({ from: accounts[i], to: accounts[0], value: web3.toWei(amount, 'ether') }); - // transfer ETH to Payroll contract - for (let i = split; i < 10; i++) - await finance.sendTransaction({ from: accounts[i], value: web3.toWei(amount, 'ether') }); - - }, - - async getDaoFinanceVault () { - const kernelBase = await getContract('Kernel').new(true) // petrify immediately - const aclBase = await getContract('ACL').new() - const regFact = await getContract('EVMScriptRegistryFactory').new() - const daoFact = await getContract('DAOFactory').new(kernelBase.address, aclBase.address, regFact.address) - const vaultBase = await getContract('Vault').new() - const financeBase = await getContract('Finance').new() - - const ANY_ENTITY = await aclBase.ANY_ENTITY() - const APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() - const CREATE_PAYMENTS_ROLE = await financeBase.CREATE_PAYMENTS_ROLE() - const CHANGE_PERIOD_ROLE = await financeBase.CHANGE_PERIOD_ROLE() - const CHANGE_BUDGETS_ROLE = await financeBase.CHANGE_BUDGETS_ROLE() - const EXECUTE_PAYMENTS_ROLE = await financeBase.EXECUTE_PAYMENTS_ROLE() - const MANAGE_PAYMENTS_ROLE = await financeBase.MANAGE_PAYMENTS_ROLE() - const TRANSFER_ROLE = await vaultBase.TRANSFER_ROLE() - - const receipt1 = await daoFact.newDAO(owner) - const dao = getContract('Kernel').at(getEvent(receipt1, 'DeployDAO', 'dao')) - const acl = getContract('ACL').at(await dao.acl()) - - await acl.createPermission(owner, dao.address, APP_MANAGER_ROLE, owner, { from: owner }) - - // finance - const receipt2 = await dao.newAppInstance('0x5678', financeBase.address, '0x', false, { from: owner }) - const finance = getContract('Finance').at(getEvent(receipt2, 'NewAppProxy', 'proxy')) - - await acl.createPermission(ANY_ENTITY, finance.address, CREATE_PAYMENTS_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, finance.address, CHANGE_PERIOD_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, finance.address, CHANGE_BUDGETS_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, finance.address, EXECUTE_PAYMENTS_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, finance.address, MANAGE_PAYMENTS_ROLE, owner, { from: owner }) - - const receipt3 = await dao.newAppInstance('0x1234', vaultBase.address, '0x', false, { from: owner }) - vault = getContract('Vault').at(getEvent(receipt3, 'NewAppProxy', 'proxy')) - await acl.createPermission(finance.address, vault.address, TRANSFER_ROLE, owner, { from: owner }) - await vault.initialize() - - await finance.initialize(vault.address, SECONDS_IN_A_YEAR) // more than one day - - return { dao, finance, vault } - }, - - async initializePayroll(dao, payrollBase, finance, denominationToken, priceFeed, rateExpiryTime) { - const ALLOWED_TOKENS_MANAGER_ROLE = await payrollBase.ALLOWED_TOKENS_MANAGER_ROLE() - const ADD_EMPLOYEE_ROLE = await payrollBase.ADD_EMPLOYEE_ROLE() - const TERMINATE_EMPLOYEE_ROLE = await payrollBase.TERMINATE_EMPLOYEE_ROLE() - const SET_EMPLOYEE_SALARY_ROLE = await payrollBase.SET_EMPLOYEE_SALARY_ROLE() - const ADD_ACCRUED_VALUE_ROLE = await payrollBase.ADD_ACCRUED_VALUE_ROLE() - const CHANGE_PRICE_FEED_ROLE = await payrollBase.CHANGE_PRICE_FEED_ROLE() - const MODIFY_RATE_EXPIRY_ROLE = await payrollBase.MODIFY_RATE_EXPIRY_ROLE() - - const receipt = await dao.newAppInstance('0x4321', payrollBase.address, '0x', false, { from: owner }) - const payroll = getContract('PayrollMock').at(getEvent(receipt, 'NewAppProxy', 'proxy')) - - const acl = await getContract('ACL').at(await dao.acl()) - const ANY_ENTITY = await acl.ANY_ENTITY() - await acl.createPermission(ANY_ENTITY, payroll.address, ALLOWED_TOKENS_MANAGER_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, payroll.address, ADD_EMPLOYEE_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, payroll.address, TERMINATE_EMPLOYEE_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, payroll.address, SET_EMPLOYEE_SALARY_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, payroll.address, ADD_ACCRUED_VALUE_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, payroll.address, CHANGE_PRICE_FEED_ROLE, owner, { from: owner }) - await acl.createPermission(ANY_ENTITY, payroll.address, MODIFY_RATE_EXPIRY_ROLE, owner, { from: owner }) - - // inits payroll - await payroll.initialize(finance.address, denominationToken.address, priceFeed.address, rateExpiryTime) - - return payroll - } -}) diff --git a/future-apps/payroll/test/helpers/events.js b/future-apps/payroll/test/helpers/events.js new file mode 100644 index 0000000000..23232ba766 --- /dev/null +++ b/future-apps/payroll/test/helpers/events.js @@ -0,0 +1,9 @@ +const getEvent = (receipt, event) => getEvents(receipt, event)[0].args +const getEvents = (receipt, event) => receipt.logs.filter(l => l.event === event) +const getEventArgument = (receipt, event, arg) => getEvent(receipt, event)[arg] + +module.exports = { + getEvent, + getEvents, + getEventArgument, +} diff --git a/future-apps/payroll/test/helpers/numbers.js b/future-apps/payroll/test/helpers/numbers.js new file mode 100644 index 0000000000..ff2b331a20 --- /dev/null +++ b/future-apps/payroll/test/helpers/numbers.js @@ -0,0 +1,20 @@ +const SECONDS_IN_A_YEAR = 31557600 // 365.25 days + +module.exports = web3 => { + const bn = x => new web3.BigNumber(x) + const bigExp = (x, y) => bn(x).mul(bn(10).pow(bn(y))) + + const maxUint = (e) => bn(2).pow(bn(e)).sub(bn(1)) + const maxUint64 = () => maxUint(64) + const maxUint256 = () => maxUint(256) + + const annualSalaryPerSecond = (amount, decimals) => bigExp(amount, decimals).dividedToIntegerBy(SECONDS_IN_A_YEAR) + + return { + bn, + bigExp, + maxUint64, + maxUint256, + annualSalaryPerSecond + } +} diff --git a/future-apps/payroll/test/helpers/setup.js b/future-apps/payroll/test/helpers/setup.js new file mode 100644 index 0000000000..3f3f655399 --- /dev/null +++ b/future-apps/payroll/test/helpers/setup.js @@ -0,0 +1,107 @@ +module.exports = (artifacts, web3) => { + const { bigExp } = require('./numbers')(web3) + const { getEventArgument } = require('./events') + const getContract = name => artifacts.require(name) + + const ACL = getContract('ACL') + const Vault = getContract('Vault') + const Kernel = getContract('Kernel') + const Finance = getContract('Finance') + const Payroll = getContract('PayrollMock') + const PriceFeed = getContract('PriceFeedMock') + const DAOFactory = getContract('DAOFactory') + const EVMScriptRegistryFactory = getContract('EVMScriptRegistryFactory') + + async function deployErc20TokenAndDeposit(sender, finance, vault, name = 'ERC20Token', decimals = 18) { + const token = await getContract('MiniMeToken').new('0x0', '0x0', 0, name, decimals, 'E20', true) // dummy parameters for minime + const amount = bigExp(1e18, decimals) + await token.generateTokens(sender, amount) + await token.approve(finance.address, amount, { from: sender }) + await finance.deposit(token.address, amount, 'Initial deployment', { from: sender }) + return token + } + + async function deployContracts(owner) { + const kernelBase = await Kernel.new(true) // petrify immediately + const aclBase = await ACL.new() + const regFact = await EVMScriptRegistryFactory.new() + const daoFact = await DAOFactory.new(kernelBase.address, aclBase.address, regFact.address) + const vaultBase = await Vault.new() + const financeBase = await Finance.new() + + const ANY_ENTITY = await aclBase.ANY_ENTITY() + const APP_MANAGER_ROLE = await kernelBase.APP_MANAGER_ROLE() + const CREATE_PAYMENTS_ROLE = await financeBase.CREATE_PAYMENTS_ROLE() + const CHANGE_PERIOD_ROLE = await financeBase.CHANGE_PERIOD_ROLE() + const CHANGE_BUDGETS_ROLE = await financeBase.CHANGE_BUDGETS_ROLE() + const EXECUTE_PAYMENTS_ROLE = await financeBase.EXECUTE_PAYMENTS_ROLE() + const MANAGE_PAYMENTS_ROLE = await financeBase.MANAGE_PAYMENTS_ROLE() + const TRANSFER_ROLE = await vaultBase.TRANSFER_ROLE() + + const kernelReceipt = await daoFact.newDAO(owner) + const dao = Kernel.at(getEventArgument(kernelReceipt, 'DeployDAO', 'dao')) + const acl = ACL.at(await dao.acl()) + + await acl.createPermission(owner, dao.address, APP_MANAGER_ROLE, owner, { from: owner }) + + // finance + const financeReceipt = await dao.newAppInstance('0x5678', financeBase.address, '0x', false, { from: owner }) + const finance = Finance.at(getEventArgument(financeReceipt, 'NewAppProxy', 'proxy')) + + await acl.createPermission(ANY_ENTITY, finance.address, CREATE_PAYMENTS_ROLE, owner, { from: owner }) + await acl.createPermission(ANY_ENTITY, finance.address, CHANGE_PERIOD_ROLE, owner, { from: owner }) + await acl.createPermission(ANY_ENTITY, finance.address, CHANGE_BUDGETS_ROLE, owner, { from: owner }) + await acl.createPermission(ANY_ENTITY, finance.address, EXECUTE_PAYMENTS_ROLE, owner, { from: owner }) + await acl.createPermission(ANY_ENTITY, finance.address, MANAGE_PAYMENTS_ROLE, owner, { from: owner }) + + const vaultReceipt = await dao.newAppInstance('0x1234', vaultBase.address, '0x', false, { from: owner }) + const vault = Vault.at(getEventArgument(vaultReceipt, 'NewAppProxy', 'proxy')) + await acl.createPermission(finance.address, vault.address, TRANSFER_ROLE, owner, { from: owner }) + await vault.initialize() + + const SECONDS_IN_A_YEAR = 31557600 // 365.25 days + await finance.initialize(vault.address, SECONDS_IN_A_YEAR) // more than one day + + const priceFeed = await PriceFeed.new() + const payrollBase = await Payroll.new() + + return { dao, finance, vault, priceFeed, payrollBase } + } + + async function createPayrollInstance(dao, payrollBase, owner) { + const receipt = await dao.newAppInstance('0x4321', payrollBase.address, '0x', false, { from: owner }) + const payroll = Payroll.at(getEventArgument(receipt, 'NewAppProxy', 'proxy')) + + const acl = ACL.at(await dao.acl()) + + const ADD_EMPLOYEE_ROLE = await payroll.ADD_EMPLOYEE_ROLE() + const ADD_ACCRUED_VALUE_ROLE = await payroll.ADD_ACCRUED_VALUE_ROLE() + const CHANGE_PRICE_FEED_ROLE = await payroll.CHANGE_PRICE_FEED_ROLE() + const MODIFY_RATE_EXPIRY_ROLE = await payroll.MODIFY_RATE_EXPIRY_ROLE() + const TERMINATE_EMPLOYEE_ROLE = await payroll.TERMINATE_EMPLOYEE_ROLE() + const SET_EMPLOYEE_SALARY_ROLE = await payroll.SET_EMPLOYEE_SALARY_ROLE() + const ALLOWED_TOKENS_MANAGER_ROLE = await payroll.ALLOWED_TOKENS_MANAGER_ROLE() + + await acl.createPermission(owner, payroll.address, ADD_EMPLOYEE_ROLE, owner, { from: owner }) + await acl.createPermission(owner, payroll.address, ADD_ACCRUED_VALUE_ROLE, owner, { from: owner }) + await acl.createPermission(owner, payroll.address, CHANGE_PRICE_FEED_ROLE, owner, { from: owner }) + await acl.createPermission(owner, payroll.address, MODIFY_RATE_EXPIRY_ROLE, owner, { from: owner }) + await acl.createPermission(owner, payroll.address, TERMINATE_EMPLOYEE_ROLE, owner, { from: owner }) + await acl.createPermission(owner, payroll.address, SET_EMPLOYEE_SALARY_ROLE, owner, { from: owner }) + await acl.createPermission(owner, payroll.address, ALLOWED_TOKENS_MANAGER_ROLE, owner, { from: owner }) + + return payroll + } + + async function mockTimestamps(payroll, priceFeed, now) { + await priceFeed.mockSetTimestamp(now) + await payroll.mockSetTimestamp(now) + } + + return { + deployContracts, + deployErc20TokenAndDeposit, + createPayrollInstance, + mockTimestamps + } +} diff --git a/future-apps/payroll/test/payroll_accrued_value.js b/future-apps/payroll/test/payroll_accrued_value.js deleted file mode 100644 index bbd46daa19..0000000000 --- a/future-apps/payroll/test/payroll_accrued_value.js +++ /dev/null @@ -1,521 +0,0 @@ -const { assertRevert } = require('@aragon/test-helpers/assertThrow') - -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => receipt.logs.find(l => l.event === event).args[arg] - -contract('Payroll, accrued value', (accounts) => { - const DECIMALS = 18 - const NOW = new Date().getTime() - const ONE_MONTH = 60 * 60 * 24 * 31 - const TWO_MONTHS = ONE_MONTH * 2 - const PCT_ONE = new web3.BigNumber(1e18) - - const [owner, employee, anyone] = accounts - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - redistributeEth, - getDaoFinanceVault, - initializePayroll - } = require('./helpers.js')(owner) - - let payroll, payrollBase, priceFeed, denominationToken, anotherToken, salary, employeeId, dao, finance, vault - - before(async () => { - payrollBase = await getContract('PayrollMock').new() - - const daoFinanceVault = await getDaoFinanceVault() - dao = daoFinanceVault.dao - finance = daoFinanceVault.finance - vault = daoFinanceVault.vault - - await redistributeEth(accounts, finance) - - priceFeed = await getContract('PriceFeedMock').new() - await priceFeed.mockSetTimestamp(NOW) - }) - - beforeEach('initialize payroll, tokens and employee', async () => { - denominationToken = await deployErc20TokenAndDeposit(owner, finance, vault, "USD", DECIMALS) - anotherToken = await deployErc20TokenAndDeposit(owner, finance, vault, "ERC20", DECIMALS) - - payroll = await initializePayroll(dao, payrollBase, finance, denominationToken, priceFeed, TWO_MONTHS) - await payroll.mockSetTimestamp(NOW) - await addAllowedTokens(payroll, [denominationToken, anotherToken]) - - salary = 1000 - const receipt = await payroll.addEmployeeShort(employee, salary, 'Kakaroto', 'Saiyajin') - employeeId = getEvent(receipt, 'AddEmployee', 'employeeId') - }) - - it('adds accrued value manually', async () => { - const accruedValue = 50 - await payroll.addAccruedValue(employeeId, accruedValue) - assert.equal((await payroll.getEmployee(employeeId))[2].toString(), accruedValue, 'Accrued Value should match') - }) - - it('fails adding an accrued value too large', async () => { - const maxAccruedValue = await payroll.getMaxAccruedValue() - await payroll.addAccruedValue(employeeId, maxAccruedValue) - - await assertRevert(payroll.addAccruedValue(employeeId, 1)) - }) - - it('considers modified salary as accrued value and it can be computed right after the change', async () => { - const timeDiff = 864000 - await payroll.mockAddTimestamp(timeDiff) - - const salary1_1 = salary * 2 - await payroll.setEmployeeSalary(employeeId, salary1_1) - - await payroll.determineAllocation([denominationToken.address], [100], { from: employee }) - const initialBalance = await denominationToken.balanceOf(employee) - await payroll.reimburse({ from: employee }) - - const finalBalance = await denominationToken.balanceOf(employee) - const payrollOwed = salary * timeDiff - assert.equal(finalBalance - initialBalance, payrollOwed, "Payroll payed doesn't match") - }) - - it('considers modified salary as accrued value and it can be computed some time after the change', async () => { - const timeDiff = 864000 - await payroll.mockAddTimestamp(timeDiff) - - const salary1_1 = salary * 2 - await payroll.setEmployeeSalary(employeeId, salary1_1) - - await payroll.mockAddTimestamp(timeDiff * 2) - - await payroll.determineAllocation([denominationToken.address], [100], { from: employee }) - const initialBalance = await denominationToken.balanceOf(employee) - await payroll.reimburse({ from: employee }) - - const finalBalance = await denominationToken.balanceOf(employee) - const payrollOwed = salary * timeDiff - assert.equal(finalBalance - initialBalance, payrollOwed, "Payroll payed doesn't match") - }) - - describe('reimburse', function () { - context('when the sender is an employee', () => { - const from = employee - - beforeEach('mock current timestamp', async () => { - await payroll.mockAddTimestamp(ONE_MONTH) - }) - - context('when the employee has already set some token allocations', () => { - beforeEach('set tokens allocation', async () => { - await payroll.determineAllocation([denominationToken.address, anotherToken.address], [80, 20], { from }) - }) - - context('when the employee has some pending reimbursements', () => { - const accruedValue = 100 - - beforeEach('add accrued value', async () => { - await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) - await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) - }) - - const assertTransferredAmounts = () => { - it('transfers all the pending reimbursements', async () => { - const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) - const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) - - await payroll.reimburse({ from }) - - const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) - assert.equal(currentDenominationTokenBalance.toString(), previousDenominationTokenBalance.plus(80).toString(), 'current USD token balance does not match') - - const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) - const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) - const expectedAnotherTokenBalance = anotherTokenRate.mul(20).plus(previousAnotherTokenBalance) - assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') - }) - - it('emits one event per allocated token', async () => { - const receipt = await payroll.reimburse({ from }) - - const events = receipt.logs.filter(l => l.event === 'SendPayment') - assert.equal(events.length, 2, 'should have emitted two events') - - const denominationTokenEvent = events.find(e => e.args.token === denominationToken.address).args - assert.equal(denominationTokenEvent.employee, employee, 'employee address does not match') - assert.equal(denominationTokenEvent.token, denominationToken.address, 'usd token address does not match') - assert.equal(denominationTokenEvent.amount.toString(), 80, 'payment amount does not match') - assert.equal(denominationTokenEvent.reference, 'Reimbursement', 'payment reference does not match') - - const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) - const anotherTokenEvent = events.find(e => e.args.token === anotherToken.address).args - assert.equal(anotherTokenEvent.employee, employee, 'employee address does not match') - assert.equal(anotherTokenEvent.token, anotherToken.address, 'token address does not match') - assert.equal(anotherTokenEvent.amount.div(anotherTokenRate).toString(), 20, 'payment amount does not match') - assert.equal(anotherTokenEvent.reference, 'Reimbursement', 'payment reference does not match') - }) - } - - const assertEmployeeIsNotRemoved = () => { - it('does not remove the employee and resets the accrued value', async () => { - await payroll.reimburse({ from }) - - const [address, employeeSalary, accruedValue] = await payroll.getEmployee(employeeId) - - assert.equal(address, employee, 'employee address does not match') - assert.equal(employeeSalary, salary, 'employee salary does not match') - assert.equal(accruedValue, 0, 'accrued value should be zero') - }) - } - - context('when the employee has some pending salary', () => { - assertTransferredAmounts() - assertEmployeeIsNotRemoved() - }) - - context('when the employee does not have pending salary', () => { - beforeEach('cash out pending salary', async () => { - await payroll.payday({ from }) - }) - - context('when the employee is not terminated', () => { - assertTransferredAmounts() - assertEmployeeIsNotRemoved() - }) - - context('when the employee is terminated', () => { - beforeEach('terminate employee', async () => { - await payroll.terminateEmployeeNow(employeeId, { from: owner }) - }) - - assertTransferredAmounts() - - it('removes the employee', async () => { - await payroll.reimburse({ from }) - - const [address, employeeSalary, accruedValue, payrollTimestamp] = await payroll.getEmployee(employeeId) - - assert.equal(address, 0, 'employee address does not match') - assert.equal(employeeSalary, 0, 'employee salary does not match') - assert.equal(accruedValue, 0, 'accrued value should be zero') - assert.equal(payrollTimestamp, 0, 'accrued value should be zero') - }) - }) - }) - }) - - context('when the employee does not have pending reimbursements', () => { - it('reverts', async () => { - await assertRevert(payroll.reimburse({ from }), 'PAYROLL_NOTHING_PAID') - }) - }) - }) - - context('when the employee did not set any token allocations yet', () => { - context('when the employee has some pending reimbursements', () => { - const accruedValue = 50 - - beforeEach('add accrued value', async () => { - await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) - await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) - }) - - it('reverts', async () => { - await assertRevert(payroll.reimburse({ from }), 'PAYROLL_NOTHING_PAID') - }) - }) - - context('when the employee does not have pending reimbursements', () => { - it('reverts', async () => { - await assertRevert(payroll.reimburse({ from }), 'PAYROLL_NOTHING_PAID') - }) - }) - }) - }) - - context('when the sender is not an employee', () => { - const from = anyone - - it('reverts', async () => { - await assertRevert(payroll.reimburse({ from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') - }) - }) - }) - - describe('partialReimburse', function () { - context('when the sender is an employee', () => { - const from = employee - - beforeEach('mock current timestamp', async () => { - await payroll.mockAddTimestamp(ONE_MONTH) - }) - - context('when the employee has already set some token allocations', () => { - const denominationTokenAllocation = 80 - const anotherTokenAllocation = 20 - - beforeEach('set tokens allocation', async () => { - await payroll.determineAllocation([denominationToken.address, anotherToken.address], [denominationTokenAllocation, anotherTokenAllocation], { from }) - }) - - context('when the employee has some pending reimbursements', () => { - const accruedValue = 100 - - beforeEach('add accrued value', async () => { - await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) - await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) - }) - - const assertTransferredAmounts = (requestedAmount, expectedRequestedAmount = requestedAmount) => { - const requestedDenominationTokenAmount = parseInt(expectedRequestedAmount * denominationTokenAllocation / 100) - const requestedAnotherTokenAmount = expectedRequestedAmount * anotherTokenAllocation / 100 - - it('transfers all the pending reimbursements', async () => { - const previousDenominationTokenBalance = await denominationToken.balanceOf(employee) - const previousAnotherTokenBalance = await anotherToken.balanceOf(employee) - - await payroll.partialReimburse(requestedAmount, { from }) - - const currentDenominationTokenBalance = await denominationToken.balanceOf(employee) - const expectedDenominationTokenBalance = previousDenominationTokenBalance.plus(requestedDenominationTokenAmount); - assert.equal(currentDenominationTokenBalance.toString(), expectedDenominationTokenBalance.toString(), 'current USD token balance does not match') - - const currentAnotherTokenBalance = await anotherToken.balanceOf(employee) - const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) - const expectedAnotherTokenBalance = anotherTokenRate.mul(requestedAnotherTokenAmount).plus(previousAnotherTokenBalance).trunc() - assert.equal(currentAnotherTokenBalance.toString(), expectedAnotherTokenBalance.toString(), 'current token balance does not match') - }) - - it('emits one event per allocated token', async () => { - const receipt = await payroll.partialReimburse(requestedAmount, { from }) - - const events = receipt.logs.filter(l => l.event === 'SendPayment') - assert.equal(events.length, 2, 'should have emitted two events') - - const denominationTokenEvent = events.find(e => e.args.token === denominationToken.address).args - assert.equal(denominationTokenEvent.employee, employee, 'employee address does not match') - assert.equal(denominationTokenEvent.token, denominationToken.address, 'usd token address does not match') - assert.equal(denominationTokenEvent.amount.toString(), requestedDenominationTokenAmount, 'payment amount does not match') - assert.equal(denominationTokenEvent.reference, 'Reimbursement', 'payment reference does not match') - - const anotherTokenRate = (await priceFeed.get(denominationToken.address, anotherToken.address))[0].div(PCT_ONE) - const anotherTokenEvent = events.find(e => e.args.token === anotherToken.address).args - assert.equal(anotherTokenEvent.employee, employee, 'employee address does not match') - assert.equal(anotherTokenEvent.token, anotherToken.address, 'token address does not match') - assert.equal(anotherTokenEvent.amount.div(anotherTokenRate).trunc().toString(), parseInt(requestedAnotherTokenAmount), 'payment amount does not match') - assert.equal(anotherTokenEvent.reference, 'Reimbursement', 'payment reference does not match') - }) - } - - const assertEmployeeIsNotRemoved = (requestedAmount, expectedRequestedAmount = requestedAmount) => { - it('does not remove the employee and resets the accrued value', async () => { - const currentAccruedValue = (await payroll.getEmployee(employeeId))[2] - await payroll.partialReimburse(requestedAmount, { from }) - - const [address, employeeSalary, accruedValue] = await payroll.getEmployee(employeeId) - - assert.equal(address, employee, 'employee address does not match') - assert.equal(employeeSalary, salary, 'employee salary does not match') - assert.equal(currentAccruedValue.minus(expectedRequestedAmount).toString(), accruedValue.toString(), 'accrued value does not match') - }) - } - - context('when the requested amount is zero', () => { - const requestedAmount = 0 - - context('when the employee has some pending salary', () => { - assertTransferredAmounts(requestedAmount, accruedValue) - assertEmployeeIsNotRemoved(requestedAmount, accruedValue) - }) - - context('when the employee does not have pending salary', () => { - beforeEach('cash out pending salary', async () => { - await payroll.payday({ from }) - }) - - context('when the employee is not terminated', () => { - assertTransferredAmounts(requestedAmount, accruedValue) - assertEmployeeIsNotRemoved(requestedAmount, accruedValue) - }) - - context('when the employee is terminated', () => { - beforeEach('terminate employee', async () => { - await payroll.terminateEmployeeNow(employeeId, { from: owner }) - }) - - assertTransferredAmounts(requestedAmount, accruedValue) - - it('removes the employee', async () => { - await payroll.partialReimburse(requestedAmount, { from }) - - const [address, employeeSalary, accruedValue, payrollTimestamp] = await payroll.getEmployee(employeeId) - - assert.equal(address, 0, 'employee address does not match') - assert.equal(employeeSalary, 0, 'employee salary does not match') - assert.equal(accruedValue, 0, 'accrued value should be zero') - assert.equal(payrollTimestamp, 0, 'accrued value should be zero') - }) - }) - }) - }) - - context('when the requested amount is less than the total accrued value', () => { - const requestedAmount = accruedValue - 1 - - context('when the employee has some pending salary', () => { - assertTransferredAmounts(requestedAmount) - assertEmployeeIsNotRemoved(requestedAmount) - }) - - context('when the employee does not have pending salary', () => { - beforeEach('cash out pending salary', async () => { - await payroll.payday({ from }) - }) - - context('when the employee is not terminated', () => { - assertTransferredAmounts(requestedAmount) - assertEmployeeIsNotRemoved(requestedAmount) - }) - - context('when the employee is terminated', () => { - beforeEach('terminate employee', async () => { - await payroll.terminateEmployeeNow(employeeId, { from: owner }) - }) - - assertTransferredAmounts(requestedAmount) - assertEmployeeIsNotRemoved(requestedAmount) - }) - }) - }) - - context('when the requested amount is equal to the total accrued value', () => { - const requestedAmount = accruedValue - - context('when the employee has some pending salary', () => { - assertTransferredAmounts(requestedAmount) - assertEmployeeIsNotRemoved(requestedAmount) - }) - - context('when the employee does not have pending salary', () => { - beforeEach('cash out pending salary', async () => { - await payroll.payday({ from }) - }) - - context('when the employee is not terminated', () => { - assertTransferredAmounts(requestedAmount) - assertEmployeeIsNotRemoved(requestedAmount) - }) - - context('when the employee is terminated', () => { - beforeEach('terminate employee', async () => { - await payroll.terminateEmployeeNow(employeeId, { from: owner }) - }) - - assertTransferredAmounts(requestedAmount) - - it('removes the employee', async () => { - await payroll.partialReimburse(requestedAmount, { from }) - - const [address, employeeSalary, accruedValue, payrollTimestamp] = await payroll.getEmployee(employeeId) - - assert.equal(address, 0, 'employee address does not match') - assert.equal(employeeSalary, 0, 'employee salary does not match') - assert.equal(accruedValue, 0, 'accrued value should be zero') - assert.equal(payrollTimestamp, 0, 'accrued value should be zero') - }) - }) - }) - }) - - context('when the requested amount is greater than the total accrued value', () => { - const requestedAmount = accruedValue + 1 - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') - }) - }) - }) - - context('when the employee does not have pending reimbursements', () => { - context('when the requested amount is greater than zero', () => { - const requestedAmount = 100 - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') - }) - }) - - context('when the requested amount is zero', () => { - const requestedAmount = 0 - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') - }) - }) - }) - }) - - context('when the employee did not set any token allocations yet', () => { - context('when the employee has some pending reimbursements', () => { - const accruedValue = 100 - - beforeEach('add accrued value', async () => { - await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) - await payroll.addAccruedValue(employeeId, accruedValue / 2, { from: owner }) - }) - - context('when the requested amount is less than the total accrued value', () => { - const requestedAmount = accruedValue - 1 - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') - }) - }) - - context('when the requested amount is equal to the total accrued value', () => { - const requestedAmount = accruedValue - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') - }) - }) - }) - - context('when the employee does not have pending reimbursements', () => { - context('when the requested amount is greater than zero', () => { - const requestedAmount = 100 - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') - }) - }) - - context('when the requested amount is zero', () => { - const requestedAmount = 0 - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_NOTHING_PAID') - }) - }) - }) - }) - }) - - context('when the sender is not an employee', () => { - const from = anyone - - context('when the requested amount is greater than zero', () => { - const requestedAmount = 100 - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') - }) - }) - - context('when the requested amount is zero', () => { - const requestedAmount = 0 - - it('reverts', async () => { - await assertRevert(payroll.partialReimburse(requestedAmount, { from }), 'PAYROLL_EMPLOYEE_DOES_NOT_MATCH') - }) - }) - }) - }) -}) diff --git a/future-apps/payroll/test/payroll_add_remove_employees.js b/future-apps/payroll/test/payroll_add_remove_employees.js deleted file mode 100644 index d0630475bb..0000000000 --- a/future-apps/payroll/test/payroll_add_remove_employees.js +++ /dev/null @@ -1,221 +0,0 @@ -const { assertRevert } = require('@aragon/test-helpers/assertThrow') -const getBalance = require('@aragon/test-helpers/balance')(web3) -const getTransaction = require('@aragon/test-helpers/transaction')(web3) - -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => { return receipt.logs.filter(l => l.event == event)[0].args[arg] } - -contract('Payroll, adding and removing employees,', function(accounts) { - const [owner, employee1, employee2] = accounts - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - getTimePassed, - getDaoFinanceVault, - initializePayroll - } = require('./helpers.js')(owner) - - const USD_DECIMALS= 18 - const USD_PRECISION = 10**USD_DECIMALS - const SECONDS_IN_A_YEAR = 31557600 // 365.25 days - const ONE = 1e18 - const ETH = '0x0' - const rateExpiryTime = 1000 - - const salary1 = (new web3.BigNumber(100000)).times(USD_PRECISION).dividedToIntegerBy(SECONDS_IN_A_YEAR) - const salary1_2 = (new web3.BigNumber(125000)).times(USD_PRECISION).dividedToIntegerBy(SECONDS_IN_A_YEAR) - const salary2 = (new web3.BigNumber(120000)).times(USD_PRECISION).dividedToIntegerBy(SECONDS_IN_A_YEAR) - const erc20Token1Decimals = 18 - - let payroll - let payrollBase - let priceFeed - let usdToken - let erc20Token1 - let dao - let finance - let vault - - before(async () => { - payrollBase = await getContract('PayrollMock').new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - usdToken = await deployErc20TokenAndDeposit(owner, finance, vault, "USD", USD_DECIMALS) - priceFeed = await getContract('PriceFeedMock').new() - - // Deploy ERC 20 Tokens - erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 1", erc20Token1Decimals) - - payroll = await initializePayroll(dao, payrollBase, finance, usdToken, priceFeed, rateExpiryTime) - - // adds allowed tokens - await addAllowedTokens(payroll, [usdToken, erc20Token1]) - }) - - context('> Adding employee', () => { - const employeeName = 'Kakaroto' - const role = 'Saiyajin' - let employeeId, payrollTimestamp - - beforeEach(async () => { - payroll = await initializePayroll(dao, payrollBase, finance, usdToken, priceFeed, rateExpiryTime) - - // adds allowed tokens - await addAllowedTokens(payroll, [usdToken, erc20Token1]) - - payrollTimestamp = (await payroll.getTimestampPublic()).toString() - - // add employee - const receipt= await payroll.addEmployeeShort(employee1, salary1, employeeName, role) - employeeId = getEvent(receipt, 'AddEmployee', 'employeeId').toString() - }) - - it('get employee by its Id', async () => { - const employee = await payroll.getEmployee(employeeId) - assert.equal(employee[0], employee1, "Employee account doesn't match") - assert.equal(employee[1].toString(), salary1.toString(), "Employee salary doesn't match") - assert.equal(employee[2].toString(), 0, "Employee accrued value doesn't match") - assert.equal(employee[3].toString(), payrollTimestamp, "last payroll should match") - }) - - it('get employee by its address', async () => { - const employee = await payroll.getEmployeeByAddress(employee1) - assert.equal(employee[0].toString(), employeeId, "Employee Id doesn't match") - assert.equal(employee[1].toString(), salary1.toString(), "Employee salary doesn't match") - assert.equal(employee[2].toString(), 0, "Employee accrued value doesn't match") - assert.equal(employee[3].toString(), (await payroll.getTimestampPublic()).toString(), "last payroll should match") - }) - - it("adds another employee", async () => { - const employee2Name = 'Joe' - - const receipt = await payroll.addEmployeeShort(employee2, salary2, employee2Name, role) - const employee2Id = getEvent(receipt, 'AddEmployee', 'employeeId') - - // Check event - const addEvent = receipt.logs.filter(l => l.event == 'AddEmployee')[0] - assert.equal(addEvent.args.employeeId.toString(), employee2Id, "Employee Id doesn't match") - assert.equal(addEvent.args.accountAddress, employee2, "Employee account doesn't match") - assert.equal(addEvent.args.initialDenominationSalary.toString(), salary2, "Employee salary doesn't match") - assert.equal(addEvent.args.name, employee2Name, "Employee name doesn't match") - assert.equal(addEvent.args.role, role, "Employee role doesn't match") - assert.equal(addEvent.args.startDate, payrollTimestamp, "Employee startdate doesn't match") - - // Check storage - const employee = await payroll.getEmployee(employee2Id) - assert.equal(employee[0], employee2, "Employee account doesn't match") - assert.equal(employee[1].toString(), salary2.toString(), "Employee salary doesn't match") - assert.equal(employee[2].toString(), 0, "Employee accrued value doesn't match") - assert.equal(employee[3].toString(), payrollTimestamp, "last payroll should match") - }) - - it("fails adding again same employee", async () => { - // Make sure that the employee exists - const existingEmployee = await payroll.getEmployeeByAddress(employee1) - assert.equal(existingEmployee[0].toString(), employeeId, "First employee id doesn't match") - - // Now try to add him again - const name = 'Joe' - return assertRevert(async () => { - await payroll.addEmployeeShort(employee1, salary1, name, role) - }) - }) - }) - - context('> Removing employee', () => { - const role = 'Saiyajin' - let employeeId - - beforeEach(async () => { - payroll = await initializePayroll(dao, payrollBase, finance, usdToken, priceFeed, rateExpiryTime) - - // adds allowed tokens - await addAllowedTokens(payroll, [usdToken, erc20Token1]) - - // add employee - const receipt = await payroll.addEmployeeShort(employee1, salary1, 'Kakaroto', role) - employeeId = getEvent(receipt, 'AddEmployee', 'employeeId').toString() - }) - - it("terminates employee with remaining payroll", async () => { - const timePassed = 1000 - const initialBalance = await usdToken.balanceOf(employee1) - await payroll.determineAllocation([usdToken.address], [100], { from: employee1 }) - - // Accrue some salary - await payroll.mockAddTimestamp(timePassed) - const owed = salary1.times(timePassed) - - // Terminate employee - await payroll.terminateEmployeeNow(employeeId) - await payroll.mockAddTimestamp(timePassed) - - // owed salary is only added to accrued value, employee need to call `payday` again - let finalBalance = await usdToken.balanceOf(employee1) - assert.equal(finalBalance.toString(), initialBalance.toString()) - - await payroll.payday({ from: employee1 }) - finalBalance = await usdToken.balanceOf(employee1) - assert.equal(finalBalance.toString(), initialBalance.add(owed).toString()) - }) - - it("fails on removing non-existent employee", async () => { - return assertRevert(async () => { - await payroll.terminateEmployee(10, await payroll.getTimestampPublic.call()) - }) - }) - - it('fails trying to terminate an employee in the past', async () => { - const nowMock = new Date().getTime() - const terminationDate = nowMock - 1 - - await payroll.mockSetTimestamp(nowMock) - - return assertRevert(async () => { - await payroll.terminateEmployee(employeeId, terminationDate) - }) - }) - - it('fails trying to re-terminate employee', async () => { - const timestamp = parseInt(await payroll.getTimestampPublic.call(), 10) - await payroll.terminateEmployeeNow(employeeId) - await payroll.mockSetTimestamp(timestamp + 500); - return assertRevert(async () => { - await payroll.terminateEmployee(employeeId, timestamp + SECONDS_IN_A_YEAR) - }) - }) - - it("can re-add removed employee with specific start date", async () => { - // Make sure that the employee exists - const existingEmployee = await payroll.getEmployeeByAddress(employee1) - assert.equal(existingEmployee[0].toString(), employeeId, "First employee id doesn't match") - - // Set their allocation so we can pay them out - await payroll.determineAllocation([usdToken.address], [100], { from: employee1 }) - - // Then terminate him and pay them out - const timestamp = parseInt(await payroll.getTimestampPublic.call(), 10) - await payroll.mockSetTimestamp(timestamp + 500); - await payroll.terminateEmployeeNow(employeeId) - await payroll.payday({ from: employee1 }) - - // Now let's add them back - const name = 'Kakaroto' - const startDate = Math.floor((new Date()).getTime() / 1000) - 2628600 - - const receipt = await payroll.addEmployee(employee1, salary1_2, name, role, startDate) - const newId = getEvent(receipt, 'AddEmployee', 'employeeId') - - const newEmployee = await payroll.getEmployee(newId) - assert.equal(newEmployee[0], employee1, "Employee account doesn't match") - assert.equal(newEmployee[1].toString(), salary1_2.toString(), "Employee salary doesn't match") - assert.equal(newEmployee[2].toString(), 0, "Employee accrued value doesn't match") - assert.equal(newEmployee[3].toString(), startDate, "Employee last paydate should match") - }) - }) -}) diff --git a/future-apps/payroll/test/payroll_allowed_tokens.js b/future-apps/payroll/test/payroll_allowed_tokens.js deleted file mode 100644 index 5aa652ca6f..0000000000 --- a/future-apps/payroll/test/payroll_allowed_tokens.js +++ /dev/null @@ -1,94 +0,0 @@ -const { assertRevert } = require('@aragon/test-helpers/assertThrow') - -const getContract = name => artifacts.require(name) - -contract('Payroll, allowed tokens,', function(accounts) { - const [owner, employee] = accounts - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - redistributeEth, - getDaoFinanceVault, - initializePayroll - } = require("./helpers.js")(owner) - - let payroll - let payrollBase - let priceFeed - let dao - let finance - let vault - - let erc20Tokens = [] - const ERC20_TOKEN_DECIMALS = 18 - const SALARY = (new web3.BigNumber(1)).times(10 ** Math.max(ERC20_TOKEN_DECIMALS - 4, 1)) - const RATE_EXPIRY_TIME = 1000 - const NOW_MOCK = new Date().getTime() - let ETH - let MAX_ALLOWED_TOKENS - - const MAX_GAS_USED = 6.5e6 - - before(async () => { - payrollBase = await getContract("PayrollMock").new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - priceFeed = await getContract("PriceFeedMock").new() - priceFeed.mockSetTimestamp(NOW_MOCK) - - MAX_ALLOWED_TOKENS = (await payrollBase.getMaxAllowedTokens()).valueOf() - // Deploy ERC 20 Tokens (0 is for ETH) - for (let i = 1; i < MAX_ALLOWED_TOKENS; i++) { - erc20Tokens.push(await deployErc20TokenAndDeposit(owner, finance, vault, `Token ${i}`, ERC20_TOKEN_DECIMALS)) - } - - // make sure owner and Payroll have enough funds - await redistributeEth(accounts, finance) - - const etherTokenConstantMock = await getContract("EtherTokenConstantMock").new() - ETH = await etherTokenConstantMock.getETHConstant() - }) - - beforeEach(async () => { - payroll = await initializePayroll( - dao, - payrollBase, - finance, - erc20Tokens[0], - priceFeed, - RATE_EXPIRY_TIME - ) - - await payroll.mockSetTimestamp(NOW_MOCK) - - // adds allowed tokens - await addAllowedTokens(payroll, erc20Tokens) - // check that it's at max capacity - assert.equal((await payroll.getAllowedTokensArrayLength()).valueOf(), MAX_ALLOWED_TOKENS) - - const startDate = parseInt(await payroll.getTimestampPublic.call(), 10) - 2628005 // now minus 1/12 year - // add employee - await payroll.addEmployee(employee, SALARY, "Kakaroto", 'Saiyajin', startDate) - }) - - it('fails adding one more token', async () => { - const erc20Token = await deployErc20TokenAndDeposit(owner, finance, vault, 'Extra token', ERC20_TOKEN_DECIMALS) - - return assertRevert(() => payroll.addAllowedToken(erc20Token.address)) - }) - - it('tests payday and ensures that it does not run out of gas', async () => { - // determine allocation - const receipt1 = await payroll.determineAllocation([ETH, ...erc20Tokens.map(t => t.address)], [100 - erc20Tokens.length, ...erc20Tokens.map(t => 1)], { from: employee }) - assert.isBelow(receipt1.receipt.cumulativeGasUsed, MAX_GAS_USED, 'Too much gas consumed for allocation') - - // call payday - const receipt2 = await payroll.payday({ from: employee }) - assert.isBelow(receipt2.receipt.cumulativeGasUsed, MAX_GAS_USED, 'Too much gas consumed for payday') - }) -}) diff --git a/future-apps/payroll/test/payroll_forward.js b/future-apps/payroll/test/payroll_forward.js deleted file mode 100644 index e5ea8d8e93..0000000000 --- a/future-apps/payroll/test/payroll_forward.js +++ /dev/null @@ -1,90 +0,0 @@ -const Payroll = artifacts.require("PayrollMock"); -const { assertRevert, assertInvalidOpcode } = require('@aragon/test-helpers/assertThrow'); -const { encodeCallScript } = require('@aragon/test-helpers/evmScript'); -const ExecutionTarget = artifacts.require('ExecutionTarget'); -const DAOFactory = artifacts.require('@aragon/os/contracts/factory/DAOFactory'); -const EVMScriptRegistryFactory = artifacts.require('@aragon/os/contracts/factory/EVMScriptRegistryFactory'); -const ACL = artifacts.require('@aragon/os/contracts/acl/ACL'); -const Kernel = artifacts.require('@aragon/os/contracts/kernel/Kernel'); - -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => { return receipt.logs.filter(l => l.event == event)[0].args[arg] } - -const ANY_ADDR = '0xffffffffffffffffffffffffffffffffffffffff'; - -contract('PayrollForward', function(accounts) { - const [owner, employee1, employee2] = accounts - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - getTimePassed, - redistributeEth, - getDaoFinanceVault, - initializePayroll - } = require('./helpers.js')(owner) - - const SECONDS_IN_A_YEAR = 31557600; // 365.25 days - const USD_DECIMALS= 18 - const rateExpiryTime = 1000 - - let dao - let payroll - let payrollBase - let finance - let vault - let priceFeed - let usdToken - let erc20Token1 - const erc20Token1Decimals = 18 - - let unused_account = accounts[7]; - - before(async () => { - payrollBase = await getContract('PayrollMock').new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - usdToken = await deployErc20TokenAndDeposit(owner, finance, vault, "USD", USD_DECIMALS) - priceFeed = await getContract('PriceFeedMock').new() - - // Deploy ERC 20 Tokens - erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 1", erc20Token1Decimals) - - // make sure owner and Payroll have enough funds - await redistributeEth(accounts, finance) - }); - - beforeEach(async () => { - payroll = await initializePayroll(dao, payrollBase, finance, usdToken, priceFeed, rateExpiryTime) - - // adds allowed tokens - await addAllowedTokens(payroll, [usdToken, erc20Token1]) - - // add employee - const receipt = await payroll.addEmployeeShort(employee1, 100000, 'Kakaroto', 'Saiyajin') - employeeId1 = getEvent(receipt, 'AddEmployee', 'employeeId') - }); - - it("checks that it's forwarder", async () => { - let result = await payroll.isForwarder.call(); - assert.equal(result.toString(), "true", "It's not forwarder"); - }); - - it('forwards actions to employee', async () => { - const executionTarget = await ExecutionTarget.new(); - const action = { to: executionTarget.address, calldata: executionTarget.contract.execute.getData() }; - const script = encodeCallScript([action]); - - await payroll.forward(script, { from: employee1 }); - assert.equal((await executionTarget.counter()).toString(), 1, 'should have received execution call'); - - // can not forward call - return assertRevert(async () => { - await payroll.forward(script, { from: unused_account }); - }); - }); -}); diff --git a/future-apps/payroll/test/payroll_gascosts.js b/future-apps/payroll/test/payroll_gascosts.js deleted file mode 100644 index 4b20391467..0000000000 --- a/future-apps/payroll/test/payroll_gascosts.js +++ /dev/null @@ -1,85 +0,0 @@ -const getContract = name => artifacts.require(name) - -contract('Payroll, payday gas costs,', function(accounts) { - const ETH = '0x0' - const USD_DECIMALS = 18 - const USD_PRECISION = 10 ** USD_DECIMALS - const SECONDS_IN_A_YEAR = 31557600 // 365.25 days - const RATE_EXPIRATION_TIME = 1000 - - let payroll, payrollBase, priceFeed, dao, finance, startDate, vault - let usdToken, erc20Token1, erc20Token2, erc20Token1ExchangeRate, erc20Token2ExchangeRate, etherExchangeRate - - const [owner, employee, anotherEmployee] = accounts - const { deployErc20TokenAndDeposit, addAllowedTokens, redistributeEth, getDaoFinanceVault, initializePayroll } = require('./helpers.js')(owner) - - const erc20Token1Decimals = 20 - const erc20Token2Decimals = 16; - - const nowMock = new Date().getTime() - - before(async () => { - payrollBase = await getContract('PayrollMock').new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - usdToken = await deployErc20TokenAndDeposit(owner, finance, vault, 'USD', USD_DECIMALS) - priceFeed = await getContract('PriceFeedMock').new() - priceFeed.mockSetTimestamp(nowMock) - - // Deploy ERC 20 Tokens - erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 1', erc20Token1Decimals) - erc20Token2 = await deployErc20TokenAndDeposit(owner, finance, vault, 'Token 2', erc20Token2Decimals); - - // get exchange rates - etherExchangeRate = (await priceFeed.get(usdToken.address, ETH))[0] - erc20Token1ExchangeRate = (await priceFeed.get(usdToken.address, erc20Token1.address))[0] - erc20Token2ExchangeRate = (await priceFeed.get(usdToken.address, erc20Token2.address))[0] - - // make sure owner and Payroll have enough funds - await redistributeEth(accounts, finance) - }) - - beforeEach(async () => { - payroll = await initializePayroll(dao, payrollBase, finance, usdToken, priceFeed, RATE_EXPIRATION_TIME) - - await payroll.mockSetTimestamp(nowMock) - startDate = parseInt(await payroll.getTimestampPublic.call(), 10) - 2628005 // now minus 1/12 year - - const salary = (new web3.BigNumber(10000)).times(USD_PRECISION).dividedToIntegerBy(SECONDS_IN_A_YEAR) - await payroll.addEmployee(employee, salary, 'John Doe', 'Boss', startDate) - await payroll.addEmployee(anotherEmployee, salary, 'John Doe Jr.', 'Manager', startDate) - }) - - context('when there are not allowed tokens yet', function () { - it('expends ~314k gas for a single allowed token', async () => { - await payroll.addAllowedToken(usdToken.address) - await payroll.determineAllocation([usdToken.address], [100], { from: employee }) - - const { receipt: { cumulativeGasUsed } } = await payroll.payday({ from: employee }) - - assert.isBelow(cumulativeGasUsed, 317000, 'payout gas cost for a single allowed token should be ~314k') - }) - }) - - context('when there are some allowed tokens', function () { - beforeEach('allow some tokens', async () => { - await addAllowedTokens(payroll, [usdToken, erc20Token1, erc20Token2]) - }) - - it('expends ~260k gas per allowed token', async () => { - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [15, 60, 25], { from: employee }) - const { receipt: { cumulativeGasUsed: employeePayoutGasUsed } } = await payroll.payday({ from: employee }) - - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address, erc20Token2.address], [15, 50, 25, 10], { from: anotherEmployee }) - const { receipt: { cumulativeGasUsed: anotherEmployeePayoutGasUsed } } = await payroll.payday({ from: anotherEmployee }) - - const gasPerAllowedToken = anotherEmployeePayoutGasUsed - employeePayoutGasUsed - assert.isBelow(gasPerAllowedToken, 270000, 'payout gas cost increment per allowed token should be ~260k') - }) - }) -}) diff --git a/future-apps/payroll/test/payroll_initialize.js b/future-apps/payroll/test/payroll_initialize.js deleted file mode 100644 index 280db42a6a..0000000000 --- a/future-apps/payroll/test/payroll_initialize.js +++ /dev/null @@ -1,96 +0,0 @@ -const { assertRevert } = require('@aragon/test-helpers/assertThrow') -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => { return receipt.logs.filter(l => l.event == event)[0].args[arg] } - -contract('Payroll, initialization,', function(accounts) { - const [owner, employee1, employee2] = accounts - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - getTimePassed, - redistributeEth, - getDaoFinanceVault, - initializePayroll - } = require('./helpers.js')(owner) - - const ONE = 1e18 - const ETH = '0x0' - const SECONDS_IN_A_YEAR = 31557600 // 365.25 days - const USD_PRECISION = 10**18 - const USD_DECIMALS= 18 - const rateExpiryTime = 1000 - - let dao - let payroll - let payrollBase - let finance - let vault - let priceFeed - let usdToken - let erc20Token1 - let erc20Token2 - const erc20Token1Decimals = 18 - const erc20Token2Decimals = 16 - - before(async () => { - payrollBase = await getContract('PayrollMock').new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - usdToken = await deployErc20TokenAndDeposit(owner, finance, vault, "USD", USD_DECIMALS) - priceFeed = await getContract('PriceFeedMock').new() - - // Deploy ERC 20 Tokens - erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 1", erc20Token1Decimals) - erc20Token2 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 2", erc20Token2Decimals) - - // make sure owner and Payroll have enough funds - await redistributeEth(accounts, finance) - }) - - it("fails to initialize with empty finance", async () => { - const receipt = await dao.newAppInstance('0x4321', payrollBase.address, '0x', false, { from: owner }) - payroll = getContract('PayrollMock').at(getEvent(receipt, 'NewAppProxy', 'proxy')) - - return assertRevert(async () => { - await payroll.initialize('0x0', usdToken.address, priceFeed.address, rateExpiryTime) - }) - }) - - it("initializes contract and checks that initial values match", async () => { - await payroll.initialize(finance.address, usdToken.address, priceFeed.address, rateExpiryTime) - let tmpFinance = await payroll.finance() - assert.equal(tmpFinance.valueOf(), finance.address, "Finance address is wrong") - let tmpUsd = await payroll.denominationToken() - assert.equal(tmpUsd.valueOf(), usdToken.address, "USD Token address is wrong") - }) - - it('fails on reinitialization', async () => { - return assertRevert(async () => { - await payroll.initialize(finance.address, usdToken.address, priceFeed.address, rateExpiryTime) - }) - }) - - it("add allowed tokens", async () => { - const ALLOWED_TOKENS_MANAGER_ROLE = await payrollBase.ALLOWED_TOKENS_MANAGER_ROLE() - const acl = await getContract('ACL').at(await dao.acl()) - const ANY_ENTITY = await acl.ANY_ENTITY() - await acl.createPermission(ANY_ENTITY, payroll.address, ALLOWED_TOKENS_MANAGER_ROLE, owner, { from: owner }) - - // add them to payroll allowed tokens - await addAllowedTokens(payroll, [usdToken, erc20Token1, erc20Token2]) - assert.isTrue(await payroll.isTokenAllowed(usdToken.address), "USD Token should be allowed") - assert.isTrue(await payroll.isTokenAllowed(erc20Token1.address), "ERC 20 Token 1 should be allowed") - assert.isTrue(await payroll.isTokenAllowed(erc20Token2.address), "ERC 20 Token 2 should be allowed") - }) - - it("fails trying to add an already allowed token", async () => { - return assertRevert(async () => { - await payroll.addAllowedToken(usdToken.address) - }) - }) -}) diff --git a/future-apps/payroll/test/payroll_modify_employees.js b/future-apps/payroll/test/payroll_modify_employees.js deleted file mode 100644 index eaaabbd9fa..0000000000 --- a/future-apps/payroll/test/payroll_modify_employees.js +++ /dev/null @@ -1,146 +0,0 @@ -const { assertRevert } = require("@aragon/test-helpers/assertThrow") -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => { - return receipt.logs.filter(l => l.event == event)[0].args[arg] -} - -contract("Payroll, modifying employees,", function(accounts) { - const USD_DECIMALS = 18 - const USD_PRECISION = 10 ** USD_DECIMALS - const SECONDS_IN_A_YEAR = 31557600 // 365.25 days - const ONE = 1e18 - const ETH = "0x0" - const rateExpiryTime = 1000 - - const [owner, employee1_1, employee1_2, employee2] = accounts - const unused_account = accounts[7] - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - getTimePassed, - redistributeEth, - getDaoFinanceVault, - initializePayroll - } = require("./helpers.js")(owner) - let salary1 = new web3.BigNumber(100000) - .times(USD_PRECISION) - .dividedToIntegerBy(SECONDS_IN_A_YEAR) - let salary2 = new web3.BigNumber(120000) - .times(USD_PRECISION) - .dividedToIntegerBy(SECONDS_IN_A_YEAR) - - let usdToken - let erc20Token1 - const erc20Token1Decimals = 18 - - let payroll - let payrollBase - let priceFeed - let employeeId1 - let dao - let finance - let vault - - before(async () => { - payrollBase = await getContract("PayrollMock").new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - usdToken = await deployErc20TokenAndDeposit( - owner, - finance, - vault, - "USD", - USD_DECIMALS - ) - priceFeed = await getContract("PriceFeedMock").new() - - // Deploy ERC 20 Tokens - erc20Token1 = await deployErc20TokenAndDeposit( - owner, - finance, - vault, - "Token 1", - erc20Token1Decimals - ) - - // make sure owner and Payroll have enough funds - await redistributeEth(accounts, finance) - }) - - beforeEach(async () => { - payroll = await initializePayroll( - dao, - payrollBase, - finance, - usdToken, - priceFeed, - rateExpiryTime - ) - - // adds allowed tokens - await addAllowedTokens(payroll, [usdToken, erc20Token1]) - - // add employee - const receipt = await payroll.addEmployeeShort(employee1_1, salary1, 'Kakaroto', 'Saiyajin') - employeeId1 = getEvent(receipt, "AddEmployee", "employeeId") - }) - - it("modifies employee salary", async () => { - await payroll.setEmployeeSalary(employeeId1, salary2) - let employee = await payroll.getEmployee(employeeId1) - assert.equal( - employee[1].toString(), - salary2.toString(), - "Salary doesn't match" - ) - }) - - it("fails modifying non-existent employee salary", async () => { - return assertRevert(async () => { - await payroll.setEmployeeSalary(employeeId1 + 10, salary2) - }) - }) - - it("fails modifying employee account address by Employee, for already existent account", async () => { - // add another employee - await payroll.addEmployeeShort(employee2, salary1, 'Joe', 'Dev') - // try to use account from this other employee - let account_old = employee1_1 - let account_new = employee2 - return assertRevert(async () => { - await payroll.changeAddressByEmployee(account_new, { from: account_old }) - }) - }) - - it("fails modifying employee account address by Employee, for null account", async () => { - let account_old = employee1_1 - let account_new = "0x0" - return assertRevert(async () => { - await payroll.changeAddressByEmployee(account_new, { from: account_old }) - }) - }) - - it("fails modifying employee account address by non Employee", async () => { - let account_new = employee1_2 - return assertRevert(async () => { - await payroll.changeAddressByEmployee(account_new, { - from: unused_account - }) - }) - }) - - it("modifies employee account address by Employee", async () => { - let account_old = employee1_1 - let account_new = employee1_2 - let employeeId = 1 - await payroll.changeAddressByEmployee(account_new, { from: account_old }) - let employee = await payroll.getEmployee(employeeId) - assert.equal(employee[0], account_new, "Employee account doesn't match") - }) - -}) diff --git a/future-apps/payroll/test/payroll_no_init.js b/future-apps/payroll/test/payroll_no_init.js deleted file mode 100644 index dedf77d7e9..0000000000 --- a/future-apps/payroll/test/payroll_no_init.js +++ /dev/null @@ -1,115 +0,0 @@ -const { assertRevert } = require('@aragon/test-helpers/assertThrow'); -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => { - return receipt.logs.filter(l => l.event == event)[0].args[arg] -} - -contract('Payroll, without init,', function(accounts) { - const USD_DECIMALS= 18 - const USD_PRECISION = 10**USD_DECIMALS - const SECONDS_IN_A_YEAR = 31557600 // 365.25 days - const ETH = '0x0' - const rateExpiryTime = 1000 - - const [owner, employee1, _] = accounts - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - getTimePassed, - redistributeEth, - getDaoFinanceVault, - initializePayroll - } = require('./helpers.js')(owner) - const salary1 = 1000 - const erc20Token1Decimals = 18 - - let payroll - let payrollBase - let priceFeed - let usdToken - let erc20Token1 - let employeeId1 - let dao - let finance - let vault - - before(async () => { - payrollBase = await getContract('PayrollMock').new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - usdToken = await deployErc20TokenAndDeposit(owner, finance, vault, "USD", USD_DECIMALS) - priceFeed = await getContract('PriceFeedMock').new() - - // Deploy ERC 20 Tokens - erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 1", erc20Token1Decimals) - - const receipt = await dao.newAppInstance('0x4321', payrollBase.address, '0x', false, { from: owner }) - payroll = getContract('PayrollMock').at(getEvent(receipt, 'NewAppProxy', 'proxy')) - }) - - it('fails to call setPriceFeed', async () => { - return assertRevert(async () => { - await payroll.setPriceFeed(priceFeed.address) - }) - }) - - it('fails to call setRateExpiryTime', async () => { - return assertRevert(async () => { - await payroll.setRateExpiryTime(1000) - }) - }) - - it('fails to call addAllowedToken', async () => { - return assertRevert(async () => { - await payroll.addAllowedToken(erc20Token1.address) - }) - }) - - it('fails to call addEmployee', async () => { - return assertRevert(async () => { - const startDate = Math.floor((new Date()).getTime() / 1000) - await payroll.addEmployee(employee1, 10000, 'Kakaroto', 'Saiyajin', startDate) - }) - }) - - it('fails to call setEmployeeSalary', async () => { - return assertRevert(async () => { - await payroll.setEmployeeSalary(1, 20000) - }) - }) - - it('fails to call terminateEmployee', async () => { - return assertRevert(async () => { - await payroll.terminateEmployee(1, await payroll.getTimestampPublic.call()) - }) - }) - - it('fails to call determineAllocation', async () => { - return assertRevert(async () => { - await payroll.determineAllocation([erc20Token1.address], [100], { from: employee1 }) - }) - }) - - it('fails to call payday', async () => { - return assertRevert(async () => { - await payroll.payday({ from: employee1 }) - }) - }) - - it('fails to call changeAddressByEmployee', async () => { - return assertRevert(async () => { - await payroll.changeAddressByEmployee(owner, { from: employee1 }) - }) - }) - - it('fails to call addAccruedValue', async () => { - return assertRevert(async () => { - await payroll.addAccruedValue(1, 1000) - }) - }) -}) diff --git a/future-apps/payroll/test/payroll_payday.js b/future-apps/payroll/test/payroll_payday.js deleted file mode 100644 index 691efba510..0000000000 --- a/future-apps/payroll/test/payroll_payday.js +++ /dev/null @@ -1,299 +0,0 @@ -const { assertRevert } = require('@aragon/test-helpers/assertThrow') -const getBalance = require('@aragon/test-helpers/balance')(web3) -const getTransaction = require('@aragon/test-helpers/transaction')(web3) - -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => { return receipt.logs.filter(l => l.event == event)[0].args[arg] } - -contract('Payroll, allocation and payday,', function(accounts) { - const USD_DECIMALS = 18 - const USD_PRECISION = 10 ** USD_DECIMALS - const SECONDS_IN_A_YEAR = 31557600 // 365.25 days - const ONE = 1e18 - const ETH = "0x0" - const rateExpiryTime = 1000 - - const [owner, employee] = accounts - const unused_account = accounts[7] - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - getTimePassed, - redistributeEth, - getDaoFinanceVault, - initializePayroll - } = require("./helpers.js")(owner) - - let salary = (new web3.BigNumber(10000)).times(USD_PRECISION).dividedToIntegerBy(SECONDS_IN_A_YEAR) - - let usdToken - let erc20Token1 - let erc20Token1ExchangeRate - const erc20Token1Decimals = 20 - let erc20Token2; - const erc20Token2Decimals = 16; - let etherExchangeRate - - let payroll - let payrollBase - let priceFeed - let employeeId - let dao - let finance - let vault - const nowMock = new Date().getTime() - - before(async () => { - payrollBase = await getContract("PayrollMock").new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - usdToken = await deployErc20TokenAndDeposit(owner, finance, vault, "USD", USD_DECIMALS) - priceFeed = await getContract("PriceFeedMock").new() - priceFeed.mockSetTimestamp(nowMock) - - // Deploy ERC 20 Tokens - erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 1", erc20Token1Decimals) - erc20Token2 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 2", erc20Token2Decimals); - - // get exchange rates - erc20Token1ExchangeRate = (await priceFeed.get(usdToken.address, erc20Token1.address))[0] - etherExchangeRate = (await priceFeed.get(usdToken.address, ETH))[0] - - // make sure owner and Payroll have enough funds - await redistributeEth(accounts, finance) - }) - - beforeEach(async () => { - payroll = await initializePayroll( - dao, - payrollBase, - finance, - usdToken, - priceFeed, - rateExpiryTime - ) - - await payroll.mockSetTimestamp(nowMock) - - // adds allowed tokens - await addAllowedTokens(payroll, [usdToken, erc20Token1]) - - const startDate = parseInt(await payroll.getTimestampPublic.call(), 10) - 2628005 // now minus 1/12 year - // add employee - const receipt = await payroll.addEmployee(employee, salary, "Kakaroto", 'Saiyajin', startDate) - - employeeId = getEvent(receipt, 'AddEmployee', 'employeeId') - }) - - it("fails on payday with no token allocation", async () => { - // make sure this payroll has enough funds - let usdTokenFunds = new web3.BigNumber(10**9).times(USD_PRECISION) - let erc20Token1Funds = new web3.BigNumber(10**9).times(10**erc20Token1Decimals) - - await usdToken.generateTokens(owner, usdTokenFunds) - await erc20Token1.generateTokens(owner, erc20Token1Funds) - - // Send funds to Finance - await usdToken.approve(finance.address, usdTokenFunds, {from: owner}) - await finance.deposit(usdToken.address, usdTokenFunds, "USD payroll", {from: owner}) - await erc20Token1.approve(finance.address, erc20Token1Funds, {from: owner}) - await finance.deposit(erc20Token1.address, erc20Token1Funds, "ERC20 1 payroll", {from: owner}) - - // No Token allocation - return assertRevert(async () => { - await payroll.payday({from: employee}) - }) - }) - - it("fails on payday with a zero exchange rate token", async () => { - let priceFeedFail = await getContract('PriceFeedFailMock').new() - await payroll.setPriceFeed(priceFeedFail.address) - // Allocation - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [10, 20, 70], {from: employee}) - // Zero exchange rate - return assertRevert(async () => { - await payroll.payday({from: employee}) - }) - }) - - it("fails on payday by non-employee", async () => { - // should throw as caller is not an employee - return assertRevert(async () => { - await payroll.payday({from: unused_account}) - }) - }) - - it("fails on payday after 0 seconds", async () => { - // correct priceFeed, make sure rates are correct - await payroll.setPriceFeed(priceFeed.address) - // determine allocation - await payroll.determineAllocation([usdToken.address], [100], {from: employee}) - // correct payday - await payroll.payday({from: employee}) - // payday called again too early: if 0 seconds have passed, payroll would be 0 - return assertRevert(async () => { - await payroll.payday({from: employee}) - }) - }) - - it("fails on Token allocation if greater than 100", async () => { - // should throw as total allocation is greater than 100 - return assertRevert(async () => { - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [20, 30, 90], {from: employee}) - }) - }) - - it("fails on Token allocation because of overflow", async () => { - // should throw as total allocation overflow - return assertRevert(async () => { - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [120, 100, 90], {from: employee}) - }) - }) - - it("fails on Token allocation if lower than 100", async () => { - // should throw as total allocation is lower than 100 - return assertRevert(async () => { - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [5, 30, 40], {from: employee}) - }) - }) - - it("fails on Token allocation for not allowed token", async () => { - // should throw as it's not an allowed token - return assertRevert(async () => { - await payroll.determineAllocation([payroll.address, usdToken.address, erc20Token1.address], [10, 20, 70], {from: employee}) - }) - }) - - it("fails on Token allocation by non-employee", async () => { - // should throw as caller is not an employee - return assertRevert(async () => { - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [10, 20, 70], {from: unused_account}) - }) - }) - - it("fails on Token allocation if arrays mismatch", async () => { - // should throw as arrays sizes are different - return assertRevert(async () => { - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [10, 90], {from: employee}) - }) - }) - - it("tests payday", async () => { - let usdTokenAllocation = 50 - let erc20Token1Allocation = 20 - let ethAllocation = 100 - usdTokenAllocation - erc20Token1Allocation - let initialEthPayroll - let initialUsdTokenPayroll - let initialErc20Token1Payroll - let initialEthEmployee - let initialUsdTokenEmployee - let initialErc20Token1Employee - - const setInitialBalances = async () => { - initialEthPayroll = await getBalance(vault.address) - initialEthEmployee = await getBalance(employee) - // Token initial balances - initialUsdTokenPayroll = await usdToken.balanceOf(vault.address) - initialErc20Token1Payroll = await erc20Token1.balanceOf(vault.address) - initialUsdTokenEmployee = await usdToken.balanceOf(employee) - initialErc20Token1Employee = await erc20Token1.balanceOf(employee) - } - - const logPayroll = function(salary, initialBalancePayroll, initialBalanceEmployee, payed, newBalancePayroll, newBalanceEmployee, expectedPayroll, expectedEmployee, name='') { - console.log("") - console.log("Checking " + name) - console.log("Salary: " + salary) - console.log("-------------------") - console.log("Initial " + name + " Payroll: " + web3.fromWei(initialBalancePayroll, 'ether')) - console.log("Initial " + name + " Employee: " + web3.fromWei(initialBalanceEmployee, 'ether')) - console.log("-------------------") - console.log("Payed: " + web3.fromWei(payed, 'ether')) - console.log("-------------------") - console.log("new " + name + " payroll: " + web3.fromWei(newBalancePayroll, 'ether')) - console.log("expected " + name + " payroll: " + web3.fromWei(expectedPayroll, 'ether')) - console.log("New " + name + " employee: " + web3.fromWei(newBalanceEmployee, 'ether')) - console.log("Expected " + name + " employee: " + web3.fromWei(expectedEmployee, 'ether')) - console.log("-------------------") - console.log("Real payed: " + web3.fromWei(initialBalancePayroll.minus(newBalancePayroll), 'ether')) - console.log("Real earned: " + web3.fromWei(newBalanceEmployee.minus(initialBalanceEmployee), 'ether')) - console.log("") - } - - const checkTokenBalances = async (token, salary, timePassed, initialBalancePayroll, initialBalanceEmployee, exchangeRate, allocation, name='') => { - let payed = salary.times(exchangeRate).times(allocation).times(timePassed).dividedToIntegerBy(100).dividedToIntegerBy(ONE) - let expectedPayroll = initialBalancePayroll.minus(payed) - let expectedEmployee = initialBalanceEmployee.plus(payed) - let newBalancePayroll - let newBalanceEmployee - newBalancePayroll = await token.balanceOf(vault.address) - newBalanceEmployee = await token.balanceOf(employee) - //logPayroll(salary, initialBalancePayroll, initialBalanceEmployee, payed, newBalancePayroll, newBalanceEmployee, expectedPayroll, expectedEmployee, name) - assert.equal(newBalancePayroll.toString(), expectedPayroll.toString(), "Payroll balance of Token " + name + " doesn't match") - assert.equal(newBalanceEmployee.toString(), expectedEmployee.toString(), "Employee balance of Token " + name + " doesn't match") - } - - const checkPayday = async (transaction, timePassed) => { - // Check ETH - let tx = await getTransaction(transaction.tx) - let gasPrice = new web3.BigNumber(tx.gasPrice) - let txFee = gasPrice.times(transaction.receipt.cumulativeGasUsed) - let newEthPayroll = await getBalance(vault.address) - let newEthEmployee = await getBalance(employee) - let payed = salary.times(etherExchangeRate).times(ethAllocation).times(timePassed).dividedToIntegerBy(100).dividedToIntegerBy(ONE) - let expectedPayroll = initialEthPayroll.minus(payed) - let expectedEmployee = initialEthEmployee.plus(payed).minus(txFee) - //logPayroll(salary, initialEthPayroll, initialEthEmployee, payed, newEthPayroll, newEthEmployee, expectedPayroll, expectedEmployee, "ETH") - assert.equal(newEthPayroll.toString(), expectedPayroll.toString(), "Payroll Eth Balance doesn't match") - assert.equal(newEthEmployee.toString(), expectedEmployee.toString(), "Employee Eth Balance doesn't match") - // Check Tokens - await checkTokenBalances(usdToken, salary, timePassed, initialUsdTokenPayroll, initialUsdTokenEmployee, ONE, usdTokenAllocation, "USD") - await checkTokenBalances(erc20Token1, salary, timePassed, initialErc20Token1Payroll, initialErc20Token1Employee, erc20Token1ExchangeRate, erc20Token1Allocation, "ERC20 1") - } - // determine allocation - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [ethAllocation, usdTokenAllocation, erc20Token1Allocation], {from: employee}) - await setInitialBalances() - const timePassed = await getTimePassed(payroll, employeeId) - // call payday - let transaction = await payroll.payday({from: employee}) - await checkPayday(transaction, timePassed) - - // check that we can call payday again after some time - // set time forward, 1 month - const timePassed2 = 2678400 - await payroll.mockAddTimestamp(timePassed2) - // we need to forward time in price feed, or rate will be obsolete - await priceFeed.mockAddTimestamp(timePassed2) - await setInitialBalances() - // call payday again - let transaction2 = await payroll.payday({from: employee}) - await checkPayday(transaction2, timePassed2) - - // check that determineAllocation can be called again - // set time forward, 5 more months - const timePassed3 = 13392000 // 5 * 31 * 24 * 3600 - await payroll.mockAddTimestamp(timePassed3) - // we need to forward time in price feed, or rate will be obsolete - await priceFeed.mockAddTimestamp(timePassed3) - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [15, 60, 25], {from: employee}) - assert.equal((await payroll.getAllocation(employeeId, ETH)).valueOf(), 15, "ETH allocation doesn't match") - assert.equal((await payroll.getAllocation(employeeId, usdToken.address)).valueOf(), 60, "USD allocation doesn't match") - assert.equal((await payroll.getAllocation(employeeId, erc20Token1.address)).valueOf(), 25, "ERC 20 Token 1 allocation doesn't match") - }) - - it("determining allocation deletes previous entries", async () => { - await payroll.determineAllocation([ETH], [100], {from: employee}) - await payroll.determineAllocation([usdToken.address], [100], {from: employee}) - const tokens = [usdToken, erc20Token1, erc20Token2] - const currencies = [ETH].concat(tokens.map(c => c.address)) - let totalAllocation = 0 - for (let tokenAddress of currencies) { - totalAllocation += parseInt(await payroll.getAllocation(employeeId, tokenAddress), 10) - } - assert.equal(totalAllocation, 100, "Total allocation should remain 100") - }) -}) diff --git a/future-apps/payroll/test/payroll_pricefeed.js b/future-apps/payroll/test/payroll_pricefeed.js deleted file mode 100644 index 3beb08266f..0000000000 --- a/future-apps/payroll/test/payroll_pricefeed.js +++ /dev/null @@ -1,118 +0,0 @@ -const { assertRevert } = require('@aragon/test-helpers/assertThrow') -const getBalance = require('@aragon/test-helpers/balance')(web3) -const getTransaction = require('@aragon/test-helpers/transaction')(web3) - -const getContract = name => artifacts.require(name) -const getEvent = (receipt, event, arg) => { return receipt.logs.filter(l => l.event == event)[0].args[arg] } - -contract('Payroll, price feed,', function(accounts) { - const USD_DECIMALS = 18 - const USD_PRECISION = 10 ** USD_DECIMALS - const SECONDS_IN_A_YEAR = 31557600 // 365.25 days - const ONE = 1e18 - const ETH = "0x0" - const rateExpiryTime = 1000 - - const [owner, employee] = accounts - const unused_account = accounts[7] - const { - deployErc20TokenAndDeposit, - addAllowedTokens, - getTimePassed, - redistributeEth, - getDaoFinanceVault, - initializePayroll - } = require("./helpers.js")(owner) - - let salary = (new web3.BigNumber(10000)).times(USD_PRECISION).dividedToIntegerBy(SECONDS_IN_A_YEAR) - - let usdToken - let erc20Token1 - let erc20Token1ExchangeRate - const erc20Token1Decimals = 20 - let erc20Token2; - const erc20Token2Decimals = 16; - let etherExchangeRate - - let payroll - let payrollBase - let priceFeed - let employeeId - let dao - let finance - let vault - - before(async () => { - payrollBase = await getContract("PayrollMock").new() - - const daoAndFinance = await getDaoFinanceVault() - - dao = daoAndFinance.dao - finance = daoAndFinance.finance - vault = daoAndFinance.vault - - usdToken = await deployErc20TokenAndDeposit(owner, finance, vault, "USD", USD_DECIMALS) - priceFeed = await getContract("PriceFeedMock").new() - - // Deploy ERC 20 Tokens - erc20Token1 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 1", erc20Token1Decimals) - erc20Token2 = await deployErc20TokenAndDeposit(owner, finance, vault, "Token 2", erc20Token2Decimals); - - // make sure owner and Payroll have enough funds - await redistributeEth(accounts, finance) - }) - - beforeEach(async () => { - payroll = await initializePayroll( - dao, - payrollBase, - finance, - usdToken, - priceFeed, - rateExpiryTime - ) - - // adds allowed tokens - await addAllowedTokens(payroll, [usdToken, erc20Token1]) - }) - - it('fails to pay if rates are obsolete', async () => { - // add employee - const startDate = parseInt(await payroll.getTimestampPublic.call(), 10) - 2628005 // now minus 1/12 year - const receipt = await payroll.addEmployee(employee, salary, "Kakaroto", 'Saiyajin', startDate) - const employeeId = getEvent(receipt, 'AddEmployee', 'employeeId') - - const usdTokenAllocation = 50 - const erc20Token1Allocation = 20 - const ethAllocation = 100 - usdTokenAllocation - erc20Token1Allocation - // determine allocation - await payroll.determineAllocation([ETH, usdToken.address, erc20Token1.address], [ethAllocation, usdTokenAllocation, erc20Token1Allocation], {from: employee}) - await getTimePassed(payroll, employeeId) - // set old date in price feed - const oldTime = parseInt(await payroll.getTimestampPublic(), 10) - rateExpiryTime - 1 - await priceFeed.mockSetTimestamp(oldTime) - // call payday - return assertRevert(async () => { - await payroll.payday({from: employee}) - }) - }) - - it('fails to change the price feed time to 0', async () => { - return assertRevert(async () => { - await payroll.setPriceFeed('0x0') - }) - }) - - it('changes the rate expiry time', async () => { - const newTime = rateExpiryTime * 2 - await payroll.setRateExpiryTime(newTime) - assert.equal(await payroll.rateExpiryTime(), newTime) - }) - - it('fails to change the rate expiry time to 0', async () => { - const newTime = 0 - return assertRevert(async () => { - await payroll.setRateExpiryTime(newTime) - }) - }) -}) diff --git a/shared/test-helpers/assertThrow.js b/shared/test-helpers/assertThrow.js index 01f0b38ca2..0430de4a13 100644 --- a/shared/test-helpers/assertThrow.js +++ b/shared/test-helpers/assertThrow.js @@ -23,7 +23,7 @@ module.exports = { const error = await assertThrows(blockOrPromise, 'revert') if (expectedReason) { const foundReason = error.message.replace(THROW_ERROR_PREFIX, '').trim() - assert.equal(expectedReason, foundReason, `Expected revert reason "${expectedReason}" but failed with "${foundReason || 'no reason'}" instead.`) + assert.equal(foundReason, expectedReason, `Expected revert reason "${expectedReason}" but failed with "${foundReason || 'no reason'}" instead.`) } }, }