# Secureum RACE #39 In the following questions, assume all contracts are deployed on Ethereum Mainnet. Each question has at least one correct answer, though some may have more than one. Whenever the module `ownable` is referenced, it is assumed to be the following module: ```python # pragma version 0.4.1 # simplified version of https://github.com/pcaversaccio/snekmate/blob/main/src/snekmate/auth/ownable.vy owner: public(address) @deploy @payable def __init__(): self._transfer_ownership(msg.sender) @external def transfer_ownership(new_owner: address): self._check_owner() assert new_owner != empty(address) self._transfer_ownership(new_owner) @internal def _check_owner(): assert msg.sender == self.owner @internal def _transfer_ownership(new_owner: address): old_owner: address = self.owner self.owner = new_owner ``` --- ## Question 1 Given the following contract, and a call to `foo()` with `gas=100_000` (assuming the sender has enough ETH to cover the message value), which of the following statements are true? ```python # pragma version 0.4.1 @payable @external def foo(): send(self, msg.value) @payable @external def __default__(): pass ``` - [ ] A. The call will never fail - [ ] B. The call will always fail - [ ] C. The call might fail - [ ] D. The contract does not compile <details> <summary><strong>Solution</strong></summary> **Correct answer:** C **Explanation:** - The contract contains two functions: `foo()` and a fallback (`__default__`). - The `foo()` function is marked as `@payable` and uses the `send()` builtin to transfer `msg.value` back to the contract via the fallback function. - **Key Point:** According to [EVM specifications](https://github.com/ethereum/execution-specs/blob/9c58cc8553ec3a59e732e81d5044c35aa480fbbb/src/ethereum/cancun/vm/gas.py#L227), when a call transfers a non-zero amount of ETH, the EVM adds 2300 gas (`GAS_STIPEND`) to the sub-context. - In Vyper, the `send()` builtin performs a call with `gas=0` (unlike specified otherwise with a kwarg). That is, when transferring **zero ETH**, the `send()` builtin does **not** add a gas stipend (unlike what Solidity's `transfer()` would do). - Therefore, if `msg.value` is zero, no gas is forwarded, and the call will fail. Thus: - **A.** Incorrect - because the call may fail when `msg.value` is zero. - **B.** Incorrect - the call may succeed when `msg.value` is non-zero. - **C.** Correct. - **D.** Incorrect - the contract compiles successfully. </details> --- ## Question 2 Assuming you are making an external call with `IERC20(token).transfer(dst, 100)` to an arbitrary ERC-20 token, what should you definitely add to the call? - [ ] A. `skip_contract_check=True` - [ ] B. `default_return_value=True` - [ ] C. `value=0` - [ ] D. `gas=2300` <details> <summary><strong>Solution</strong></summary> **Correct answer:** B **Explanation:** - When calling an ERC-20 token's `transfer()` function, you must account for variations in implementations. - Some tokens (like Tether/USDT) do not return a boolean as specified by the ERC-20 standard. - By specifying `default_return_value=True`, the call will treat a missing return value as `True` by default, preventing a revert due to non-standard behavior. Thus: - **A.** Incorrect - skipping the contract check is not recommended. - **B.** Correct. - **C.** Incorrect - `value=0` is the default behavior. - **D.** Incorrect Providing only 2300 gas might be insufficient for a call to an ERC-20 token. Furthermore, even if it was enough, hard-coding the gas value is not recommended as future hard forks might change the gas cost of certain operations. </details> --- ## Question 3 Given the following contract, what is a correct way of checking if a user has the `Moderator` or `Admin` role? ```python # pragma version 0.4.1 flag Access: User Admin Moderator accessOf: HashMap[address, Access] def only_admin_or_moderator_A(): if self.accessOf[msg.sender] == Access.User: raise "User access denied" def only_admin_or_moderator_B(): if not ( self.accessOf[msg.sender] == Access.Admin or self.accessOf[msg.sender] == Access.Moderator ): raise "User access denied" def only_admin_or_moderator_C(): if not (self.accessOf[msg.sender] in (Access.Admin | Access.Moderator)): raise "User access denied" ``` - [ ] A. `only_admin_or_moderator_A` - [ ] B. `only_admin_or_moderator_B` - [ ] C. `only_admin_or_moderator_C` - [ ] D. All of the above <details> <summary><strong>Solution</strong></summary> **Correct answer:** C **Explanation:** - Vyper's `flag` work similarly to flag enums in other languages (each value represents a bit). The `Access` type above is represented as: ``` User : 0b001 Admin : 0b010 Moderator: 0b100 ``` - Because any combination (and even `empty(Access) == 0b000`) is valid, the check must verify that at least one of the desired bits is set. Thus: - A. Incorrect. This function only checks that the caller is not just a `User` but does not verify the presence of `Admin` or `Moderator` flags. - B. Incorrect. This function checks that the caller have the `Admin` role XOR the `Moderator` role, but it would incorrectly fail if: 1. The caller has both roles: `( Access.Admin | Access.Moderator) = 0b110`. 2. The caller is an Admin or a Moderator but is also a User, for example: `( Access.Admin | Access.User) = 0b011`. - C. Correct. In vyper the keyword `in` checks that any of the flags on two operands are simultaneously set, `self.accessOf[msg.sender] in (Access.Admin | Access.Moderator)` is equivalent to `(self.accessOf[msg.sender] & 0b110) != 0b000`. - D. Incorrect. Only answer C is correct. </details> --- ## Question 4 Given the following contract: ```python flag E: a @external def hello_vyper_world(a: E, b: Bytes[4] = b'1234') -> Bytes[4]: return slice(msg.data, 0, 4) ``` Which of the following are valid function selectors for the function `hello_vyper_world`? - [ ] A. `0xdbae851a` - [ ] B. `0x41aa4785` - [ ] C. `0x6acbda94` - [ ] D. `0x986a9642` <details> <summary><strong>Solution</strong></summary> **Correct answers:** A and D **Explanation:** - In Vyper: 1. Flags (like `E`) are converted to the ABI type `uint256`. 2. `Bytes[N]` types are converted to the ABI type `bytes`. 3. When a function has default arguments, Vyper generates one entry point per overload. - Therefore, the two canonical representations for the function signature are: - `hello_vyper_world(uint256)` - `hello_vyper_world(uint256,bytes)` - Taking the first 4 bytes of the keccak256 hash of these canonical representations yields: - `method_id(hello_vyper_world(uint256)): 0xdbae851a` - `method_id(hello_vyper_world(uint256,bytes)): 0x986a9642` - Alternatively, an easy way to check the method identifiers of a vyper contract is `vyper -f method_identifiers foo.vy`. Thus, **A** and **D** are the valid selectors. </details> --- ## Question 5 Given the following contract, which of the following statements are true? (The module ownable is defined at the top of the RACE) ```python # pragma version 0.4.1 import ownable from ethereum.ercs import IERC20 initializes: ownable receivers: DynArray[Receiver, max_value(uint32)] BPS: constant(uint256) = 10_000 token_whitelist: HashMap[IERC20, bool] token_balances: public(HashMap[IERC20, HashMap[address, uint256]]) token_balance_tracked: public(HashMap[IERC20, uint256]) struct Receiver: addr: address weight: uint256 @deploy def __init__(initial_receivers: address[4]): ownable.__init__() for receiver: address in initial_receivers: self.receivers.append(Receiver(addr=receiver, weight=BPS // 4)) @external def set_token_whitelist(token: IERC20, status: bool): ownable._check_owner() self.token_whitelist[token] = status @internal def _set_receivers(_receivers: DynArray[Receiver, max_value(uint32)]): total_weight: uint256 = 0 for receiver: Receiver in _receivers: assert receiver.addr != empty(address), "receiver is the zero address" assert receiver.weight > 0, "receiver weight is zero" assert receiver.weight <= BPS, "receiver weight is too high" total_weight += receiver.weight assert total_weight == BPS, "total weight is not 100%" self.receivers = _receivers @internal def transfer_in(token: IERC20, amount: uint256) -> uint256: assert self.token_whitelist[token] if amount > 0: extcall token.transferFrom(msg.sender, self, amount) return amount @external def set_receivers(_receivers: DynArray[Receiver, max_value(uint32)]): ownable._check_owner() self._set_receivers(_receivers) @payable def deposit(token: address, amount: uint256): if token == empty(address): assert msg.value == amount else: assert msg.value == 0 self.transfer_in(IERC20(token), amount) @external def distribute_tokens(token: IERC20, amount: uint256 = 0): balance: uint256 = unsafe_add( staticcall token.balanceOf(self), self.transfer_in(token, amount) ) new_balance: uint256 = balance - self.token_balance_tracked[token] # Leave dust due to rounding errors in the untracked balance, to be distributed next time for receiver: Receiver in self.receivers: receiver_amount: uint256 = new_balance * receiver.weight // BPS self.token_balance_tracked[token] += receiver_amount self.token_balances[token][receiver.addr] += receiver_amount @external def claim_tokens(token: IERC20, to: address, amount: uint256): assert self.token_whitelist[token] assert self.token_balances[token][msg.sender] >= amount self.token_balances[token][msg.sender] -= amount self.token_balance_tracked[token] -= amount extcall token.transfer(to, amount) @external def recover(token: IERC20, to: address, amount: uint256, force: bool = False): if token.address == empty(address): ownable._check_owner() assert force, "force required" send(to, amount) else: # Anyone can recover non-whitelisted tokens from the contract assert not self.token_whitelist[token] success: bool = raw_call( token.address, abi_encode( to, amount, method_id=method_id("transfer(address,uint256)"), ), revert_on_failure=False, ) ``` - [ ] A. The owner can transfer ownership to any address except the zero address - [ ] B. The initial owner is the deployer of the contract - [ ] C. Anyone can take ownership of the contract without the owner's consent - [ ] D. None of the above <details> <summary><strong>Solution</strong></summary> **Correct answer:** B **Explanation:** - In Vyper, external functions from an imported module (such as `transfer_ownership` from `ownable`) are **not** automatically exposed in the compiling contract. - To expose such a function, you would need an explicit statement (e.g., `exports: ownable.transfer_ownership`). Thus: - A. Incorrect The `transfer_ownership` function is not exposed externally, so the owner cannot transfer ownership to any address. - B. Correct. The `ownable.__init__` function is called by the constructor of the contract and transfers ownership to the deployer of the contract. - C. Incorrect. `_transfer_ownership()` is unreachable from the outside and only called in the constructor. - D. Incorrect. Hence, only **B** is correct. </details> --- ## Question 6 Given the code from Question 5, which of the following statements are true? - [ ] A. It is not possible to set the receivers - [ ] B. The sum of receivers' `weight` might be larger than BPS in some cases - [ ] C. No easy way to deposit ETH is implemented - [ ] D. None of the above <details> <summary><strong>Solution</strong></summary> **Correct answers:** A and C **Explanation:** - **A. Correct.** Vyper allocates memory statically. For a dynamic array defined as `DynArray[Receiver, max_value(uint32)]`, the extremely large upper bound forces the reservation of a vast amount of memory (of size `max_value(uint32) * 32 * 2 + 32` bytes). When any variable allocated after this block is written to, the memory expansion of the block is triggered and charged. As its cost is much greater than Ethereum block gas limit, the execution will run out of gas. This prohibitively high gas costs effectively make setting receivers impossible. - **B. Incorrect.** The `_set_receivers` function ensures that the sum of all receiver weights equals `BPS`, so the total cannot exceed `BPS`. - **C. Correct.** The `deposit()` function is marked only as `@payable` (and not as `@external`), meaning it is not accessible externally. Although ETH could be forced into the contract via other means (e.g., selfdestruct), there is no intended method for depositing ETH. - **D.** Incorrect. </details> --- ## Question 7 Given the code from Question 5, and assuming all tokens used by the system are trusted, ERC-20 compliant, with no unusual behaviors (e.g., rebasing, transfers a different amount than requested, fee-on-transfer, double entry points, non-compliant interface, or hooks) — for example, tokens like DAI — which of the following statements are true? - [ ] A. ERC20 tokens might be stuck forever in the contract - [ ] B. Some receivers could get more tokens than they should - [ ] C. A user who is not a receiver nor the admin could steal whitelisted tokens from the contract - [ ] D. None of the above <details> <summary><strong>Solution</strong></summary> **Correct answer:** B **Explanation:** - **A. Incorrect.** Non-whitelisted tokens can be recovered via the `recover` function, and whitelisted tokens are properly managed through `distribute_tokens`. - **B. Correct.** According to `GHSA-g2xh-c426-v8mf `, Vyper evaluates the argument of several expressions from right to left. This includes `unsafe_add()`. When computing ```python balance: uint256 = unsafe_add( staticcall token.balanceOf(self), self.transfer_in(token, amount) ) ``` This means that the call to `transfer_in` will be performed before reading `token.balanceOf(self)`. - `self.transfer_in(token, amount)` returns `amount` - `token.balanceOf(self)` returns `initial_balance + amount`. As a result, the computed `balance` becomes `initial_balance + 2 * amount` instead of the intended `initial_balance + amount`. This means that all receivers will be allocated too much tokens and the contract will be insolvent. The first receivers to call `claim_tokens()` will be stealing tokens from the others. - **C. Incorrect.** The `recover` function prevents non-authorized recovery of whitelisted tokens. - **D. Incorrect.** Thus, only **B** is correct. </details> --- ## Question 8 Given the following contract, what storage slot(s) are written to when calling `set()`? (Note that **no** storage layout override is being used) ```python # pragma version 0.4.1 # pragma evm-version cancun import ownable initializes: ownable struct A: a: uint128 b: bool a: A b: transient(address) c: HashMap[uint256, DynArray[uint256, 5]] @deploy def __init__(): ownable.__init__() self.c[4] = [1, 2] @nonreentrant @external def set(): assert len(self.c[4]) == 2 self.c[4].append(12) ``` - [ ] A. `0` - [ ] B. `1` - [ ] C. `keccak256(concat(convert(3,bytes32),convert(3,bytes32)))` - [ ] D. `keccak256(concat(convert(4,bytes32),convert(3,bytes32)))` - [ ] E. `keccak256(concat(convert(3,bytes32),convert(4,bytes32)))` - [ ] F. `convert(convert(keccak256(concat(convert(3,bytes32),convert(3,bytes32))),uint256)+3,bytes32)` - [ ] G. `convert(convert(keccak256(concat(convert(4,bytes32),convert(3,bytes32))),uint256)+3,bytes32)` - [ ] H. `convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+3,bytes32)` - [ ] I. `convert(convert(keccak256(concat(convert(3,bytes32),convert(3,bytes32))),uint256)+2,bytes32)` - [ ] J. `convert(convert(keccak256(concat(convert(4,bytes32),convert(3,bytes32))),uint256)+2,bytes32)` - [ ] K. `convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+2,bytes32)` - [ ] L. `convert(convert(keccak256(keccak256(concat(convert(3,bytes32),convert(3,bytes32)))),uint256)+2,bytes32)` - [ ] M. `convert(convert(keccak256(keccak256(concat(convert(4,bytes32),convert(3,bytes32)))),uint256)+2,bytes32)` - [ ] N. `convert(convert(keccak256(keccak256(concat(convert(3,bytes32),convert(4,bytes32)))),uint256)+2,bytes32)` <details> <summary><strong>Solution</strong></summary> **Correct answers:** E and H **Explanation:** The storage slot written to during a call to `set()` are: - The value at the index `2` of the Dynamic array `self.c[4]`, as it is set to `12`. - The length of the Dynamic array `self.c[4]`, as it is updated by the `append` function. Having `cancun` as the EVM version means that the `@nonreentrant` key is stored in the transient storage and not in the regular storage. The storage layout of the contract can be obtained with `vyper -f layout foo.vy` and is as follows: ``` 0x00: ownable.owner 0x01: a.a 0x02: a.b 0x03: c ``` Key points to consider here are: 1. The storage of the `ownable` module is inserted where the statement `initializes: ownable` is declared. 2. The fields of the struct `A` are not tightly packed as Vyper never pack storage variables. 3. The variable `b` is transient and does not occupy a storage slot. Given that value corresponding to a mapping key `k` is located at `keccak256(slot || k)` (when k is a value type), the storage slot of `self.c[4]` is: ```python s0 = keccak256(concat(convert(3,bytes32),convert(4,bytes32))) ``` Note that this differs from Solidity, which would do `keccak256(k || slot)`. Vyper does not store dynamic arrays in the same way as Solidity, because a maximum bound is known at compile time, the length is stored in the first slot, and all elements are stored in subsequent consecutive slots. In the case of the array at `self.c[4]`, this would lead to the following layout: ``` s0 : length s0+1: array[0] ... s0+5: array[4] ``` Thus: - **E.** Correct - length is stored at `keccak256(concat(convert(3,bytes32),convert(4,bytes32)))`. - **H.** Correct - the element value is stored at `convert(convert(keccak256(concat(convert(3,bytes32),convert(4,bytes32))),uint256)+3,bytes32)`. All other options are incorrect. </details>