Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gas Optimizations #47

Open
code423n4 opened this issue Feb 16, 2022 · 4 comments
Open

Gas Optimizations #47

code423n4 opened this issue Feb 16, 2022 · 4 comments
Labels
bug Something isn't working G (Gas Optimization)

Comments

@code423n4
Copy link
Contributor

Gas Report

Table of Contents:

Foreword

  • @audit tags

The code is annotated at multiple places with //@audit comments to pinpoint the issues. Please, pay attention to them for more details.

File: FollowNFT.sol

function getPowerByBlockNumber()

Unchecked block

116:         if (snapshotCount == 0) {
117:             return 0; // Returning zero since this means the user never delegated and has no power
118:         }
119: 
120:         uint256 lower = 0;
121:         uint256 upper = snapshotCount - 1; //@audit uncheck (see condition L116)

As it's impossible for line 121 to underflow (see condition line 116), it should be wrapped inside an unchecked block.

function getDelegatedSupplyByBlockNumber()

Unchecked block

158:         if (snapshotCount == 0) {
159:             return 0; // Returning zero since this means a delegation has never occurred
160:         }
161: 
162:         uint256 lower = 0;
163:         uint256 upper = snapshotCount - 1; //@audit uncheck (see condition line 158)

As it's impossible for line 163 to underflow (see condition line 158), it should be wrapped inside an unchecked block.

File: LensHub.sol

