Platform: Code4rena
Start Date: 18/11/2021
Pot Size: $50,000 USDC
Total HM: 18
Participants: 26
Period: 7 days
Judge: leastwood
Total Solo HM: 12
Id: 54
League: ETH
Rank: 1/26
Findings: 8
Award: $12,005.46
π Selected for report: 16
π Solo Findings: 4
π Selected for report: WatchPug
3984.587 USDC - $3,984.59
WatchPug
The current design/implementation of freeTrial allows users to get full refund before the freeTrial ends. Plus, a user can transfer partial of thier time to another user using shareKey
.
This makes it possible for the attacker to steal from the protocol by transferring freeTrial time from multiple addresses to one address and adding up to expirationDuration
and call refund to steal from the protocol.
Given:
keyPrice
is 1 ETH;expirationDuration
is 360 days;freeTrialLength
is 31 days.The attacker can create two wallet addresses: Alice and Bob.
purchase()
, transfer 30 days via shareKey()
to Bob, then calls cancelAndRefund()
to get full refund; Repeat 12 times;cancelAndRefund()
and get 1 ETH.Consider disabling cancelAndRefund()
for users who transferred time to another user.
#0 - julien51
2021-11-26T03:13:18Z
I think this is valid! The free trial approach is indeed a risk on that front and we need to "warn" lock managers about this more.
#1 - julien51
2021-11-26T03:14:13Z
For lock manager who still want to offer free trials, the best approach would probably be to set a high transfer fee to make sure that free trials cannot be transfered.
#2 - julien51
2021-11-26T03:14:48Z
As a consequence of this I am not sure this is as critical as indicated by the submitter.
#3 - 0xleastwood
2022-01-16T08:16:26Z
Nice find!
From what I can tell at least, this does seem like a viable attack vector. Can I ask why this should not be treated as high
risk? @julien51
#4 - julien51
2022-03-16T06:36:04Z
Sorry for the long delay here. In short: this is valid, but only an issue for locks which are enabling free trials (no one has done it) and we would make sure our UI shows this as a potential issue. In other words: a lock manager would need to explicitly enable free trials, despite our warning to put their own funds at risk. For that reason I don't think this is "High".
#5 - 0xleastwood
2022-03-22T21:36:19Z
While this is a valid issue pertaining only to lock managers who explicitly enable free trials, this may still lead to a loss of funds if cancelAndRefund
is called by a user who has transferred their time to another account. I still believe this deserves a high
severity rating.
#6 - 0xleastwood
2022-03-22T21:37:03Z
In my honest opinion, a warning isn't sufficient to prevent such abuse. I think on-chain enforcement ideal in this situation.
π Selected for report: WatchPug
3984.587 USDC - $3,984.59
WatchPug
if (toKey.tokenId == 0) { toKey.tokenId = _tokenId; _recordOwner(_recipient, _tokenId); // Clear any previous approvals _clearApproval(_tokenId); } if (previousExpiration <= block.timestamp) { // The recipient did not have a key, or had a key but it expired. The new expiration is the sender's key expiration // An expired key is no longer a valid key, so the new tokenID is the sender's tokenID toKey.expirationTimestamp = fromKey.expirationTimestamp; toKey.tokenId = _tokenId; // Reset the key Manager to the key owner _setKeyManagerOf(_tokenId, address(0)); _recordOwner(_recipient, _tokenId); } else { // The recipient has a non expired key. We just add them the corresponding remaining time // SafeSub is not required since the if confirms `previousExpiration - block.timestamp` cannot underflow toKey.expirationTimestamp = fromKey.expirationTimestamp + previousExpiration - block.timestamp; }
Based on the context, L131-136 seems to be the logic of handling the case of the recipient with no key, and L138-148 is handing the case of the recipient's key expired.
However, in L131-136, the key manager is not being reset.
This allows attackers to keep the role of key manager after the transfer, and transfer the key back or to another recipient.
Given:
setKeyManagerOf()
, making herself the keyManager;transferFrom()
, transferring the key to Bob; Bob might have paid a certain amount of money to Alice upon receive of the key;transferFrom()
again, transferring the key back from Bob.Consider resetting the key manager regardless of the status of the recipient's key.
#0 - julien51
2021-12-11T15:16:18Z
I think you are onto something here. We will need to investigate further and reproduce to fix!
#1 - 0xleastwood
2022-01-16T08:14:20Z
@julien51 Just following up if you were able to double-check this?
#2 - julien51
2022-03-16T06:36:53Z
This is indeed valid and I think we will need to "patch" this. We're still unsure how but we're exploring multiple ways.
π Selected for report: WatchPug
WatchPug
In the current implementation, Unlock.sol#recordKeyPurchase()
will send estimatedGasForPurchase * tx.gasprice
worth of UDT to the referrer.
uint tokensToDistribute = (estimatedGasForPurchase * tx.gasprice) * (125 * 10 ** 18) / 100 / udtPrice;
We believe there are multiple potential economic attack vectors to exploit this.
If estimatedGasForPurchase
is misconfigured to a higher amount than the actual avg gas cost for a purchase call, or future network upgrades make the actual gas cost become lower than the configured estimatedGasForPurchase
, it can be exploited simply by creating a lock and call purchase()
many times to mint UDT.
Even if estimatedGasForPurchase
is configured to an amount similar to the actual gas cost, a more sophisticated attack is still possible:
Given:
estimatedGasForPurchase
is configured as 200,000
;200,000
.The attacker can create a lock contract and set the token address to a special gas saving token, which will SELFDESTRUCT to get a gas refund on transfer
.
The attacker can:
1 gwei
;purchase()
and use 48 contract slots with gas price: 1000 gwei
;Total gas saved will be ~0.8 ETH (or other native tokens, eg. BNB, MATIC). Therefore, the attacker will profit ~0.8 ETH worth of UDT.
See: https://gastoken.io/
Consider setting a global daily upper limit of total UDT grants to referrers, plus, an upper limit for UDT minted per purchase.
#0 - julien51
2021-12-11T15:01:46Z
If estimatedGasForPurchase is misconfigured to a higher amount than the actual avg gas cost for a purchase call, or future network upgrades make the actual gas cost become lower than the configured estimatedGasForPurchase, it can be exploited simply by creating a lock and call purchase() many times to mint UDT.
Absolutely but considering the security model, the admin indeed have full control over the protocol. We are thinking about finding a mechanism to not hardcode gas spent but use the actual number eventually. When we do that we should consider the impact of things like gas-token (even though EIP1559 has probably made them mostly impractical?).
At this point given that the gas spent is hardcoded, there is a de-facto cap on how much UDT they could earn (based on the token price).
#1 - 0xleastwood
2022-01-16T04:56:49Z
While I agree with the warden, there is potential for value extraction, however, it does require the admin to be unaware about upcoming network upgrades.
As the sponsor has noted, they will be moving towards a dynamic estimatedGasForPurchase
value, however, from the perspective of the c4 contest, this doesn't change the outcome of my decision.
#2 - 0xleastwood
2022-01-16T04:57:44Z
As the protocol may leak value based on certain network assumptions, I'll mark this as medium
severity.
#3 - 0xleastwood
2022-01-16T04:57:59Z
2 β Med (M): vulns have a risk of 2 and are considered βMediumβ severity when assets are not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
#4 - julien51
2022-03-16T06:42:57Z
I don't think the protocl can "leak" value based on that.
the tokens that are used to compute GDP and distribute tokens have to be approved by the DAO (right now only USDC, DAI and BAT have been approved on mainnet, and only USDC on Polygon). I don't think the DAO would approve gas tokens givem that indeed they could result in leakage of UDT, so I think it is minor
.
#5 - 0xleastwood
2022-03-22T21:44:30Z
Considering the sponsor's comments, I actually agree that this is less likely than initially stated. Similar to the SafeERC20
issue, it isn't expected that gas saving tokens will be approved to compute and distribute UDT
tokens. I'll downgrade this to low
.
71.4681 USDC - $71.47
WatchPug
It is usually good to add a require-statement that checks the return value or to use something like safeTransferFrom; unless one is sure the given token reverts in case of a failure.
// We explicitly allow for greater amounts of ETH or tokens to allow 'donations' uint pricePaid; if(tokenAddress != address(0)) { pricePaid = _value; IERC20Upgradeable token = IERC20Upgradeable(tokenAddress); token.transferFrom(msg.sender, address(this), pricePaid); } // ...
Consider adding a require-statement or using safeTransferFrom
.
#0 - julien51
2021-12-11T15:19:07Z
We have already explained the if a lock manager creates a lock with a malicious ERC20 they should expect to indeed see unexpected behaviors.
#1 - 0xleastwood
2022-01-16T12:05:35Z
Duplicate of #162
#2 - 0xleastwood
2022-01-16T12:06:17Z
Duplicate of #221
WatchPug
function _getCancelAndRefundValue( address _keyOwner ) private view hasValidKey(_keyOwner) returns (uint refund) { Key storage key = keyByOwner[_keyOwner]; // Math: safeSub is not required since `hasValidKey` confirms timeRemaining is positive uint timeRemaining = key.expirationTimestamp - block.timestamp; if(timeRemaining + freeTrialLength >= expirationDuration) { refund = keyPrice; } else { refund = keyPrice * timeRemaining / expirationDuration; } // Apply the penalty if this is not a free trial if(freeTrialLength == 0 || timeRemaining + freeTrialLength < expirationDuration) { uint penalty = keyPrice * refundPenaltyBasisPoints / BASIS_POINTS_DEN; if (refund > penalty) { refund -= penalty; } else { refund = 0; } } }
The current implementation of _getCancelAndRefundValue()
is using the latest value of freeTrialLength
, refundPenaltyBasisPoints
and expirationDuration
for calculation.
Therefore, after the user purchased a key, if the lock owner changed the settings of freeTrialLength
, refundPenaltyBasisPoints
and expirationDuration
for worse, eg, a shorter freeTrialLength
, a larger refundPenaltyBasisPoints
or a shorter expirationDuration
, user will get worse terms with _getCancelAndRefundValue()
, which is unexpected.
Consider taking a snapshot of the settings on purchase()
and use the snapshot values for _getCancelAndRefundValue()
.
#0 - julien51
2021-12-11T14:57:56Z
When we shipped this feature, we decided against keeping track of "past" purchases to decide on the cancellation terms because we wanted to avoid wasting gas for "marginal" issues. In practice, the lock manager has full control over the lock contract and could easily just prevent cancellation altogether if they were malicious anyway by withdrawing all of the funds on it.
#1 - 0xleastwood
2022-01-16T09:48:09Z
Duplicate of #53
π Selected for report: WatchPug
WatchPug
The current design/implementation allows users who are refunded before to get another freeTrial. This can be exploited by malicious users to get an infinite free trial.
Given:
keyPrice
is 1 ETH;freeTrialLength
is 31 days.A malicious user can:
purchase()
, pay 1 ETH and get 31 days of freeTrial
on day 1;cancelAndRefund()
on day 30 and get 1 ETH of refund; then call purchase()
again, pay 1 ETH and get 31 days of freeTrial
again.Repeat the steps above and the user can get infinite freeTrial.
A malicious third party may provide a service named freeUnlock
, which will call cancelAndRefund()
and purchase()
automatically right before the end of the freeTrial. This can cause fund loss to all the owners that provide a freeTrial.
Consider adding a mapping(address => uint256) freeTrialEnds
and make sure each address can only get 1 freeTrial.
#0 - julien51
2021-11-26T03:10:12Z
Isn't that the case with every free trial system? If they use the same address the lock manager could easily use the hook system to keep track of who already had received a full refund and not grant it on the 2nd cancellation. The user could still use new addresses all the time, and in that case that would be valid, but that is actually the case with a lot of systems like that :) One of my roommates in colleges was just subscribing to newspaper and getting the full risk-free refund by using a different name every time (but used the same address)
#1 - 0xleastwood
2022-01-16T08:19:22Z
While I agree with the warden, there is potential for unlimited free trials. Limiting a free trial to a single address does not resolve the issue as an attacker can generate any number of addresses from a single seed. However, I do understand this is a tricky issue to workaround.
#2 - 0xleastwood
2022-01-16T08:20:39Z
So I'm not sure how this should be treated as it does affect how the protocol is intended to operate. Is there any reason for users to not abuse this @julien51 ? Typically with newspapers, you have to provide credit card details, so an individual is really limited by the number of cards they hold.
#3 - julien51
2022-01-17T02:10:59Z
As you noted, there is no way to prevent free trials from being abused which is why by default, locks do not have a free trial: they have to be manually explicitly configured. From there, since it's trivial to just create an infinite number of accounts, anyone could just claim free trials over and over from new accounts.
#4 - 0xleastwood
2022-01-17T03:02:56Z
As per sponsor, trials are not enabled by default. But seeing as this impacts protocol availability through abuse if enabled. I'll mark this as medium
.
179.3064 USDC - $179.31
WatchPug
The initializer function that initializes important contract state can be called by anyone.
The attacker can initialize the contract before the legitimate deployer, hoping that the victim continues to use the same contract.
In the best case for the victim, they notice it and have to redeploy their contract costing gas.
Use the constructor to initialize non-proxied contracts.
For initializing proxy contracts deploy contracts using a factory contract that immediately calls initialize after deployment or make sure to call it immediately after deployment and verify the transaction succeeded.
#0 - julien51
2021-12-11T14:55:52Z
The function addLockTemplate
can only be called by an admin. We would expect the admin to expliclty deploy the template and call initialize
on it before the call addLockTemplate
.
#1 - 0xleastwood
2022-01-16T09:29:49Z
Duplicate of #153
π Selected for report: WatchPug
398.4587 USDC - $398.46
WatchPug
It isnβt safe to simply add a state variable when upgrading contracts because it "shifts down" all of the state variables below in the inheritance chain. This makes the storage layouts incompatible.
Therefore, Mixin***
contracts should include an empty reserved space in storage, usually named __gap
.
See: https://docs.openzeppelin.com/contracts/3.x/upgradeable#storage_gaps
#0 - julien51
2021-12-11T15:20:18Z
Yes! This _gap
pattern was identified/popularized by OZ long after we had shipped to mainnet :) We cannot go back in time and update these anymore but we should absolutely consider these for the next upgrades.
π Selected for report: WatchPug
Also found by: GiveMeTestEther, mics, pauliax
WatchPug
For the arithmetic operations that will never over/underflow, using the unchecked directive (Solidity v0.8 has default overflow/underflow checks) can save some gas from the unnecessary internal over/underflow checks.
For example:
function _purchasePriceFor( address _recipient, address _referrer, bytes memory _data ) internal view returns (uint minKeyPrice, uint unlockDiscount, uint unlockTokens) { if(address(onKeyPurchaseHook) != address(0)) { minKeyPrice = onKeyPurchaseHook.keyPurchasePrice(msg.sender, _recipient, _referrer, _data); } else { minKeyPrice = keyPrice; } if(minKeyPrice > 0) { (unlockDiscount, unlockTokens) = unlockProtocol.computeAvailableDiscountFor(_recipient, minKeyPrice); require(unlockDiscount <= minKeyPrice, 'INVALID_DISCOUNT_FROM_UNLOCK'); minKeyPrice -= unlockDiscount; } }
minKeyPrice -= unlockDiscount
will never underflow.
function _getCancelAndRefundValue( address _keyOwner ) private view hasValidKey(_keyOwner) returns (uint refund) { Key storage key = keyByOwner[_keyOwner]; // Math: safeSub is not required since `hasValidKey` confirms timeRemaining is positive uint timeRemaining = key.expirationTimestamp - block.timestamp; if(timeRemaining + freeTrialLength >= expirationDuration) { refund = keyPrice; } else { refund = keyPrice * timeRemaining / expirationDuration; } // Apply the penalty if this is not a free trial if(freeTrialLength == 0 || timeRemaining + freeTrialLength < expirationDuration) { uint penalty = keyPrice * refundPenaltyBasisPoints / BASIS_POINTS_DEN; if (refund > penalty) { refund -= penalty; } else { refund = 0; } } }
key.expirationTimestamp - block.timestamp
and refund -= penalty
will never underflow.
π Selected for report: GiveMeTestEther
Also found by: WatchPug
WatchPug
function createLock( bytes memory data ) public returns(address) { require(proxyAdminAddress != address(0), "proxyAdmin is not set"); require(publicLockAddress != address(0), 'MISSING_LOCK_TEMPLATE'); // deploy a proxy pointing to impl TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(publicLockAddress, proxyAdminAddress, data); address payable newLock = payable(address(proxy)); // assign the new Lock locks[newLock] = LockBalances({ deployed: true, totalSales: 0, yieldedDiscountTokens: 0 }); // trigger event emit NewLock(msg.sender, newLock); return newLock; }
The payable
keyword on newLock
is redundant, removing it can reduce contract size and save gas at deployment.
#0 - 0xleastwood
2022-01-17T08:46:40Z
Duplicate of #226
WatchPug
Every reason string takes at least 32 bytes.
Use short reason strings that fits in 32 bytes or it will become more expensive.
Instances include:
require(_isLockManager(lockAddress, msg.sender) == true, "caller is not a manager of this lock");
require( version == currentVersion + 1, 'version error: only +1 increments are allowed');
require( hasRole(LOCK_MANAGER_ROLE, msg.sender), 'MixinRoles: caller does not have the LockManager role');
require(isKeyGranter(msg.sender) || isLockManager(msg.sender), 'MixinRoles: caller does not have the KeyGranter or LockManager role');
require(_EIP712NameHash() == bytes32(0), "Already initialized: EIP712_init, ERC20Permit_init, ERC20Votes_init");
#0 - 0xleastwood
2022-01-17T08:52:55Z
Duplicate of #36
π Selected for report: WatchPug
WatchPug
function uint2Str( uint _i ) internal pure returns (string memory _uintAsString) { // make a copy of the param to avoid security/no-assign-params error uint c = _i; if (_i == 0) { return '0'; } uint j = _i; uint len; while (j != 0) { len++; j /= 10; } bytes memory bstr = new bytes(len); uint k = len; while (c != 0) { k = k-1; uint8 temp = (48 + uint8(c - c / 10 * 10)); bytes1 b1 = bytes1(temp); bstr[k] = b1; c /= 10; } return string(bstr); }
Change to:
function uint2Str( uint _i ) internal pure returns (string memory _uintAsString) { // make a copy of the param to avoid security/no-assign-params error uint c = _i; if (_i == 0) { return '0'; } uint j = _i; uint len; while (j != 0) { len++; j /= 10; } bytes memory bstr = new bytes(len); uint k = len; while (c != 0) { bstr[--k] = bytes1(uint8(48 + c % 10)); c /= 10; } return string(bstr); }
#0 - 0xleastwood
2022-01-17T08:42:04Z
I think this makes sense, but I'll get @julien51 to verify
#1 - julien51
2022-01-17T15:17:48Z
At a high level I think it makes sense but I did not run tests myself to see how much gas it would save.
π Selected for report: WatchPug
WatchPug
Unused named returns increase contract size and gas usage at deployment.
function _isLockManager(address lockAddress, address _sender) private view returns(bool isManager) { IPublicLock lock = IPublicLock(lockAddress); return lock.isLockManager(_sender); }
isManager
is unused.
Change to:
function _isLockManager(address lockAddress, address _sender) private view returns(bool) { IPublicLock lock = IPublicLock(lockAddress); return lock.isLockManager(_sender); }
π Selected for report: WatchPug
WatchPug
/** * A function which lets the owner of the lock change the pricing for future purchases. * This consists of 2 parts: The token address and the price in the given token. * In order to set the token to ETH, use 0 for the token Address. */ function updateKeyPricing( uint _keyPrice, address _tokenAddress ) external onlyLockManager onlyIfAlive { uint oldKeyPrice = keyPrice; address oldTokenAddress = tokenAddress; require( _tokenAddress == address(0) || IERC20Upgradeable(_tokenAddress).totalSupply() > 0, 'INVALID_TOKEN' ); keyPrice = _keyPrice; tokenAddress = _tokenAddress; emit PricingChanged(oldKeyPrice, keyPrice, oldTokenAddress, tokenAddress); }
Check of _tokenAddress
at L180 can be done earlier to avoid unnecessary code execution at L177-178 and save some gas for failure transactions.
π Selected for report: WatchPug
WatchPug
uint pricePaid; if(tokenAddress != address(0)) { pricePaid = _value; IERC20Upgradeable token = IERC20Upgradeable(tokenAddress); token.transferFrom(msg.sender, address(this), pricePaid); }
token
is unnecessary as it's being used only once. Can be changed to:
uint pricePaid; if(tokenAddress != address(0)) { pricePaid = _value; IERC20Upgradeable(tokenAddress).transferFrom(msg.sender, address(this), pricePaid); }
function _isLockManager(address lockAddress, address _sender) private view returns(bool isManager) { IPublicLock lock = IPublicLock(lockAddress); return lock.isLockManager(_sender); }
lock
is unnecessary as it's being used only once. Can be changed to:
function _isLockManager(address lockAddress, address _sender) private view returns(bool isManager) { return IPublicLock(lockAddress).isLockManager(_sender); }
π Selected for report: WatchPug
WatchPug
By the time MixinRoles._initializeMixinRoles()
is called, there are no roles configured yet, therefore the check of !isLockManager(sender)
and !isKeyGranter(sender)
is redundant.
function _initializeMixinRoles(address sender) internal { // for admin mamangers to add other lock admins _setRoleAdmin(LOCK_MANAGER_ROLE, LOCK_MANAGER_ROLE); // for lock managers to add/remove key granters _setRoleAdmin(KEY_GRANTER_ROLE, LOCK_MANAGER_ROLE); if (!isLockManager(sender)) { _setupRole(LOCK_MANAGER_ROLE, sender); } if (!isKeyGranter(sender)) { _setupRole(KEY_GRANTER_ROLE, sender); } }
Change to:
function _initializeMixinRoles(address sender) internal { // for admin mamangers to add other lock admins _setRoleAdmin(LOCK_MANAGER_ROLE, LOCK_MANAGER_ROLE); // for lock managers to add/remove key granters _setRoleAdmin(KEY_GRANTER_ROLE, LOCK_MANAGER_ROLE); _setupRole(LOCK_MANAGER_ROLE, sender); _setupRole(KEY_GRANTER_ROLE, sender); }
#0 - 0xleastwood
2022-01-17T08:48:08Z
This seems right
WatchPug
For public functions, the input parameters are copied to memory automatically which costs gas. If a function is only called externally, making its visibility as external will save gas because external functionsβ parameters are not copied into memory and are instead read from calldata directly.
For example:
π Selected for report: WatchPug
WatchPug
For the storage variables that will be accessed multiple times, cache and read from the stack can save ~100 gas from each extra read (SLOAD
after Berlin).
In Unlock.sol#_deployProxyAdmin()
, _proxyAdmin
can be cached to avoid unnecessary storage reads.
function _deployProxyAdmin() private returns(address) { proxyAdmin = new ProxyAdmin(); proxyAdminAddress = address(proxyAdmin); return address(proxyAdmin); }
Change to:
function _deployProxyAdmin() private returns(address) { ProxyAdmin _proxyAdmin = new ProxyAdmin(); proxyAdmin = _proxyAdmin; proxyAdminAddress = address(_proxyAdmin); return address(_proxyAdmin); }
π Selected for report: WatchPug
WatchPug
function upgradeLock(address payable lockAddress, uint16 version) public returns(address) { require(proxyAdminAddress != address(0), "proxyAdmin is not set"); // check perms require(_isLockManager(lockAddress, msg.sender) == true, "caller is not a manager of this lock"); // check version IPublicLock lock = IPublicLock(lockAddress); uint16 currentVersion = lock.publicLockVersion(); require( version == currentVersion + 1, 'version error: only +1 increments are allowed'); // make our upgrade address impl = _publicLockImpls[version]; TransparentUpgradeableProxy proxy = TransparentUpgradeableProxy(lockAddress); proxyAdmin.upgrade(proxy, impl); emit LockUpgraded(lockAddress, version); return lockAddress; } function _isLockManager(address lockAddress, address _sender) private view returns(bool isManager) { IPublicLock lock = IPublicLock(lockAddress); return lock.isLockManager(_sender); }
_isLockManager()
is unnecessary as it's being used only once. Can be changed to:
function upgradeLock(address payable lockAddress, uint16 version) public returns(address) { require(proxyAdminAddress != address(0), "proxyAdmin is not set"); // check perms require(IPublicLock(lockAddress).isLockManager(msg.sender), "caller is not a manager of this lock"); // check version IPublicLock lock = IPublicLock(lockAddress); uint16 currentVersion = lock.publicLockVersion(); require( version == currentVersion + 1, 'version error: only +1 increments are allowed'); // make our upgrade address impl = _publicLockImpls[version]; TransparentUpgradeableProxy proxy = TransparentUpgradeableProxy(lockAddress); proxyAdmin.upgrade(proxy, impl); emit LockUpgraded(lockAddress, version); return lockAddress; }
π Selected for report: WatchPug
WatchPug
For the storage variables that will be accessed multiple times, cache and read from the stack can save ~100 gas from each extra read (SLOAD
after Berlin).
In MixinRefunds.sol#_getCancelAndRefundValue
: freeTrialLength
, expirationDuration
and keyPrice
are accessed multiple times.
function _getCancelAndRefundValue( address _keyOwner ) private view hasValidKey(_keyOwner) returns (uint refund) { Key storage key = keyByOwner[_keyOwner]; // Math: safeSub is not required since `hasValidKey` confirms timeRemaining is positive uint timeRemaining = key.expirationTimestamp - block.timestamp; if(timeRemaining + freeTrialLength >= expirationDuration) { refund = keyPrice; } else { refund = keyPrice * timeRemaining / expirationDuration; } // Apply the penalty if this is not a free trial if(freeTrialLength == 0 || timeRemaining + freeTrialLength < expirationDuration) { uint penalty = keyPrice * refundPenaltyBasisPoints / BASIS_POINTS_DEN; if (refund > penalty) { refund -= penalty; } else { refund = 0; } } }
Change to:
function _getCancelAndRefundValue( address _keyOwner ) private view hasValidKey(_keyOwner) returns (uint refund) { Key storage key = keyByOwner[_keyOwner]; // Math: safeSub is not required since `hasValidKey` confirms timeRemaining is positive uint timeRemaining = key.expirationTimestamp - block.timestamp; uint _expirationDuration = expirationDuration; uint _freeTrialLength = freeTrialLength; uint _keyPrice = keyPrice; if(timeRemaining + _freeTrialLength >= _expirationDuration) { refund = _keyPrice; } else { refund = _keyPrice * timeRemaining / _expirationDuration; } // Apply the penalty if this is not a free trial if(_freeTrialLength == 0 || timeRemaining + _freeTrialLength < _expirationDuration) { uint penalty = _keyPrice * refundPenaltyBasisPoints / BASIS_POINTS_DEN; if (refund > penalty) { refund -= penalty; } else { refund = 0; } } }
π Selected for report: WatchPug
Also found by: GiveMeTestEther
WatchPug
uint timeRemaining = key.expirationTimestamp - block.timestamp; if(timeRemaining + freeTrialLength >= expirationDuration) { refund = keyPrice; } else { refund = keyPrice * timeRemaining / expirationDuration; }
Given:
keyPrice
is 1 ETH;expirationDuration
is 1 year.purchase()
10 times, paid 1 ETH and got 1 extra year each time;cancelAndRefund()
, expected to receive a refund of 10 ETH but only receive 1 ETH instead.Users may suffer fund loss when they purchase multiple times and refund.
Consider recording the total amount paid by the user, and refund accordingly.
#0 - julien51
2021-11-26T03:16:21Z
I understand this but I actually think it is intentional that someone who purchases a membership multiple times only gets a refund on the "latest" purchase they made and not the previous ones.
#1 - 0xleastwood
2022-01-16T08:24:09Z
I think I agree with the sponsor here. There is no expectation that a user will purchase multiple memberships from the same account. But is there any reason not to refund users? I think this could arguably have a medium
severity @julien51 ?
#2 - julien51
2022-01-17T02:07:12Z
So there is a refund, just for the latest one they buy, which is why I would put this as Low at best. (Also technically, our front-end will not let them purchase one if they already have a valid one... even though obviously anyone could create their own front-end)
#3 - 0xleastwood
2022-01-17T03:00:43Z
As per sponsor, I'll mark this as low
. There doesn't seem to be any reason as to why someone would purchase another membership if they already have a valid one.