Notes

  • State Variables: Constant & Immutable

    1. State variables can be declared as constant or immutable. In both cases, the variables cannot be modified after the contract has been constructed. For constant variables, the value has to be fixed at compile-time, while for immutable, it can still be assigned at construction time i.e. in the constructor or point of declaration.

    2. For constant variables, the value has to be a constant at compile time and it has to be assigned where the variable is declared. Any expression that accesses storage, blockchain data (e.g. block.timestamp, address(this).balance or block.number) or execution data (msg.value or gasleft()) or makes calls to external contracts is disallowed.

    3. Immutable variables can be assigned an arbitrary value in the constructor of the contract or at the point of their declaration. They cannot be read during construction time and can only be assigned once.

    4. The compiler does not reserve a storage slot for these variables, and every occurrence is replaced by the respective value.

  • Compared to regular state variables, the gas costs of constant and immutable variables are much lower:

    1. For a constant variable, the expression assigned to it is copied to all the places where it is accessed and also re-evaluated each time. This allows for local optimizations.

    2. Immutable variables are evaluated once at construction time and their value is copied to all the places in the code where they are accessed. For these values, 32 bytes are reserved, even if they would fit in fewer bytes. Due to this, constant values can sometimes be cheaper than immutable values.

    3. The only supported types are strings (only for constants) and value types.

  • Free Functions: Functions that are defined outside of contracts are called “free functions” and always have implicit internal visibility. Their code is included in all contracts that call them, similar to internal library functions.

  • Events: They are an abstraction on top of the EVM’s logging functionality. Emitting events cause the arguments to be stored in the transaction’s log - a special data structure in the blockchain. These logs are associated with the address of the contract, are incorporated into the blockchain, and stay there as long as a block is accessible. The Log and its event data is not accessible from within contracts (not even from the contract that created them). Applications can subscribe and listen to these events through the RPC interface of an Ethereum client.

  • Indexed Event Parameters: Adding the attribute indexed for up to three parameters adds them to a special data structure known as “topics” instead of the data part of the log. If you use arrays (including string and bytes) as indexed arguments, its Keccak-256 hash is stored as a topic instead, this is because a topic can only hold a single word (32 bytes). All parameters without the indexed attribute are ABI-encoded into the data part of the log. Topics allow you to search for events, for example when filtering a sequence of blocks for certain events. You can also filter events by the address of the contract that emitted the event.

  • Constructor: Contracts can be created “from outside” via Ethereum transactions or from within Solidity contracts. When a contract is created, its constructor (a function declared with the constructor keyword) is executed once. A constructor is optional and only one constructor is allowed. After the constructor has executed, the final code of the contract is stored on the blockchain. This code includes all public and external functions and all functions that are reachable from there through function calls. The deployed code does not include the constructor code or internal functions only called from the constructor.

  • Solidity has two categories of types: Value Types and Reference Types. Value Types are called so because variables of these types will always be passed by value, i.e. they are always copied when they are used as function arguments or in assignments. In contrast, Reference Types can be modified through multiple different names i.e. references to the same underlying variable.

  • Value Types: Types that are passed by value, i.e. they are always copied when they are used as function arguments or in assignments — Booleans, Integers, Fixed Point Numbers, Address, Contract, Fixed-size Byte Arrays (bytes1, bytes2, …, bytes32), Literals (Address, Rational, Integer, String, Unicode, Hexadecimal), Enums, Functions.

  • Reference Types: Types that can be modified through multiple different names. Arrays (including Dynamically-sized bytes array bytes and string), Structs, Mappings.

  • Scoping: Scoping in Solidity follows the widespread scoping rules of C99

    1. Variables are visible from the point right after their declaration until the end of the smallest { }-block that contains the declaration. As an exception to this rule, variables declared in the initialization part of a for-loop are only visible until the end of the for-loop.

    2. Variables that are parameter-like (function parameters, modifier parameters, catch parameters, …) are visible inside the code block that follows - the body of the function/modifier for a function and modifier parameter and the catch block for a catch parameter.

    3. Variables and other items declared outside of a code block, for example functions, contracts, user-defined types, etc., are visible even before they were declared. This means you can use state variables before they are declared and call functions recursively.

  • Boolean: bool Keyword and the possible values are constants true and false.

    1. Operators are ! (logical negation) && (logical conjunction, “and”) || (logical disjunction, “or”)== (equality) and != (inequality).

    2. The operators || and && apply the common short-circuiting rules. This means that in the expression f(x) || g(y), if f(x) evaluates to true, g(y) will not be evaluated even if it may have side-effects.

  • Integers: int / uint: Signed and unsigned integers of various sizes. Keywords uint8 to uint256 in steps of 8 (unsigned of 8 up to 256 bits) and int8 to int256. uint and int are aliases for uint256 and int256, respectively. Operators are:

    1. Comparisons: <=, <, ==, !=, >=, > (evaluate to bool)

    2. Bit operators: &, |, ^ (bitwise exclusive or), ~ (bitwise negation)

    3. Shift operators: << (left shift), >> (right shift)

    4. *Arithmetic operators: +, -, unary - (only for signed integers), , /, % (modulo), ** (exponentiation)

  • Reference Types & Data Location: Every reference type has an additional annotation — the data location where it is stored. There are three data locations: memory, storage and calldata.

    1. memory: whose lifetime is limited to an external function call

    2. storage: whose lifetime is limited to the lifetime of a contract and the location where the state variables are stored

    3. calldata: which is a non-modifiable, non-persistent area where function arguments are stored and behaves mostly like memory. It is required for parameters of external functions but can also be used for other variables.

  • Data Location & Assignment: Data locations are not only relevant for persistence of data, but also for the semantics of assignments.

    1. Assignments between storage and memory (or from calldata) always create an independent copy.

    2. Assignments from memory to memory only create references. This means that changes to one memory variable are also visible in all other memory variables that refer to the same data.

    3. Assignments from storage to a local storage variable also only assign a reference.

    4. All other assignments to storage always copy. Examples for this case are assignments to state variables or to members of local variables of storage struct type, even if the local variable itself is just a reference.

  • Variables of type bytes and string are special arrays

    1. bytes is similar to byte[], but it is packed tightly in calldata and memory

    2. string is equal to bytes but does not allow length or index access

    3. Solidity does not have string manipulation functions, but there are third-party string libraries

    4. Use bytes for arbitrary-length raw byte data and string for arbitrary-length string (UTF-8) data

    5. Use bytes over byte[] because it is cheaper, since byte[] adds 31 padding bytes between the elements

    6. If you can limit the length to a certain number of bytes, always use one of the value types bytes1 to bytes32 because they are much cheaper

  • Memory Arrays: Memory arrays with dynamic length can be created using the new operator

    1. As opposed to storage arrays, it is not possible to resize memory arrays i.e. the .push member functions are not available

    2. You either have to calculate the required size in advance or create a new memory array and copy every element

  • Mapping Types: Mappings define key-value pairs and are declared using the syntax mapping(_KeyType => _ValueType) _VariableName.

    1. The _KeyType can be any built-in value type, bytes, string, or any contract or enum type. Other user-defined or complex types, such as mappings, structs or array types are not allowed. _ValueType can be any type, including mappings, arrays and structs.

    2. Key data is not stored in a mapping, only its keccak256 hash is used to look up the value

    3. They do not have a length or a concept of a key or value being set

    4. They can only have a data location of storage and thus are allowed for state variables, as storage reference types in functions, or as parameters for library functions

    5. They cannot be used as parameters or return parameters of contract functions that are publicly visible. These restrictions are also true for arrays and structs that contain mappings.

    6. You cannot iterate over mappings, i.e. you cannot enumerate their keys. It is possible, though, to implement a data structure on top of them and iterate over that.

  • Operators Involving LValues (i.e. a variable or something that can be assigned to)

    1. a += e is equivalent to a = a + e. The operators -=, =, /=, %=, |=, &= and ^= are defined accordingly

    2. a++ and a-- are equivalent to a += 1 / a -= 1 but the expression itself still has the previous value of a

    3. In contrast, --a and ++a have the same effect on a but return the value after the change

  • Explicit Conversions: If the compiler does not allow implicit conversion but you are confident a conversion will work, an explicit type conversion is sometimes possible. This may result in unexpected behaviour and allows you to bypass some security features of the compiler e.g. int to uint

    1. If an integer is explicitly converted to a smaller type, higher-order bits are cut off

    2. If an integer is explicitly converted to a larger type, it is padded on the left (i.e., at the higher order end)

    3. Fixed-size bytes types while explicitly converting to a smaller type and will cut off the bytes to the right

    4. Fixed-size bytes types while explicitly converting to a larger type and will pad bytes to the right.

  • Suffixes like seconds, minutes, hours, days and weeks after literal numbers can be used to specify units of time where seconds are the base unit where 1 == 1 seconds,1 minutes == 60 seconds, 1 hours == 60 minutes, 1 days == 24 hours and 1 weeks == 7 days

    1. Take care if you perform calendar calculations using these units, because not every year equals 365 days and not even every day has 24 hours because of leap seconds

    2. These suffixes cannot be applied directly to variables but can be applied by multiplication

  • Block and Transaction Properties:

    1. blockhash(uint blockNumber) returns (bytes32): hash of the given block - only works for 256 most recent, excluding current, blocks

    2. block.chainid (uint): current chain id

    3. block.coinbase (address payable): current block miner’s address

    4. block.difficulty (uint): current block difficulty

    5. block.gaslimit (uint): current block gaslimit

    6. block.number (uint): current block number

    7. block.timestamp (uint): current block timestamp as seconds since unix epoch

    8. msg.data (bytes calldata): complete calldata

    9. msg.sender (address): sender of the message (current call)

    10. msg.sig (bytes4): first four bytes of the calldata (i.e. function identifier)

    11. msg.value (uint): number of wei sent with the message

    12. tx.gasprice (uint): gas price of the transaction

    13. gasleft() returns (uint256): remaining gas

    14. tx.origin (address): sender of the transaction (full call chain)

  • ABI Encoding and Decoding Functions:

    1. abi.decode(bytes memory encodedData, (...)) returns (...): ABI-decodes the given data, while the types are given in parentheses as second argument.

    2. abi.encode(...) returns (bytes memory): ABI-encodes the given arguments

    3. abi.encodePacked(...) returns (bytes memory): Performs packed encoding of the given arguments. Note that packed encoding can be ambiguous!

    4. abi.encodeWithSelector(bytes4 selector, ...) returns (bytes memory): ABI-encodes the given arguments starting from the second and prepends the given four-byte selector

    5. abi.encodeWithSignature(string memory signature, ...) returns (bytes memory): Equivalent to abi.encodeWithSelector(bytes4(keccak256(bytes(signature))), …)

  • If you use ecrecover, be aware that a valid signature can be turned into a different valid signature without requiring knowledge of the corresponding private key. This is usually not a problem unless you require signatures to be unique or use them to identify items. OpenZeppelin has a ECDSA helper library that you can use as a wrapper for ecrecover without this issue.

  • selfdestruct has some peculiarities: the receiving contract’s receive function is not executed and the contract is only really destroyed at the end of the transaction and revert’s might “undo” the destruction.

  • Exceptions: Solidity uses state-reverting exceptions to handle errors. Such an exception undoes all changes made to the state in the current call (and all its sub-calls) and flags an error to the caller

    1. When exceptions happen in a sub-call, they “bubble up” (i.e., exceptions are rethrown) automatically. Exceptions to this rule are send and the low-level functions call, delegatecall and staticcall: they return false as their first return value in case of an exception instead of “bubbling up”.

    2. Exceptions in external calls can be caught with the try/catch statement

    3. Exceptions can contain data that is passed back to the caller. This data consists of a 4-byte selector and subsequent ABI-encoded data. The selector is computed in the same way as a function selector, i.e., the first four bytes of the keccak256-hash of a function signature - in this case an error signature.

    4. Solidity supports two error signatures: Error(string) and Panic(uint256). The first (“error”) is used for “regular” error conditions while the second (“panic”) is used for errors that should not be present in bug-free code.

  • The low-level functions call, delegatecall and staticcall return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed.

  • try/catch: The try keyword has to be followed by an expression representing an external function call or a contract creation (new ContractName()). Errors inside the expression are not caught (for example if it is a complex expression that also involves internal function calls), only a revert happening inside the external call itself. The returns part (which is optional) that follows declares return variables matching the types returned by the external call. In case there was no error, these variables are assigned and the contract’s execution continues inside the first success block. If the end of the success block is reached, execution continues after the catch blocks.

  • Solidity supports different kinds of catch blocks depending on the type of error:

    1. catch Error(string memory reason) { ... }: This catch clause is executed if the error was caused by revert("reasonString") or require(false, "reasonString") (or an internal error that causes such an exception).

    2. catch Panic(uint errorCode) { ... }: If the error was caused by a panic, i.e. by a failing assert, division by zero, invalid array access, arithmetic overflow and others, this catch clause will be run.

    3. catch (bytes memory lowLevelData) { ... }: This clause is executed if the error signature does not match any other clause, if there was an error while decoding the error message, or if no error data was provided with the exception. The declared variable provides access to the low-level error data in that case.

    4. catch { ... }: If you are not interested in the error data, you can just use catch { ... } (even as the only catch clause) instead of the previous clause.

  • If execution reaches a catch-block, then the state-changing effects of the external call have been reverted. If execution reaches the success block, the effects were not reverted. If the effects have been reverted, then execution either continues in a catch block or the execution of the try/catch statement itself reverts (for example due to decoding failures as noted above or due to not providing a low-level catch clause).

  • The reason behind a failed call can be manifold. Do not assume that the error message is coming directly from the called contract: The error might have happened deeper down in the call chain and the called contract just forwarded it. Also, it could be due to an out-of-gas situation and not a deliberate error condition: The caller always retains 63/64th of the gas in a call and thus even if the called contract goes out of gas, the caller still has some gas left

Last updated