neutrl-contracts
Cantina Security Report
Organization
- @Neutrl
Engagement Type
Spearbit Web3
Period
-
Researchers
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
6 findings
5 fixed
1 acknowledged
Informational
17 findings
13 fixed
4 acknowledged
Gas Optimizations
6 findings
4 fixed
2 acknowledged
Medium Risk1 finding
Users can decrease their asset lock end times
State
- Fixed
PR #35
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Low Submitted by
Kurt Barry
Description
The
AssetLock.lockAsset()
function allows users to add assets to an existing locked position. They are also able to specify a new duration which is applied to the position without regard for the existing expiration:lockEndTime: uint64(block.timestamp + _lockDuration)
It's possible for the new lock end time to be earlier than the original lock end time. This applies as well to the
lockAssetOnBehalf()
function added in PR#20.Recommendation
Use the maximum of the current
lockEndTime
andblock.timestamp + _lockDuration
as the updatedlockEndTime
.
Low Risk6 findings
Granting multiple roles to the same address is risky
Summary
Granting multiple roles, which have different levels/types of authorization, to the same address is risky because it defeats the motivation for role based access control to impose separation of privileges.
Finding Description
Different protocol components define different roles to enforce role based access control. However, all the roles are initialized to a single address/account. This defeats the purpose of role based access control:
- NUSD assigns
_admin
to bothDEFAULT_ADMIN_ROLE
andDENYLIST_MANAGER_ROLE
. - sNUSD assigns
_owner
to bothDEFAULT_ADMIN_ROLE
andPAUSER_ROLE
. - Router assigns
_admin
toDEFAULT_ADMIN_ROLE
,WHITELISTER_ROLE
, andPAUSER_ROLE
. - AssetLock assigns
_admin
toDEFAULT_ADMIN_ROLE
,MANAGER_ROLE
, andPAUSER_ROLE
. - YieldDistributor assigns
_admin
to bothDEFAULT_ADMIN_ROLE
andYIELD_TOKEN_MANAGER_ROLE
.
These roles have different levels/types of privileges and should be granted to different actors.
DEFAULT_ADMIN_ROLE
, which has the highest level of privilege should not be given to addresses that also control lower privileged roles because their risk profiles are different which allows their wallet operational security to be managed proportionately.Impact Explanation
High, because if that one account is compromised then protocol functionality controlled by all its roles is compromised.
Likelihood Explanation
Very low, assuming that single account has the highest level of operational security and is not compromised.
Recommendation
Consider using different addresses/accounts to initialize different roles within the protocol components.
Neutrl
Spearbit
Reviewed that PR #31 removes multiple assignments of roles to the same address and instead moves those role assignments to the deployment script where different addresses (dummy zero addresses for now which are noted to be replaced later) are assigned to these different roles.
- NUSD assigns
Removed yield tokens can never be added again affecting protocol yield strategy
Summary
Removed yield tokens from
YieldDistributor
can never be added again, which negatively affects protocol yield strategy and distribution thereafter.Finding Description
YieldDistributor
manages and distributes yield from accepted stablecoins (USDC, USDT and USDe) to sNUSD holders. While the yield generation happens offchain, the generated yield in USDC, USDT and USDe is converted to NUSD viaconvertYieldToNUSD()
and then distributed to sNUSD holders viadistributeYield()
.The protocol manages the accepted yield tokens via
addYieldToken()
andremoveYieldToken()
.addYieldToken()
prevents addition of duplicated yield tokens by checking if the one being added already exists in theyieldTokens
array.removeYieldToken()
removes a yield token not by removing it from the array but only marking it as inactive viayieldTokens[i].isActive = false
.Given this differing logic during addition and removal, once a yield token is removed by marking it as inactive, it can never be added back again because
addYieldToken()
only checks for its presence and not forisActive == true
.Impact Explanation
Low, because not being able to add a yield token back will negatively impact protocol yield strategy and distribution thereafter.
Likelihood Explanation
Low, assuming it is unlikely for the admin to remove a yield token and then want to add it back later for some reason.
Recommendation
Consider checking for
yieldTokens[i].isActive == true
along withyieldTokens[i].token == _yieldToken
inaddYieldToken()
logic, so that:- If a token is present but
isActive == false
then it can be set totrue
. - If a token is present and
isActive == true
then it can revert. - If a token is absent then it can be added.
- If a token is present but
Using a single EOA deployer for all privileged roles across protocol is risky
Summary
DeployProtocol
script currently uses an EOA deployer address for all privileged roles across the protocol, which is extremely risky.Finding Description
DeployProtocol
script derives a deployer address from a private key read from an environment variable and uses that in calls todeployDeterministic(deployer)
anddeployOthers(deployer)
.deployDeterministic(deployer)
uses that deployer address to initialize all privileged roles across NUSD and sNUSD contracts.deployOthers(deployer)
uses the same deployer address to initialize all privileged roles acrossRouter
,StableMinter
,Redeemer
,AssetReserve
,AssetLock
, andYieldDistributor
contracts.Impact Explanation
High, because if that single EOA is compromised, the entire protocol can be critically impacted given the centralized roles and responsibilities as documented in Finding 5.
Likelihood Explanation
Very low, assuming that this is a placeholder for now, will be replaced by a safer alternative and transferred immediately after deployment with a combination of multisig/mpc wallets (as communicated).
Recommendation
Consider:
- Using safer alternatives than reading the deployer key from an environment variable as described in Foundry's key management best practices.
- Transferring the EOA deployer to a combination of multisig/mpc wallets immediately after deployment.
- Revoking all access from the EOA deployer immediately (and atomically) after step (2).
Neutrl
Spearbit
Reviewed that PR #30 adds a
mpcAdminWallet
(dummy zero-address for now noting that this will be replaced by the actual MPC admin wallet once its setup is complete) to which the admin roles of all deployed contracts are immediately transferred from the earlier used deployer address inrun()
.Users can take advantage of share values changing suddenly when the vesting period is increased
State
- Fixed
PR #32
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Kurt Barry
Description
The
setVestingPeriod()
function checks that thegetUnvestedAmount()
function returns zero and reverts if not. However, an increase of the vesting period can still affect the unvested rewards, because neithervestingAmount
norlastDistributionTimestamp
are updated. This will lead to a discontinuous change in the return of thetotalAssets()
function and thus affect the value per share. Specifically, if the vesting period is lengthened, and the new value oflastDistributionTimestamp + vestingPeriod
is in the future, a fraction of thevestingAmount
value will become unvested again.Users can take advantage of this as follows:
- Withdraw just prior to a vesting period increase. This will withdraw some amount of the vested rewards.
- Redeposit after the increase, re-earning a portion of the previously vested rewards at the expense of other users.
Proof of Concept
The following test can be added to
test/unit/concrete/sNUSD/authorized/sNUSD_authorized.t.sol
and run viaforge test --match-test test_ChangingVestingPeriodChangesUnvestedRewards
:function test_ChangingVestingPeriodChangesUnvestedRewards() external { address user1 = address(0x1111); address user2 = address(0x2222); deal(address(nusd), user1, 1 ether); deal(address(nusd), user2, 1 ether); // Both users deposit. vm.startPrank(user1); nusd.approve(address(sNusd), type(uint256).max); sNusd.deposit(1 ether, user1); vm.stopPrank(); vm.startPrank(user2); nusd.approve(address(sNusd), type(uint256).max); sNusd.deposit(1 ether, user2); vm.stopPrank(); // First transfer to create unvested rewards vm.startPrank(rewarder); deal(address(nusd), rewarder, 1 ether); nusd.approve(address(sNusd), 1 ether); sNusd.transferInRewards(1 ether); vm.stopPrank(); uint256 vestingPeriod = sNusd.vestingPeriod(); vm.warp(sNusd.lastDistributionTimestamp() + vestingPeriod); // All rewards have vested. assertEq(sNusd.getUnvestedAmount(), 0); // The two users, having made identical deposits at the identical time, have equal assets. assertEq(sNusd.convertToAssets(sNusd.balanceOf(user1)), sNusd.convertToAssets(sNusd.balanceOf(user2))); // user1 withdraws just before the vesting period update // (for simplicity, we are in instant withdrawal mode) vm.startPrank(users.admin); sNusd.setCooldownDuration(0); vm.stopPrank(); vm.startPrank(user1); sNusd.redeem(sNusd.balanceOf(user1), user1, user1); vm.stopPrank(); uint256 totalAssetsBefore = sNusd.totalAssets(); vm.startPrank(users.admin); sNusd.setVestingPeriod(2 * vestingPeriod); vm.stopPrank(); // This should still be zero but instead it has gone back to half of the unvestedRewards value. assertEq(sNusd.getUnvestedAmount(), 0.5e18); // Total assets has changed discontinuously assertEq(totalAssetsBefore - 0.5e18, sNusd.totalAssets()); // user1 redeposits, including their accrued interest, to take advantage of the devalued share price. vm.startPrank(user1); sNusd.deposit(nusd.balanceOf(user1), user1); vm.stopPrank(); // Warp to end of the new vesting period. vm.warp(sNusd.lastDistributionTimestamp() + 2 * vestingPeriod); // user1, despite merely withdrawing and re-depositing, and with no new rewards added, // now has significantly more assets than user2, because the previously added rewards have // been redistributed in their favor. assertEq(sNusd.convertToAssets(sNusd.balanceOf(user1)), 1799999999999999998); assertEq(sNusd.convertToAssets(sNusd.balanceOf(user2)), 1200000000000000001); }
Recommendation
Set
vestingAmount
to zero whenever the vesting period is updated (or just when it is lengthened, as this issue does not apply to shortening the vesting period).If coolDownDuration is lowered, users may be able to reduce their cooldown end times
State
- Fixed
PR #33
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Kurt Barry
Description
Both
sNUSD.cooldownAssets()
andsNUSD.cooldownShares()
update the user's cooldown end time as follows:cooldowns[msg.sender].cooldownEnd = uint104(block.timestamp) + cooldownDuration;
This overwrites the previous value. If the
cooldownDuration
is lowered soon enough after a user initiates a cooldown, then the user may be able to lower theircooldownEnd
value by initiating another cooldown (potentially depositing more assets if need be).Recommendation
Set the
cooldownEnd
value to the maximum of the current value anduint104(block.timestamp) + cooldownDuration
in bothcooldownAssets()
andcooldownShares()
.Yield accrual in YieldDistributor will fail if NUSD mint limit is exceeded
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Chinmay Farkya
Description
convertYieldtoNUSD()
function inYieldDistributor.sol
is used to accrue allyieldToken
balances. It first calculates the total amount in terms of NUSD using collateral prices fromROUTER.quoteDeposit()
and then calls the ROUTER to mint the calculated NUSD amounts for each yieldToken.The call flow is
convertYieldtoNUSD() => ROUTER.mint() => StableMinter.mint()
StableMinter.sol
inherits fromBaseMintRedeem.sol
which does some checks on this mint order parameters. One such check is_belowMaxMintPerBlock()
.function _belowMaxMintPerBlock(uint256 mintAmount) internal view { if (mintedPerBlock[block.number] + mintAmount > maxMintPerBlock) { revert MaxMintPerBlockExceeded(); } }
This check ensures that the already minted NUSD in the actual block plus the amount to be minted is below the
maxMintPerBlock
value. This mint limit is shared by all users that are trying to mint NUSD from the minter contract of that particular collateral asset.The
convertYieldtoNUSD()
loops over all yieldTokens, so the whole yield distribution can fail if mint limit of any one collateral asset's minter has been exceeded.This can cause yield distribution failure, and delay in accruing yield to sNUSD in a timely manner. This can also be purposefully DOS'ed by an attacker.
This will be problematic for integrators that depend on timely yield accrual as any withdrawals from sNUSD will still succeed even though the yield that was expected at the time was purposefully delayed by an attacker => leading to potential loss of yield even though they contributed with their NUSD properly.
Recommendation
Consider documenting this potential threat for integrators and monitoring mint usage over time to dynamically adjust mint limits such that yield operations do not fail.
Neutrl
Acknowledged.
We acknowledge this potential scenario where yield distribution could be disrupted by attackers exhausting mint limits for specific collateral assets.
If this situation occurs, we have several mitigation strategies available:
- Whitelist Management
- Since we operate with whitelisting controls, we can remove malicious actors from the whitelist to prevent further abuse
- Dynamic Threshold Adjustment
- We can increase the maxMintPerBlock threshold for affected collateral assets to ensure yield distribution operations can proceed
- Operational Monitoring
- We will monitor mint usage patterns to proactively identify and address potential DoS attempts before they impact yield distribution
These mitigation measures provide us with the operational flexibility to maintain timely yield accrual while protecting against malicious interference.
Spearbit
Acknowledged.
Informational17 findings
Missing event emission for privileged functions reduces offchain transparency
Description
While many of the privileged functions across the protocol emit events to enable offchain monitoring and increased transparency, some are missing such event emits. This reduces offchain transparency, which is a key aspect of Neutrl protocol to strengthen user confidence.
Recommendation
Consider emitting missed events in all privileged functions.
Unused code constructs reduce readability
Description
There are some code constructs such as declared errors and events which are unused and reduce readability.
Recommendation
Consider revisiting their intended usage or remove them for clarity.
Centralized roles and responsibilities across the protocol are susceptible to misuse
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
0xRajeev
Description
The protocol has several centralized roles and responsibilities across components, which are susceptible to misuse/abuse if compromised, such as:
- NUSD
MINTER_ROLE
can mint arbitrary NUSD amounts to arbitrary addresses. - NUSD
REDEEMER_ROLE
can burn arbitrary NUSD amounts from arbitrary addresses. - NUSD
DENYLIST_MANAGER_ROLE
can prevent arbitrary NUSD transfers. - Router
WHITELISTER_ROLE
can prevent arbitrary mint/redeem requests. - Router
KEEPER_ROLE
can serve/cancel arbitary mint/redeem requests. - Router admin can configure arbitrary minters/redeemers.
- Router admin can set arbitrary order waiting periods affecting order expiry.
- Minter/Redeemer admin can add/remove support for arbitrary assets.
- Minter/Redeemer admin can set arbitrary min/max price for supported collateral assets.
- Minter/Redeemer admin can set arbitrary max mints/redeems per block.
- Admins can set arbitrary asset reserve and router addresses.
PAUSER_ROLE
can pause/unpause various key functions.- YieldDistributor admin can add/remove support for arbitrary yield tokens.
- YieldDistributor admin can recover arbitrary ERC20 tokens.
- sNUSD
BLACKLIST_MANAGER_ROLE
can prevent arbitrary sNUSD transfers. - sNUSD admin can set arbitrary cooldown durations for unstaking/withdrawing.
- In PR#20, the
MANAGER_ROLE
can indefinitely extend a user's lock end time by locking a tiny amount of tokens just before each lock expiration while specifying the largest possible duration to update the end time far into the future.
Recommendation
Consider:
- Enforcing the best privilege separation possible across roles/actors.
- Implementing the highest degree of operational security for wallet accounts controlling them.
- Implementing timelocks for some of the most critical actions.
Neutrl
Acknowledged.
We acknowledge the centralized roles identified in the protocol and recognize the potential security risks associated with compromised administrative keys. To mitigate the risk of compromised admin keys, we will implement the following security measures:
Security Mitigation Strategy
-
Robust Private Key Management
-
Strict operational security (OpSec) practices for all admin wallets and role-based wallets
-
Industry-standard key storage and access protocols
-
Multi-Party Computation (MPC) Wallet Implementation
-
All administrative roles managed through MPC wallets with strict role-based policies
-
Multi-signature requirements for critical operations
-
Advanced Transaction Protection
-
Integration with third-party security solutions to detect and automatically reject malicious transactions on MPC wallets
-
Real-time monitoring and anomaly detection for administrative actions
These comprehensive security measures will effectively mitigate the risks associated with centralized roles while maintaining the operational capabilities necessary for protocol functionality.
Spearbit
Acknowledged.
Event parameters can be indexed for efficient lookups
Description
Events
LockedAmountRedistributed
,Unstaked
andRequestUnstaked
do not use indexed attribute on their address parameters for more efficient lookups.Recommendation
Consider adding
indexed
attribute to these event parameters.Symbol instead of name passed to ERC20Permit constructor in sNUSD
State
- Fixed
PR #25
Severity
- Severity: Informational
Submitted by
Kurt Barry
Description
The name, rather than the symbol, is intended to be passed to the
ERC20Permit
constructor:However, the
sNUSD
contract passes the symbol. This is in contrast to theNUSD
contract, which correctly uses the token name.Recommendation
Pass the name (
"Staked NUSD"
) rather than the symbol to theERC20Permit
constructor insNUSD
.Missing zero-address checks on critical protocol addresses
Description
While the protocol implements zero-address checks in several places, this is missing in some of the initializations and setter functions.
Recommendation
Consider adding zero-address checks on all critical protocol addresses to reduce accidental errors.
Miscellaneous issues reduce readability
Description
There are miscellaneous minor issues related to naming, NatSpec, function visibility, missing checks etc. across the codebase that reduce readability.
Recommendation
Consider:
- Adding a non-zero check for
amountToDistribute
inredistributeLockedAmount()
. - Making
5e18
a declared constant or a settable storage value to allow modifying the acceptable slippage inconvertYieldToNUSD()
. - Renaming
startTime
aslockStartTime
inAssetLock.UserLock
. - Renaming
_asset
as_collateralAsset
in various Router functions. - Making
Router.quoteRedemption()
external
visibility instead ofpublic
. - Making
SingleAdminAccessControl.owner()
external
visibility instead ofpublic
as in IERC5313. - Changing
@notice
of_setMintWhitelisted()
to/// @notice Internal function to set a user's mint whitelist status
. - Changing
@notice
of_setMintWhitelistEnforcement()
to/// @notice Internal function to set the mint whitelist enforcement
. - Adding
@param
for_nusd
inRouter.constructor
. - Adding
@param
for_multisig
inElpDistribution.constructor
. - Using named mapping parameters for Router
minters
andredeemers
.
- Adding a non-zero check for
Use _msgSender() instead of msg.sender in SingleAdminAccessControl
State
- Fixed
PR #36
Severity
- Severity: Informational
Submitted by
Kurt Barry
Description and Recommendation
Consider using
_msgSender()
instead ofmsg.sender
inSingleAdminAccessControl
for consistency with the OZ base class implementation:While currently the two options are equivalent, if there's ever a contract that inherits
SingleAdminAccessControl
and overrides_msgSender()
, then bugs could result from usingmsg.sender
inSingleAdminAccessControl
instead of_msgSender()
.Unused imports
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Kurt Barry
Description
The following imports are unused:
- StableMinter.sol#L4-L5: the
IERC20
andSafeERC20
imports are only used in theusing
statement on line 15; they can be removed along with theusing
statement. - Redeemer.sol#L5: the
SafeERC20
import is only used in theusing
statement on line 14; it can be removed along with theusing
statement.
Recommendation
Remove unused imports and related lines for clarity and readability.
Consider additional validation checks
State
- Fixed
PR #39
Severity
- Severity: Informational
Submitted by
Kurt Barry
Description and Recommendation
- In
Router.configureAsset()
, consider validating that_asset
is not NUSD as NUSD should never be its own collateral. - In
Router._setMinter()
andRouter._setRedeemer()
, consider verifying that the minter or redeemer supports the specified asset. - In
BaseMintRedeem._addAsset()
, consider validating that_asset
is not NUSD as NUSD should never be its own collateral.
Misleading benefactor value in Redeem event when a queued redemption request is served
State
- Fixed
PR #37
Severity
- Severity: Informational
Submitted by
Kurt Barry
Description
When a queued redemption request is served, the
order.benefactor
value is changed to be theRouter
since that's where the NUSD tokens were transferred when the request was created. This modification is necessary for the internal_redeem()
function to work correctly, but it obscures the original benefactor in the emittedRedeem
event--it will now be theRouter
instead of the original NUSD source. This makes this event parameter less informative for an asynchronously executed redemption.Recommendation
Consider modifying the code to always emit the
Redeem
event using the original benefactor.Yield should be accrued before removing a yieldToken from YieldDistributor
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Chinmay Farkya
Description
The
YieldDistributor
contract has methods to accrue and distribute yield according to yield token balances accumulated in the contract. TheYIELD_TOKEN_MANAGER_ROLE
is supposed to periodically callconvertYieldToNUSD()
to convert all token balances into NUSD.There is also a way to remove a registered yield token from the distribution mechanisms, via the
removeYieldToken()
function which is controlled byDEFAULT_ADMIN_ROLE
.The problem here is that if the yield token is removed from the
yieldTokens
array without accruing its existing balance into NUSD yield, then the existing balance will be left out of the distribution mechanism of the sNUSD contract.The
yieldToken
can also not be added again because of another issue, and can only be recovered. SinceYIELD_TOKEN_MANAGER_ROLE
andDEFAULT_ADMIN_ROLE
are assumed to be separate privileged addresses, this can break the yield distribution process.Recommendation
Protocol has to make sure that these two calls :
convertYieldToNUSD()
andremoveYieldToken()
are always made in the specified order by privileged roles.Incorrect comments
State
- Fixed
PR #38
Severity
- Severity: Informational
Submitted by
Chinmay Farkya
Description
There are several instances of incorrect/ incomplete comments across the codebase :
- sNUSD.sol#L177 : "This function is called by the
StakingRewardsDistributo
r contract" should be changed to "This function is called by theYieldDistributor
contract". - YieldDistributor.sol#L17: "A contract that manages and distributes yield from accepted stablecoins (USDC & USDT) to sNUSD holders" should be changed to "A contract that manages and distributes yield from accepted stablecoins (USDC,USDT & USDe) to sNUSD holders".
- BaseMintRedeem.sol#L117-L118: these two comment lines should use the term
price
instead ofslippage
; while the terms are logically related, "slippage" usually denotes a relative price difference rather than an absolute price (which is what is actually accepted as the argument to this function and stored in the contract).
Recommendation
Consider modifying the mentioned comments.
AUTHORIZED role is not set up in AssetReserve constructor
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Chinmay Farkya
Description
Across the codebase, all roles are set up in the constructor, which ensures proper role allotment at deployment.
In one instance in
AssetReserve
contract, theAUTHORIZED
role has not been granted by default. This role is required to move collateral assets from AssetReserve contract to custodian addresses.This can lead to no address having the
AUTHORIZED
role initially, affecting protocol strategy as user funds sit inAssetReserve
contract.Recommendation
Consider granting
AUTHORIZED
role at deployment via constructor, for consistency.Neutrl
Acknowledged.
Most of the roles have been removed according to Finding #1. They are defined at deployment, for the authorized role, we are aware of this we need first some MPC wallets being created.
Spearbit
Acknowledged.
Pending request listing functions include expired requests in the returned arrays
State
- Fixed
PR #26
Severity
- Severity: Informational
Submitted by
Kurt Barry
Description
In the
Router
contract the functionsgetPendingMintRequests()
andgetPendingRedeemRequests()
both include pending but expired requests in the arrays they return. However, expired requests cannot be served, only cancelled. Depending on the use cases for these functions, including expired requests may be undesirable and lead to confusion.Recommendation
Consider whether including expired requests is desirable. For maximum flexibility, a boolean parameter could be added to these functions specifying whether to include expired requests or not.
Subcontract-specific constructs do not belong in base contracts
State
- Fixed
PR #42
Severity
- Severity: Informational
Submitted by
Kurt Barry
Description
The
BaseMintRedeem
contract defines a number of fields and functions that are specific to whether the derived contract is a minter or redeemer; in either case, the fields for the other type of derived contract are entirely unused in the derived contract. Specifically, the fieldsminPrice
,maxPrice
,mintedPerBlock
,redeemedPerBlock
,maxMintPerBlock
, andmaxRedeemedPerBlock
, and the associated functions that set or check these values, are all minter- or redeemer-specific.This is undesirable for several reasons:
- Gas costs. Derived contracts possess all of these fields and functions. This increases bytecode size (hence deployment costs), and can increase function resolution costs at runtime.
- Possibility for mistakes. It creates the possibility of accidentally calling the wrong function and having a transaction succeed when the intended goal was not accomplished. Concretely, imagine a governance action that is supposed to set
minPrice
on the minter andmaxPrice
on the redeemer, but accidentally swaps which contract each field is set on. The action will succeed with no obvious indication that a mistake was made, whereas it would fail instantly if minters and redeemers didn't share functions unnecessarily. - Maintainability. It violates expectations of best practices, making the code less readable and maintainable.
Recommendation
Since the desire appears to be capable of supporting multiple minters and redeemers in the future, refactor the inheritance structure, with an extra layer of base contracts that are specific to minters and redeemers:
. BaseMinter -- all minter contracts /BaseMintRedeem \ BaseRedeemer -- all redeemer contracts
Move the minter- and redeemer-specific fields and functions to the appropriate intermediate base contract.
Inconsistent use of errors in BaseMintRedeem and derived contracts
State
- Fixed
PR #40
Severity
- Severity: Informational
Submitted by
Kurt Barry
Description
The
BaseMintRedeem
class defines a custom errorZeroInput()
but usesInvalidInput()
for cases where the input is zero; only in the derived classRedeemer
isZeroInput()
is used, for the exact same sort of error thatBaseMintRedeem
is usingInvalidInput()
for.Recommendation
Consider making the error usage more regular and consistent with the rest of the codebase. In particular, use
ZeroInput()
when the input is zero, andInvalidInput()
for other problems with function arguments.
Gas Optimizations6 findings
mintRequestId and redemptionRequestId can be cached
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
0xRajeev
Description
_requestMint()
and_requestRedemption()
loadmintRequestId
andredemptionRequestId
from storage three times consecutively. Given these are frequently used user flows, these repeated storage reads unnecessarily leads to more gas usage than required.mintRequests[mintRequestId] = _order;mintRequestStatus[mintRequestId] = RequestStatus.PENDING;emit RequestMint(mintRequestId, _order);
redemptionRequests[redemptionRequestId] = _order;redemptionRequestStatus[redemptionRequestId] = RequestStatus.PENDING;emit RequestRedemption(redemptionRequestId, _order);
Recommendation
Consider caching these storage variables to avoid repeated storage reads.
getPendingMintRequests() and getPendingRedemptionRequests() may hit OOG exception
Description
getPendingMintRequests()
andgetPendingRedemptionRequests()
loop through every mint and redemption requests made in the protocol lifetime to check which of them are pending to return their details.While this may work initially, this iterative approach is likely to hit Out-of-Gas (OOG) exception at some point in the future when the number of requests exceed a certain number. Even if this is only used by the protocol for offchain monitoring and accounting, the logic is suboptimal for gas usage.
Recommendation
Consider:
- Adding a starting index parameter to these functions so that successive calls can optionally ignore the previously determined pending requests.
- Caching
mintRequestId
andredemptionRequestId
instead of loading them repeatedly from storage.
Neutrl
Spearbit
Reviewed that PR#26 mitigates the issue as recommended in (1) by adding another set of functions that take starting index as a parameter. All functions now also return only pending mint/redemption requests that expire beyond
block.timestamp
. This PR however introduced an issue ingetPendingRedemptionRequests()
functions wheremintRequests[i].expiry
was incorrectly used instead ofredemptionRequests[i].expiry
. This was later resolved in PR#44.Redundant order type checks in mint() and redeem()
Description
mint()
andredeem()
check their order types to beOrderType.MINT
andOrderType.REDEEM
respectively. However, they call_verifyOrder(order)
which also checks forif (order.orderType != OrderType.MINT && order.orderType != OrderType.REDEEM) revert InvalidInput()
.These checks are redundant and the consolidated check in
_verifyOrder()
can be removed in favor of the specific checks already performed at the two call sites.Recommendation
Consider:
- Removing
if (order.orderType != OrderType.MINT && order.orderType != OrderType.REDEEM) revert InvalidInput()
from_verifyOrder()
- Moving the duplicated check
if (order.expiry < block.timestamp) revert OrderExpired()
frommint()
andredeem()
to_verifyOrder()
.
Neutrl
Spearbit
Reviewed that PR #28 fixes the issue as recommended in (1).
- Removing
Redundant getters
State
- Fixed
PR #34
Severity
- Severity: Gas optimization
Submitted by
Kurt Barry
Description
The
getAssetInfo()
andgetUserLock()
getters in theAssetLock
contract are redundant with thepublic
fieldsassetInfo
anduserLocks
. Having both increases bytecode size (and thus deployment costs) and can also increase function dispatch costs on calls to the contract since the function lookup logic will be more extensive.Recommendation
Either remove the unnecessary getters or make the associated fields
private
.Unnecessary storage field
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Kurt Barry
Description
The
isSupported
field of theAssetInfo
struct used in theAssetLock
contract isn't strictly necessary--the same effect could be achieved by allowing amaxLockCapacity
of zero denote an unsupported asset. This would eliminate a full storage slot per locked position.Recommendation
Remove
AssetLock.isSupported
and rely onmaxLockCapacity
alone to reduce gas costs.Unnecessary transfer of sNUSD shares in ELPDistribution.proceedToELPDistribution()
State
- Fixed
PR #41
Severity
- Severity: Gas optimization
Submitted by
Kurt Barry
Description and Recommendation
In
ELPDistribution.proceedToELPDistribution()
, funds are deposited into thesNUSD
contract and then the resulting shares are transferred to a multisig address for storage:sNUSD.deposit(totalAllocated, address(this));sNUSD.transfer(multisig, totalAllocated);
The second parameter of
deposit()
is the address that receives the shares, so the code could be simplified to:sNUSD.deposit(totalAllocated, multisig);
eliminating an external call and thus saving gas.