Try   HackMD

Relearning Solidity

Motivation

I've been doing protocol engineering for nearly 4 years now with a handful of projects, some of which have had millions of dollars in TVL, but I am still kinda mid.

This is my how I go from a mid solidity engineer to a kinda good one.

Smart Contract Design Patterns

Github Repositories

https://github.com/sablier-labs/v2-core
Seaport
Openzeppelin
Rate Limiter

  • events need to match types
    Masterchef or synthetix staking
    uniswap v2
    some airdrop claiming contract

Useful solidity patterns/trivia

https://github.com/dragonfly-xyz/useful-solidity-patterns

  • You can slice calldata using calldata[4:] but you cannot for memory arrays. You need to manually copy the bytes over using assembly

Many times, people use assembly to look cool or to optimize gas. Here are some legitimate use cases for assembly:

  • Handling Reverts - When an external call reverts while calling it from a try/catch, call, delegatecall, or staticcall, the revert message will just be some bytes. If you want to bubble this revert up and revert the transaction with the bytes that came from the external call's revert, you can use the following pattern
try otherContract.foo() {
    // handle successful call...
} catch (bytes memory revertBytes) {
    // call failed, do some error processing
    // if all else fails, bubble up the revert
    assembly { revert(add(revertBytes, 0x20), mload(revertBytes)) }
}
  • Saving Gas when Hashing together 32 byte values - The non-assembly method would be to do keccak256(abi.encode(bytes32 a, uint256 b)). However, abi.encode allocates memory to store the result of abi.encode(bytes32 a, uint256 b). The optimal solution would be to use bytes of scratch space, as follows
bytes32 hash;
assembly {
    mstore(0x00, word1)
    mstore(0x20, word2)
    hash := keccak256(0x00, 0x40)
}
  • Casting between different structs/arrays - If you have structs/arrays with the same underlying type(ie address[] vs interface[]), you can use assembly to cast between them. You can also do the same thing with structs, as long as the elements in the structs are the exact same.
address[] memory addressArr = ...;
IERC20Token[] memory erc20Arr; // No need to allocate new memory.
assembly { erc20Arr := addressArr }
  • Proxy Patterns
    • Consists of a Logic Contract and a Proxy Contract
    • The proxy contract has two functions, the default fallback function and a delegate call function. When you want to make a call, the calldata gets passed to the delegate call function via the fallback function(which catches everything)
    • To make a proxy upgradable, you can allow the proxy contract to change the address of the logic contract. However, there are several pitfalls to this pattern.
      • If you change up the order of how variables are declared, the compiler will put them into different storage slots and you will have variables overwriting stuff. Even worse, you could overwrite your logic contract address storage slot. This is why we have stuff like ERC-1967, which declares which storage slot your logic contract address is supposed to be in.
      • Also, you can't use a constructor, so you use an initializer. But, if someone manages to reinitialize your logic contract, you will get hacked
  • SSTORE2 - If you want to store data that is larger than 32 bytes, you can store the data in a contract's bytecode to save gas on reads and writes. The premise is to store a bytes array or string into a contract, and read from it using address(contract).code
    • Of course, you want to check to make sure the contract doesn't actually have the ability to execute, so make sure to prefix the bytecode with any opcode except selfdestruct
contract StoreString {
    constructor(string memory s) {
        // Store the string in the contract's code storage.
        // Prefix with STOP opcode to prevent execution.
        bytes memory p = abi.encodePacked(hex"00", s);
        assembly {
            return(
                add(p, 0x20), // start of return data
                mload(p) // size of return data
            )
        }
    }
}
  • Bitmaps - If you want to store a nonce for each order on an orderbook, you can use a bitmap to save gas. By packing nonces within a 256 bit word, you can ensure that users only write to a new slot once every 255 times.
contract TransferRelay {
    mapping (address => mapping (uint248 => uint256)) public signerNonceBitmap;

    function executeTransferMessage(
        Message calldata mess,
        uint8 v,
        bytes32 r,
        bytes32 s
    )
        external
    {
        require(mess.from != address(0), 'bad from');
        require(mess.validAfter < block.timestamp, 'not ready');
        require(!_getSignerNonceState(mess.from, mess.nonce), 'already consumed');
        {
            bytes32 messHash = keccak256(abi.encode(block.chainid, address(this), mess));
            require(ecrecover(messHash, v, r, s) == mess.from, 'bad signature');
        }
        // Mark the message consumed.
        _setSignerNonce(mess.from, mess.nonce);
        // Perform the transfer.
        mess.token.transferFrom(address(mess.from), mess.to, mess.amount);
    }

    function _getSignerNonceState(address signer, uint256 nonce) private view returns (bool) {
        uint256 bitmap = signerNonceBitmap[signer][uint248(nonce >> 8)];
        return bitmap & (1 << (nonce & 0xFF)) != 0;
    }

    function _setSignerNonce(address signer, uint256 nonce) private {
        signerNonceBitmap[signer][uint248(nonce >> 8)] |= 1 << (nonce & 0xFF);
    }
}
  • ERC20 Compatibility - Certain ERC20 tokens such as USDT and BNB do not conform to the ERC20 specification of returning a boolean indicating success or failure. When trying to decode the returned boolean, the transaction will revert as there is no data there. However, we can use SafeERC20 to solve this problem, as it first checks if the call has enough data to decode before decoding it
(bool success, bytes memory returnOrRevertData) =
    address(token).call(abi.encodeCall(IERC20.transfer, (to, amount)));
// Did the call revert?
require(success, 'transfer failed');
// The call did not revert. If we got enough return data to encode a bool, decode it.
if (returnOrRevertData.length >= 32) {
    // Ensure that the returned bool is true.
    require(abi.decode(returnOrRevertData, (bool)), 'transfer failed');
}

Additionally, there are ERC20 tokens that only allow approvals if the user currently has an approval balance of zero. This is to bypass the frontrunning approval attack.

Solidity v0.8

Changes introduced between Solidity v0.8.0 and v0.8.23:

  • Exponentiation is right associative. a**b**c is now parsed as a**(b**c).
  • Custom Errors
  • New Opcodes for 1559 ie block.basefee, block.maxPriorityFee, block.maxFeePerGas
  • User Defined Value Types(UDVT) ie type Decimals18 is uint256;
    • The previous solution was struct Decimals18{ uint256 decimals} which had extra gas overhead
  • Use abi.encodeCall over abi.encodeWithSelector when encoding calldata as it checks that the function's types match the supplied arguments
  • User Defined Value Types now have user defined operators. This allows you to use custom functions as operators for your UDVTs
    ​​​​using {add} for Int global;
    
    ​​​​function add(Int a, Int b) pure returns (Int) {
    ​​​​    return Int.wrap(Int.unwrap(a) + Int.unwrap(b));
    ​​​​}
    
    ​​​​function test(Int a, Int b) pure returns (Int) {
    ​​​​    return a.add(b);
    ​​​​}
    

Foundry Notes

Read 1-29 of https://book.getfoundry.sh/

For Defi, understand the following examples. These are good design patterns
https://solidity-by-example.org/defi/staking-rewards/
https://solidity-by-example.org/defi/constant-sum-amm/
https://solidity-by-example.org/defi/vault/

Articles

https://cmichel.io/how-to-become-a-smart-contract-auditor/
https://solodit.xyz/

Code arena reports

https://code4rena.com/reports/2023-01-opensea

TODO

Environment Diagram practice for solidity