modifier onlyWhitelistedProfileCreator() {

A modifier used only once can get inline to save gas

The modifier onlyWhitelistedProfileCreator is used only once:

File: LensHub.sol
142:     function createProfile(DataTypes.CreateProfileData calldata vars)
143:         external
144:         override
145:         whenNotPaused
146:         onlyWhitelistedProfileCreator
147:     {

Therefore, it can get inlined in createProfile to save gas

function setState()

95:     function setState(DataTypes.ProtocolState newState) external override {
96:         if (msg.sender != _governance && msg.sender != _emergencyAdmin) //@audit gas: this is a sad path with always 2 SLOADs. There's a happy path.
97:             revert Errors.NotGovernanceOrEmergencyAdmin();
98:         _setState(newState);
99:     }

Short-circuiting can provide a happy path

The current implementation of function setState favors a sad path which will always cost 2 SLOADs to check the condition.
It's possible to short-circuit this condition to provide a happy path which may cost only 1 SLOAD instead.
I suggest rewriting the function to:

    function setState(DataTypes.ProtocolState newState) external override {
        if (msg.sender == _governance || msg.sender == _emergencyAdmin) { //@audit-info happy path. Use as 1st condition the most frequent one (here, we assume _governance calls this method the most often)
          _setState(newState);
        } else {
          revert Errors.NotGovernanceOrEmergencyAdmin();
        }
    }

Another alternative:

    function setState(DataTypes.ProtocolState newState) external override {
        if (msg.sender == _governance || msg.sender == _emergencyAdmin) {
          _setState(newState);
          return;
        }
        revert Errors.NotGovernanceOrEmergencyAdmin();
    }

function getProfileIdByHandle()

794:     function getProfileIdByHandle(string calldata handle) external view override returns (uint256) {
795:         bytes32 handleHash = keccak256(bytes(handle)); //@audit gas: var used only once, inline it
796:         return _profileIdByHandleHash[handleHash];
797:     }

A variable used only once shouldn't get cached

I suggest inlining handleHash L796.

function _createPost()

856:     function _createPost(
857:         uint256 profileId,
858:         string memory contentURI, //@audit gas: should be calldata (calls L333 and L374 are passing a calldata variable)
859:         address collectModule,
860:         bytes memory collectModuleData, //@audit gas: should be calldata (calls L335 and L376 are passing a calldata variable)
861:         address referenceModule,
862:         bytes memory referenceModuleData //@audit gas: should be calldata (calls L337 and L378 are passing a calldata variable)
863:     ) internal {

Use calldata instead of memory for string contentURI

Use calldata instead of memory for bytes collectModuleData

Use calldata instead of memory for bytes referenceModuleData

For the 3 memory variables declared in _createPost(), the parent functions are actually passing a calldata variable:

329:     function post(DataTypes.PostData calldata vars) external override whenPublishingEnabled { //@audit-info vars is using calldata
330:         _validateCallerIsProfileOwnerOrDispatcher(vars.profileId);
331:         _createPost(
332:             vars.profileId,
333:             vars.contentURI, //@audit-info calldata
334:             vars.collectModule,
335:             vars.collectModuleData, //@audit-info calldata
336:             vars.referenceModule,
337:             vars.referenceModuleData //@audit-info calldata
338:         );
339:     }
...
342:     function postWithSig(DataTypes.PostWithSigData calldata vars) //@audit-info vars is using calldata
343:         external
344:         override
345:         whenPublishingEnabled
346:     {
...
372:         _createPost(
373:             vars.profileId,
374:             vars.contentURI, //@audit-info calldata
375:             vars.collectModule,
376:             vars.collectModuleData, //@audit-info calldata
377:             vars.referenceModule,
378:             vars.referenceModuleData //@audit-info calldata
379:         );

Therefore, the function declaration should use calldata instead of memory for these 3 parameters.

This optimization is similar to the one for the internal function _createMirrorin LensNFTBase.sol which uses bytes calldata referenceModuleData:

File: LensHub.sol
890:     function _createMirror(
891:         uint256 profileId,
892:         uint256 profileIdPointed,
893:         uint256 pubIdPointed,
894:         address referenceModule,
895:         bytes calldata referenceModuleData //@audit-ok : calldata on internal because parent function passes a calldata variable
896:     ) internal {

function _setFollowNFTURI()

919:     function _setFollowNFTURI(uint256 profileId, string memory followNFTURI) internal { //@audit gas: should be calldata (calls L255 and L285 are passing a calldata variable)
920:         _profileById[profileId].followNFTURI = followNFTURI;
921:         emit Events.FollowNFTURISet(profileId, followNFTURI, block.timestamp);
922:     }

Use calldata instead of memory for string followNFTURI

The parent functions are actually passing a calldata variable:

289:     function setFollowNFTURI(uint256 profileId, string calldata followNFTURI) //@audit-info calldata
290:         external
291:         override
292:         whenNotPaused
293:     {
294:         _validateCallerIsProfileOwnerOrDispatcher(profileId);
295:         _setFollowNFTURI(profileId, followNFTURI); //@audit-info calldata
296:     }
...
299:     function setFollowNFTURIWithSig(DataTypes.SetFollowNFTURIWithSigData calldata vars) //@audit-info calldata
300:         external
301:         override
302:         whenNotPaused
303:     {
...
325:         _setFollowNFTURI(vars.profileId, vars.followNFTURI); //@audit-info calldata
326:     }

Therefore, the function declaration should use calldata instead of memory for string followNFTURI

This optimization is similar to the one for the internal function _createMirrorin LensNFTBase.sol which uses bytes calldata referenceModuleData.

function _validateCallerIsProfileOwnerOrDispatcher()

940:     function _validateCallerIsProfileOwnerOrDispatcher(uint256 profileId) internal view {
941:         if (msg.sender != ownerOf(profileId) && msg.sender != _dispatcherByProfile[profileId]) //@audit gas: this is a sad path with 2 SLOADs. A happy path is possible
942:             revert Errors.NotProfileOwnerOrDispatcher();
943:     }

Short-circuiting can provide a happy path

The current implementation of function _validateCallerIsProfileOwnerOrDispatcher favors a sad path which will always cost 2 SLOADs to check the condition.
It's possible to short-circuit this condition to provide a happy path which may cost only 1 SLOAD instead.
I suggest rewriting the function to:

    function _validateCallerIsProfileOwnerOrDispatcher(uint256 profileId) internal view {
        if (msg.sender == ownerOf(profileId) || msg.sender == _dispatcherByProfile[profileId]) {
          return;
        }
        revert Errors.NotProfileOwnerOrDispatcher();
    }

File: LensNFTBase.sol

function _validateRecoveredAddress()

159:     function _validateRecoveredAddress(
160:         bytes32 digest,
161:         address expectedAddress,
162:         DataTypes.EIP712Signature memory sig //@audit gas: should be calldata (calls L78, L111 and L152 are passing a calldata variable)
163:     ) internal view {

Use calldata instead of memory

The calls to the internal function _validateRecoveredAddress pass a calldata variable:

51:     function permit(
52:         address spender,
53:         uint256 tokenId,
54:         DataTypes.EIP712Signature calldata sig //@audit-info calldata
55:     ) external override {
...
78:         _validateRecoveredAddress(digest, owner, sig); //@audit-info calldata
...
83:     function permitForAll(
84:         address owner,
85:         address operator,
86:         bool approved,
87:         DataTypes.EIP712Signature calldata sig //@audit-info calldata
88:     ) external override {
...
111:         _validateRecoveredAddress(digest, owner, sig); //@audit-info calldata
...
127:     function burnWithSig(uint256 tokenId, DataTypes.EIP712Signature calldata sig) //@audit-info calldata
...
152:         _validateRecoveredAddress(digest, owner, sig); //@audit-info calldata

Therefore, the function declaration should use calldata instead of memory

This optimization is similar to the one for the internal function _createMirrorin LensNFTBase.sol which uses bytes calldata referenceModuleData.

File: PublishingLogic.sol

function _initFollowModule()

342:     function _initFollowModule(
343:         uint256 profileId,
344:         address followModule,
345:         bytes memory followModuleData, //@audit gas: should be calldata (calls L64 and L95 are passing a calldata variable)
346:         mapping(address => bool) storage _followModuleWhitelisted
347:     ) private returns (bytes memory) {

Use calldata instead of memory

The calls to the private function _initFollowModule pass a calldata variable:

39:     function createProfile(
40:         DataTypes.CreateProfileData calldata vars, //@audit-info calldata
...
61:         bytes memory followModuleReturnData = _initFollowModule(
62:             profileId,
63:             vars.followModule,
64:             vars.followModuleData, //@audit-info calldata
65:             _followModuleWhitelisted
66:         );
...
80:     function setFollowModule(
81:         uint256 profileId,
82:         address followModule,
83:         bytes calldata followModuleData, //@audit-info calldata
...
92:         bytes memory followModuleReturnData = _initFollowModule(
93:             profileId,
94:             followModule,
95:             followModuleData, //@audit-info calldata
96:             _followModuleWhitelisted
97:         );

Therefore, the function declaration should use calldata instead of memory

This optimization is similar to the one for the internal function _createMirrorin LensNFTBase.sol which uses bytes calldata referenceModuleData.

General recommendations

Variables

No need to explicitly initialize variables with default values

If a variable is not set/initialized, it is assumed to have the default value (0 for uint, false for bool, address(0) for address...). Explicitly initializing it with its default value is an anti-pattern and wastes gas.

As an example: for (uint256 i = 0; i < numIterations; ++i) { should be replaced with for (uint256 i; i < numIterations; ++i) {

Instances include:

core\modules\follow\ApprovalFollowModule.sol:41:        for (uint256 i = 0; i < addresses.length; ++i) {
core\modules\follow\ApprovalFollowModule.sol:66:            for (uint256 i = 0; i < addresses.length; ++i) {
core\modules\follow\ApprovalFollowModule.sol:128:        for (uint256 i = 0; i < toCheck.length; ++i) {
core\FollowNFT.sol:120:        uint256 lower = 0;
core\FollowNFT.sol:162:        uint256 lower = 0;
core\LensHub.sol:541:        for (uint256 i = 0; i < vars.datas.length; ++i) {
libraries\InteractionLogic.sol:47:        for (uint256 i = 0; i < profileIds.length; ++i) {
libraries\PublishingLogic.sol:403:        for (uint256 i = 0; i < byteHandle.length; ++i) {
upgradeability\VersionedInitializable.sol:29:    uint256 private lastInitializedRevision = 0;

I suggest removing explicit initializations for default values.

Pre-increments cost less gas compared to post-increments

As the solution employs pre-increments for all of its for-loops, I'm sure the sponsor is aware of the fact that pre-increments cost less gas compared to post-increments (about 5 gas)

However, some places outside loops were missed.

Instances include:

core\modules\collect\LimitedFeeCollectModule.sol:112:            _dataByPublicationByProfile[profileId][pubId].currentCollects++;
core\modules\collect\LimitedTimedFeeCollectModule.sol:123:            _dataByPublicationByProfile[profileId][pubId].currentCollects++;
core\LensHub.sol:887:        _profileById[vars.profileId].pubCount++;

I suggest only replacing these, as some other places in the solution are actually using post-increments the right way. The logic would break if those other places (not mentioned here) are changed too.

For-Loops

Increments can be unchecked

In Solidity 0.8+, there's a default overflow check on unsigned integers. It's possible to uncheck this in for-loops and save some gas at each iteration, but at the cost of some code readability, as this uncheck cannot be made inline.

ethereum/solidity#10695

Instances include:

core\modules\follow\ApprovalFollowModule.sol:41:        for (uint256 i = 0; i < addresses.length; ++i) {
core\modules\follow\ApprovalFollowModule.sol:66:            for (uint256 i = 0; i < addresses.length; ++i) {
core\modules\follow\ApprovalFollowModule.sol:128:        for (uint256 i = 0; i < toCheck.length; ++i) {
core\LensHub.sol:541:        for (uint256 i = 0; i < vars.datas.length; ++i) {
libraries\InteractionLogic.sol:47:        for (uint256 i = 0; i < profileIds.length; ++i) {
libraries\PublishingLogic.sol:403:        for (uint256 i = 0; i < byteHandle.length; ++i) {

The code would go from:

for (uint256 i; i < numIterations; ++i) {  
 // ...  
}  

to:

for (uint256 i; i < numIterations;) {  
 // ...  
 unchecked { ++i; }  
}  

The risk of overflow is inexistant for a uint256 here.

An array's length should be cached to save gas in for-loops

Reading array length at each iteration of the loop takes 6 gas (3 for mload and 3 to place memory_offset) in the stack.

Caching the array length in the stack saves around 3 gas per iteration.

Here, I suggest storing the array's length in a variable before the for-loop, and use it instead:

core\modules\follow\ApprovalFollowModule.sol:41:        for (uint256 i = 0; i < addresses.length; ++i) {
core\modules\follow\ApprovalFollowModule.sol:66:            for (uint256 i = 0; i < addresses.length; ++i) {
core\modules\follow\ApprovalFollowModule.sol:128:        for (uint256 i = 0; i < toCheck.length; ++i) {
core\LensHub.sol:541:        for (uint256 i = 0; i < vars.datas.length; ++i) {
libraries\InteractionLogic.sol:47:        for (uint256 i = 0; i < profileIds.length; ++i) {
libraries\PublishingLogic.sol:403:        for (uint256 i = 0; i < byteHandle.length; ++i) {

Arithmetics

Shift Right instead of Dividing by 2

A division by 2 can be calculated by shifting one to the right.

While the DIV opcode uses 5 gas, the SHR opcode only uses 3 gas. Furthermore, Solidity's division operation also includes a division-by-0 prevention which is bypassed using shifting.

I suggest replacing / 2 with >> 1 here:

core\modules\ModuleGlobals.sol:109:        if (newTreasuryFee >= BPS_MAX / 2) revert Errors.InitParamsInvalid();
core\FollowNFT.sol:134:            uint256 center = upper - (upper - lower) / 2;
core\FollowNFT.sol:176:            uint256 center = upper - (upper - lower) / 2;

Errors

Use Custom Errors instead of Revert Strings to save Gas

I'm quite certain that the sponsor is aware of this optimization, as the library Errors contains a lot of custom errors. Therefore, I won't explain it (suffice to say, they are cheaper than require statements + revert strings).

The finding here is that the contracts ERC721Enumerable and ERC721Time don't benefit from this practice:

core\base\ERC721Enumerable.sol:
  53:         require(index < ERC721Time.balanceOf(owner), 'ERC721Enumerable: owner index out of bounds');
  68:         require(
  69              index < ERC721Enumerable.totalSupply(),
  70              'ERC721Enumerable: global index out of bounds'
  71          );

core\base\ERC721Time.sol:
   77:         require(owner != address(0), 'ERC721: balance query for the zero address');
   86:         require(owner != address(0), 'ERC721: owner query for nonexistent token');
   95:         require(mintTimestamp != 0, 'ERC721: mint timestamp query for nonexistent token');
  109:         require(_exists(tokenId), 'ERC721: token data query for nonexistent token');
  131:         require(_exists(tokenId), 'ERC721Metadata: URI query for nonexistent token');
  152:         require(to != owner, 'ERC721: approval to current owner');
  154:         require(
  155              _msgSender() == owner || isApprovedForAll(owner, _msgSender()),
  156              'ERC721: approve caller is not owner nor approved for all'
  157          );
  166:         require(_exists(tokenId), 'ERC721: approved query for nonexistent token');
  175:         require(operator != _msgSender(), 'ERC721: approve to caller');
  202:         require(
  203              _isApprovedOrOwner(_msgSender(), tokenId),
  204              'ERC721: transfer caller is not owner nor approved'
  205          );
  230:         require(
  231              _isApprovedOrOwner(_msgSender(), tokenId),
  232              'ERC721: transfer caller is not owner nor approved'
  233          );
  262:         require(
  263              _checkOnERC721Received(from, to, tokenId, _data),
  264              'ERC721: transfer to non ERC721Receiver implementer'
  265          );
  293:         require(_exists(tokenId), 'ERC721: operator query for nonexistent token');
  324:         require(
  325              _checkOnERC721Received(address(0), to, tokenId, _data),
  326              'ERC721: transfer to non ERC721Receiver implementer'
  327          );
  343:         require(to != address(0), 'ERC721: mint to the zero address');
  344:         require(!_exists(tokenId), 'ERC721: token already minted');
  395:         require(ERC721Time.ownerOf(tokenId) == from, 'ERC721: transfer of token that is not own');
  396:         require(to != address(0), 'ERC721: transfer to the zero address');

I suggest using custom errors in ERC721Enumerable and ERC721Time.

@code423n4 code423n4 added bug Something isn't working G (Gas Optimization) labels Feb 16, 2022
code423n4 added a commit that referenced this issue Feb 16, 2022
@Zer0dot
Copy link
Collaborator

Zer0dot commented Mar 22, 2022

Okay this is possibly the best gas report I have ever seen. Huge props to Dravee!

@Zer0dot
Copy link
Collaborator

Zer0dot commented Mar 23, 2022

Implementing a whole lot of this in a new PR, here are some notes:

  1. Inlining the handle hash computation increased code size by ~3 bytes and increased gas by 4, so we're not implementing that.
  2. Calldata instead of memory is valid but the reason we're doing this is to avoid stack too deep errors, follow NFT URI thing is a good catch though, and valid for the profile image URI too!
  3. The = 0 initialization is there for clarity and I believe it's handled by the optimizer.

Will write more, I'm currently at the unchecked increment section, which is valid. Anyway, C4 give this gigachad a medal.

@Zer0dot
Copy link
Collaborator

Zer0dot commented Mar 24, 2022

Included unchecked increments, and cached array lengths (where possible) too!

Unchecked increments: lens-protocol/core@37ab8ce
Array length caching: lens-protocol/core@a698476

The SHR sacrifices readability too much, though it's still valid. Lastly the custom errors I would say are not valid since that's just how ERC721 is built, we don't want to stray away from the standard, even in revert messages as much as possible.

Overall this is a fantastic report!

@Zer0dot
Copy link
Collaborator

Zer0dot commented Mar 31, 2022

PSA: The happy path short-circuiting is only partly valid in that it saves a minor amount of gas (2-3 opcodes) since even the "sad path" is evaluated lazily, if the first condition evaluates to false, the second condition is not evaluated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working G (Gas Optimization)
Projects
None yet
Development

No branches or pull requests

2 participants