Prepared by:
HALBORN
Last Updated 12/03/2024
Date of Engagement by: October 16th, 2023 - October 27th, 2023
100% of all REPORTED Findings have been addressed
All findings
14
Critical
0
High
0
Medium
3
Low
5
Informational
6
The Plural Protocol is an EVM smart contract protocol for tokenizing RWAs in a regulatory compliant manner. Features include restricted transfer, recovery mechanisms, and continuous dividend accrual and distribution to token holders.
\client engaged Halborn
to conduct a security assessment on their smart contracts beginning on 2023-10-16 and ending on 2023-10-27. The security assessment was scoped to the smart contracts provided in the plural-energy/plural-protocol GitHub repository. Commit hashes and further details can be found in the Scope section of this report.
Halborn was provided 2 weeks for the engagement and assigned one full-time security engineer to review the security of the smart contracts in scope. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the assessment is to:
Identify potential security issues within the smart contracts.
Ensure that smart contract functionality operates as intended.
In summary, Halborn identified some security risks, that were successfully addressed by Plural Energy. The main ones are the following:
Use OpenZeppelin's SafeERC20
wrapper with the IERC20
interface to make the contracts compatible with currencies that return no value.
Fix the recover()
function of the AssetToken
contract to enable the Owner
to burn tokens from users.
Add a validation to the linkWallet()
function that verifies that its child
parameter is not a parent.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
Research into architecture and purpose.
Smart contract manual code review and walk-through.
Graphing out functionality and contract logic/connectivity/functions (solgraph
).
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.
Manual testing by custom scripts.
Static Analysis of security for scoped contract, and imported functions (Slither
).
Testnet deployment (Foundry
, Brownie
).
Code repositories:
Plural Protocol
Repository: plural-energy/plural-protocol
Commit ID: dc87e2759f517bdad463f3dbca771ee2a98fb32d
Smart contracts in scope:
src/RewardsManagerConstant.sol
src/lib/QuarterPeriod.sol
src/lib/Structs.sol
src/lib/Roles.sol
src/lib/MonthPeriod.sol
src/AssetFactory.sol
src/AssetToken.sol
src/BaseAccessControlPausable.sol
src/BaseAccessControlPausableUpgradeable.sol
src/Compliance.sol
src/RewardsManagerRetroactive.sol
Fix Commit ID: The fixes of each findings are listed in their corresponding section.
src/RewardsManagerConstant.sol
src/lib/QuarterPeriod.sol
src/lib/Structs.sol
src/lib/Roles.sol
src/lib/MonthPeriod.sol
src/AssetFactory.sol
src/AssetToken.sol
src/BaseAccessControlPausable.sol
src/BaseAccessControlPausableUpgradeable.sol
src/Compliance.sol
src/RewardsManagerRetroactive.sol
Fix Commit ID: The fixes of each findings are listed in their corresponding section.
Out-of-scope
Third-party libraries and dependencies.
Economic attacks.
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
3
Low
5
Informational
6
Security analysis | Risk level | Remediation Date |
---|---|---|
USING TRANSFER INSTEAD OF SAFETRANSFER | Medium | Solved - 11/08/2023 |
IMPROPER TOKEN RECOVERY IMPLEMENTATION | Medium | Solved - 11/21/2023 |
IMPROPER LINKWALLET VALIDATION | Medium | Solved - 11/20/2023 |
IMPROPER ASSETTOKEN UPDATE FUNCTION | Low | Solved - 11/20/2023 |
IMPROPER REWARDS DISTRIBUTION OVERLAPPING CHECK | Low | Solved - 11/08/2023 |
TOKEN ROLES CAN BE ADDED MULTIPLE TIMES | Low | Solved - 11/08/2023 |
IMPROPER ROLE-BASED ACCESS CONTROL | Low | Solved - 11/20/2023 |
SINGLE STEP OWNERSHIP TRANSFER PROCESS | Low | Solved - 11/08/2023 |
OWNER CAN RENOUNCE OWNERSHIP | Informational | Solved - 11/21/2023 |
LACK OF EMERGENCY STOP PATTERN IMPLEMENTATION | Informational | Solved - 11/08/2023 |
MISSING EVENTS FOR CONTRACT OPERATIONS | Informational | Solved - 11/08/2023 |
LACK OF DISABLEINITIALIZERS IN THE IMPLEMENTATION CONTRACT | Informational | Solved - 11/08/2023 |
FOR LOOPS CAN BE GAS OPTIMIZED | Informational | Solved - 11/08/2023 |
USING REVERT STRINGS INSTEAD OF CUSTOM ERRORS | Informational | Solved - 11/08/2023 |
// Medium
It was identified that the claim()
function in the RewardsManagerRetroactive
and RewardsManagerConstant
contracts use the IERC20
interface to interact with the rewardTokenAddress
to transfer currencies from the contract to the parameter wallet. The IERC20
interface expects the transfer
function to have a return value on success. The rewards manager contracts are designed to be used with different currencies. It is important to note that the transfer functions of some tokens (e.g., USDT, BNB) do not return any values, so these tokens are incompatible with the current version of the rewards' manager contracts.
The claim()
function uses the IERC20
interface to interact with rewardTokenAddress
:
accountClaimedRewards[account] = claimed + unscaledClaimable;
claimedRewards += unscaledClaimable;
bool success = IERC20(rewardTokenAddress).transfer(account, claimable);
if (!success) {
revert ClaimError();
}
emit RewardClaimed(assetTokenAddress, account, unscaledClaimable);
The claim()
function reverts if used with tokens not having a return value (e.g., USDT):
SOLVED: The \client team solved the issue in commit 3735f2f by using the OpenZeppelin's SafeERC20
wrapper.
// Medium
The Owner
can recover funds from a compromised account by burning and re-minting tokens. However, it was identified that the recover()
function was implemented using the mint()
function, which reverts if the total supply is not 0. Therefore, tokens could only be recovered if only one wallet held the entire total supply.
Note that it was also identified that the recover()
function does not verify if the wallet is sanctioned.
The recover()
function uses the AssetToken
's mint()
function instead of OpenZeppelin
's ERC20._mint()
:
function recover(address from, uint256 amount) public override onlyOwner {
/// @dev: to start, recovery can consist of blocking transfer for a wallet, either through sanctioning
/// or role removal, then once that's done we can burn and remint
/// @dev: require multisig here: e.g. 2/3 multisig with keys held by admin, regulators, and a trusted third party
address owner = owner();
_burn(from, amount);
mint(owner, amount);
emit Recovered(from, owner, amount);
}
The mint()
function reverts if the total supply is not 0:
function mint(address to, uint256 amount) public override onlyOwner {
compliance.authorizeTransfer(msg.sender, to, address(this), amount);
require(totalSupply() == 0, "Not allowing additional minting");
require(decimals() <= 18, "Max decimals for token is 18");
require(amount <= 1e9 * 10 ** decimals(), "Limit tokens to 1 billion to prevent overflow");
super._mint(to, amount);
IRewardsManager(rewardsManager).signalAssetTokensMinted();
}
The recover()
function reverts:
// Medium
A wallet can be linked to a parent wallet by the Owner
with the linkWallet()
function of the Compliance
contract. The contract assumes that it is not possible for a wallet to be both a child and a parent. Otherwise, there would be cases where the sanction checks could be bypassed. However, because of the improper validation, it is possible to link more than two wallets.
Wallets can be linked using the linkWallet()
function.
function linkWallet(
address child,
address parent
) external override onlyOwner {
if (hasRole(Roles.ROLE_SANCTIONED, parent)) {
revert ParentSanctioned();
}
if (linkedWallets[parent] != address(0)) {
revert ParentWalletIsChild();
}
linkedWallets[child] = parent;
emit LinkedWallet(parent, child);
}
Reversing the linking order of the wallets bypasses the validation check:
SOLVED: The \client team solved the issue in commit a44aa74 by adding the validation.
// Low
It was identified that the updateTokenImplementation()
function in the AssetFactory
contract calls the upgradeTo()
function of the AssetToken
contract, but the upgradeTo()
function can only be called by the Owner
of the AssetToken
contract. Because the Owner
is a wallet that manages the AssetToken
contract and the AssetFactory
does not have the required permission, the updateTokenImplementation()
function reverts.
function updateTokenImplementation(
address token,
address newImplementation
) external override onlyOwner {
if (newImplementation == address(0)) {
revert ZeroImplementationAddress();
}
AssetToken(token).upgradeTo(newImplementation);
emit UpdatedTokenImplementation(newImplementation);
}
function upgradeTo(address newImplementation) public override onlyOwner {
super.upgradeTo(newImplementation);
}
SOLVED: The \client team solved the issue in commit a44aa74 by configuring the AssetFactory
as Owner
during initialization and using a different role to manage the AssetToken
contract.
// Low
It was identified that the depositRewards()
function in the RewardsManagerConstant
contract uses an improper distribution period overlapping validation. Therefore, it is possible to start a new reward period before the end of the current one, despite validation check and documentation. However, it is important to note that the rewards did not stack, as the previous rate and duration is overwritten with the new one, and the new reward is not applied retroactively.
The depositRewards()
function compares the current block timestamp to the time elapsed since the genesis point, and therefore this validation will always be true:
function depositRewards(
uint64 distributionDuration,
uint64 rewardPerSecond
) external override {
if (
!hasRole(rewardsManagerRole, msg.sender) &&
!hasRole(OWNER_ROLE, msg.sender)
) {
revert NotRewardsManager();
}
if (rewardPerSecond > REWARD_PER_SECOND_MAX) {
revert RewardRateTooHigh();
}
/// Prevent underflow with online avgerage calculation, we shouldn't have rewards this small
/// anyways. At 1e6 precision, this is $0.000001/second => ~$31/year
if (rewardPerSecond < REWARD_PER_SECOND_MIN) {
revert RewardRateTooLow();
}
uint40 currentTime = uint40(block.timestamp);
// Check that the now > accrualEnd so we don't stack dividends
RewardsConfig memory config = _updateReward();
if (currentTime < config.accrualEnd) {
revert NoOverlappingRewards();
}
if (config.genesisTimestamp <= 0) {
config.genesisTimestamp = currentTime;
}
uint64 distributionEnd = _currentOffset(config.genesisTimestamp) +
distributionDuration;
config.receivedRewards += rewardPerSecond * distributionDuration;
config.accrualEnd = distributionEnd;
config.currentPeriodRewardPerSecond = rewardPerSecond;
After 1 year, Bob should have earned 3153601 USDT. However, it was possible to start a new reward period that overwrote the original one.
SOLVED: The \client team solved the issue in commit 09c9c2f by not allowing depositing rewards until the current period ends.
Note that, in this version, it is not possible to cancel or modify the active reward period. However, the administrators can withdraw the deposited reward tokens from the contract.
// Low
It was identified that the same allowed role can be added to the asset token multiple times. This might result in unexpected behavior, for example, in a deadlock situation where removing the roles would not be possible because of an Index out of range
error.
Roles can be associated with tokens using the addAllowedRolesToTokens()
and resumeRoles()
functions:
function addAllowedRolesToTokens(
address[] calldata assetTokens,
string[] calldata roles
) public override onlyOwner {
for (uint i = 0; i < assetTokens.length; i++) {
for (uint j = 0; j < roles.length; j++) {
tokenAllowedRoles[assetTokens[i]].push(
keccak256(abi.encodePacked(roles[j]))
);
}
}
emit AddedRolesToTokens(assetTokens, roles);
}
function resumeRoles(
address tokenAddress,
bytes32[] calldata roles
) external override onlyOwner {
for (uint256 i = 0; i < roles.length; i++) {
tokenAllowedRoles[tokenAddress].push(roles[i]);
}
emit ResumedRoles(tokenAddress, roles);
}
function _halt(address tokenAddress, bytes32 roleToRemove) internal {
bytes32[] memory allowedRoles = tokenAllowedRoles[tokenAddress];
uint rolesLength = allowedRoles.length;
for (uint256 x = 0; x < rolesLength; x++) {
if (allowedRoles[x] == roleToRemove) {
tokenAllowedRoles[tokenAddress][x] = tokenAllowedRoles[
tokenAddress
][rolesLength - 1];
tokenAllowedRoles[tokenAddress].pop();
}
}
}
SOLVED: The \client team solved the issue in commit 3cae28c.
// Low
The Compliance
contract has different roles to manage the compliance status of the wallets. However, it was identified that the addRoleToAccount()
, addRoleToAccounts()
and removeRoleFromAccounts()
, sanctionWallets()
and sanctionWallet()
functions can only be used by the Owner
. This limits the ROLE_MANAGER
's ability to manage the permissions effectively.
The roles are configured in the initialize()
function.
function initialize(address owner) public override initializer {
if (owner == address(0)) {
revert ZeroOwnerAddress();
}
__BaseAccessControlPausable__init(owner);
_setRoleAdmin(Roles.ROLE_MANAGER, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_KYC_APPROVED, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_US_KYC_APPROVED, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_KYC_ACCREDITED, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_US_KYC_ACCREDITED, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_KYB_APPROVED, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_US_KYB_APPROVED, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_PRIVATE_PLACEMENT, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_PROTOCOL, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_PENDING_SANCTIONED, OWNER_ROLE);
_setRoleAdmin(Roles.ROLE_SANCTIONED, OWNER_ROLE);
}
However, administrative functions can only be called by the Owner. For example:
function addRoleToAccounts(
string calldata roleName,
address[] calldata accounts
) external override onlyOwner {
for (uint256 i = 0; i < accounts.length; i++) {
addRoleToAccount(roleName, accounts[i]);
}
}
// Low
The ownership of the contracts can be lost as the AssetToken
contract is inherited from the OwnableUpgradeable contract and its ownership can be transferred in a single-step process. The address the ownership is changed to should be verified to be active or willing to act as the owner.
function transferOwnership(address newOwner) public virtual onlyOwner {
require(newOwner != address(0), "Ownable: new owner is the zero address");
_transferOwnership(newOwner);
}
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
SOLVED: The \client team solved the issue in commit 971f710 by using the Ownable2StepUpgradeable
library over the OwnableUpgradeable
library in the AssetToken
contract.
// Informational
The AssetToken
contract is inherited from the OwnableUpgradeable contract. The Owner
of the contract is usually the account that deploys the contract. As a result, the Owner
can perform some privileged functions. In the Ownable
contracts, the renounceOwnership()
function is used to renounce the Owner
permission. Renouncing ownership before transferring would result in the contract having no Owner
, eliminating the ability to call privileged functions.
function renounceOwnership() public virtual onlyOwner {
_transferOwnership(address(0));
}
SOLVED: The \client team solved the issue in commit f100681 by overriding the renounceOwnership()
function.
// Informational
It was identified that the whennotpaused
modifier is not used in the Compliance
contract, even though it is inherited from the BaseAccessControlPausableUpgradeable
contract.
SOLVED: The \client team solved the issue in commit 514e35d by using the emergency stop pattern with the authorizeTransfer()
function.
// Informational
It was identified that the setRewardsManager()
function in the AssetToken
contract does not emit any events. As a result, it might be more difficult for blockchain monitoring systems to detect suspicious behavior related to these features.
// Informational
The contracts use the Initializable
module from OpenZeppelin, and the implementations of these contracts are not marked as initialized in the constructor. An uninitialized instance can be initialized by anyone to take over the contract. Even if it does not affect the instances deployed by the AssetFactory
, it is still a good practice to prevent the initialization of the implementation contracts to avoid misuse. In the latest versions, this is done by calling the _disableInitializers
function in the constructor.
SOLVED: The \client team solved the issue in commit 565ddec by including a constructor to automatically mark the contracts as initialized when they are deployed.
// Informational
It was identified that the for loops employed in the Compliance
, RewardsManagerConstant
and RewardsManagerRetroactive
contracts can be gas optimized by the following principles:
i++
) operator was used to increment the i
variables. It is known that, in loops, using prefix operators (e.g. ++i
) costs less gas per iteration than postfix operators. Example function using unoptimized for loops:
function addAllowedRolesToTokens(
address[] calldata assetTokens,
string[] calldata roles
) public override onlyOwner {
for (uint i = 0; i < assetTokens.length; i++) {
for (uint j = 0; j < roles.length; j++) {
tokenAllowedRoles[assetTokens[i]].push(
keccak256(abi.encodePacked(roles[j]))
);
}
}
emit AddedRolesToTokens(assetTokens, roles);
}
SOLVED: The \client team solved the issue in commit 9a16372 by applying the recommendations.
// Informational
It was identified that revert strings are used in the AssetToken
, BaseAccessControlPausable
and BaseAccessControlPausableUpgradeable
contracts. Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. If the revert string uses strings to provide additional information about failures (e.g. require(amount <= 1e9 * 10 ** decimals(), "Limit tokens to 1 billion to prevent overflow");
), but they are rather expensive, especially when it comes to deploying cost, and it is difficult to use dynamic information in them.
Example usage of revert strings in the AssetToken
contract:
function mint(address to, uint256 amount) public override onlyOwner {
compliance.authorizeTransfer(msg.sender, to, address(this), amount);
require(totalSupply() == 0, "Not allowing additional minting");
require(decimals() <= 18, "Max decimals for token is 18");
require(amount <= 1e9 * 10 ** decimals(), "Limit tokens to 1 billion to prevent overflow");
super._mint(to, amount);
IRewardsManager(rewardsManager).signalAssetTokensMinted();
}
SOLVED: The \client team solved the issue in commit d2bbf3a by using custom errors instead of reverting strings.
Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their ABIs and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base. The security team assessed all findings identified by the Slither software, however, findings with severity Information
and Optimization
are not included in the below results for the sake of report readability.
contracts/AssetFactory.sol
\input{slither/AssetFactory}
contracts/AssetToken.sol
\input{slither/AssetToken}
contracts/BaseAccessControlPausable.sol
Slither did not identify any vulnerabilities in the contract.
contracts/BaseAccessControlPausableUpgradeable.sol
Slither did not identify any vulnerabilities in the contract.
contracts/Compliance.sol
\input{slither/Compliance}
contracts/RewardsManagerConstant.sol
\input{slither/RewardsManagerConstant}
contracts/RewardsManagerRetroactive.sol
\input{slither/RewardsManagerRetroactive}
The findings obtained as a result of the Slither scan were reviewed. The majority of Slither findings were determined false-positives.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed