code-423n4 / 2022-08-foundation-findings

0 stars 0 forks source link

Gas Optimizations #210

Open code423n4 opened 2 years ago

code423n4 commented 2 years ago

Gas Optimizations

Omit hash in contract salts

NFTCollectionFactory generates a unique salt per collection by concatenating the creator address and a nonce and returning the hash of the concatenated values.

NFTCollectionFactory._getSalt:

  function _getSalt(address creator, uint256 nonce) private pure returns (bytes32) {
    return keccak256(abi.encodePacked(creator, nonce));
  }

If you are willing to constrain the nonce value to a uint96, you can save a bit of gas by omitting the keccak hash and packing the creator address and nonce together in a bytes32. Since this is paid by creators on every new drop and collection, small savings here may add up.

Original implementation: 22974 gas.

Optimized: 22457 gas:

    function _getSalt(address creator, uint96 nonce) public pure returns (bytes32) {
        return bytes32(uint256((uint256(nonce) << 160) | uint160(creator)));
    }

Optimize BytesLibrary.startsWith

BytesLibrary.startsWith checks whether the given bytes callData begin with a provided bytes4 functionSig. It performs this check by iterating over each individual byte:

Original implementation (~2150 gas):

  function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool) {
    // A signature is 4 bytes long
    if (callData.length < 4) {
      return false;
    }
    unchecked {
      for (uint256 i = 0; i < 4; ++i) {
        if (callData[i] != functionSig[i]) {
          return false;
        }
      }
    }

    return true;
  }
}
Running 3 tests for test/foundry/BytesLibrary.t.sol:TestBytesLibrary
[PASS] testStartsWithFullString() (gas: 2199)
[PASS] testStartsWithPrefix() (gas: 2145)
[PASS] testStartsWithTooShort() (gas: 642)
Test result: ok. 3 passed; 0 failed; finished in 3.11ms

There are a few options to optimize this function, depending on whether you want to use inline assembly.

No assembly required: cast callData to bytes4

It’s possible to convert callData to a bytes4 (truncating the remainder), and compare directly with functionSig. This saves about 850 gas.

function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool) {
    // A signature is 4 bytes long
    if (callData.length < 4) {
      return false;
    }
    return bytes4(callData) == functionSig;
  }
Running 3 tests for test/foundry/BytesLibrary.t.sol:TestBytesLibrary
[PASS] testStartsWithFullString() (gas: 1359)
[PASS] testStartsWithPrefix() (gas: 1305)
[PASS] testStartsWithTooShort() (gas: 642)
Test result: ok. 3 passed; 0 failed; finished in 426.58µs

Some assembly required: cast callData to bytes4 in assembly

If you’re up for some low level assembly, you can perform the same truncation more cheaply by assigning callData to a bytes4 in assembly. This saves about 1300 gas.

  function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool) {
    // A signature is 4 bytes long
    if (callData.length < 4) {
      return false;
    }
    bytes4 sigBytes;
    assembly {
      sigBytes := mload(add(callData, 0x20))
    }
    return sigBytes == functionSig;
  }
}
Running 3 tests for test/foundry/BytesLibrary.t.sol:TestBytesLibrary
[PASS] testStartsWithFullString() (gas: 894)
[PASS] testStartsWithPrefix() (gas: 840)
[PASS] testStartsWithTooShort() (gas: 642)
Test result: ok. 3 passed; 0 failed; finished in 3.23ms

Assembly required: perform comparison in assembly

If you really want to show off, you can perform the whole comparison in assembly and save an additional 50-ish gas:

function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool sigMatches) {
    // A signature is 4 bytes long
    if (callData.length < 4) {
      return false;
    }
    assembly {
      sigMatches :=
        eq(
          and(                          // Take bitwise and of...
            shl(0xe0, 0xffffffff),      // Mask over first 4 bytes of bytes32
            mload(add(callData, 0x20))  // First word after callData length
          ),
          functionSig                   // Does it eq functionSig?
        )
    }
  }
Running 3 tests for test/foundry/BytesLibrary.t.sol:TestBytesLibrary
[PASS] testStartsWithFullString() (gas: 849)
[PASS] testStartsWithPrefix() (gas: 795)
[PASS] testStartsWithTooShort() (gas: 642)

(This last one is probably not worth it, but it was fun to work out).

HardlyDifficult commented 2 years ago

Love this report -- thanks for the feedback.

Omit hash in contract salts

This is an interesting suggestion! We'll test it out.

Optimize BytesLibrary.startsWith

Great suggestions, will consider these.

horsefacts commented 2 years ago

Thanks! FYI, Saw-mon & Natalie came up with a cleaner, cheaper assembly startsWith in their submission here. You can just mload the 4 signature bytes and don't need to use a mask.