# Subtle Vyper: Understanding Vyper's Idiosyncracies and Gotchas
One of Vyper's primary goals is to be as secure and readable as possible. However, due to historical reasons or implementation details, there are some edge cases ("gotchas") in the language whose behavior might not be intuitive for users. This article goes over these features as a preventative measure to help users attain the goals of writing secure and readable contracts. Further, some items only highlight differences of Vyper w.r.t Solidity or Python and might be surprising only when switching from those languages.
I am Vyper's lead security engineer and the list was assembled based on what I consider potentially problematic. The list is based both on my long-term reviews of the Vyper compiler and also commonly raised issues by our auditors and users.
The article was written for the release [0.4.1](https://github.com/vyperlang/vyper/releases/tag/v0.4.1) but should be generally applicable to other releases too. Most items in the list are associated with links for more detailed information and source code examples to help with understanding.
## 1. Order of evaluation
Order of evaluation refers to the order in which expressions within a statement are evaluated. Vyper should evaluate expressions from left to right. However, this is not true in some instances. First, since the `0.4.0` version, the evaluation order of the built-in functions (`raw_call`, `slice`, etc.) is undefined. Furthermore, this problem extends even to operators (for a complete list, see [this security advisory](https://github.com/vyperlang/vyper/security/advisories/GHSA-g2xh-c426-v8mf)).
Unexpected order of evaluation can become problematic if one argument produces a side-effect that another argument consumes. For example, here, `foo` returns 13 instead of 1, which would have been the case if the evaluation order was left-to-right.
```vyper
state: uint256
def side_effect() -> uint256:
self.state = 12
return 1
@external
def foo() -> uint256:
return unsafe_add(self.state, self.side_effect()) # returns 13 instead of 1
```
> [!NOTE]
> These order of evaluation problems are known drawbacks of Vyper, which don't have an easy fix without introducing performance regressions. We are actively working on resolving this problem.
## 2. Linearization of init functions
Since the version `0.4.0` Vyper provides modules. Stateful modules must be initialized. However, the compiler does not enforce that the initialization functions are called in the dependency order.
Consider the following example and assume `lib1` depends on `lib2`:
```vyper
import lib1
import lib2
initializes: lib1[lib2 := lib2]
initializes: lib2
@deploy
def __init__():
lib1.__init__() # lib2.__init__() has not been called yet!
lib2.__init__()
```
By using `initializes: lib1[lib2 := lib2]` we declare that `lib2` is a state dependency of `lib1`. However, in the example, we call the `__init__` function of `lib1` first, and thus, if `lib1`'s `__init__` depends on `lib2`, this might lead to unexpected behavior.
> [!NOTE]
> This is a deliberate design decision, but we are debating introducing the linearization order checks.
## 3. DoS due to memory allocation of DynArrays
Vyper allocates memory statically (see this [article](https://blog.vyperlang.org/posts/memory-allocation/) for an in-depth explanation of memory allocation in Vyper); this includes `DynArray` types. It allocates memory for the `DynArray` to accommodate its maximum possible size (also called the bound).
In the following example, `352` (a lengh slot + 10 value slots) bytes will be allocated:
```vyper
def foo():
array: DynArray[uint256, 10] = [1, 2]
a: uint256 = 1
```
When we touch the variable `a` (allocated on higher addresses than `array`), the memory will be expanded as if the `array` was full, and will trigger memory expansion costs. This can be particularly surprising if the `DynArray` is provided through calldata because, in that case, Vyper immediately copies it to memory anyway.
Thus, if a high bound for the `DynArray` is specified, this can easily lead to an out-of-gas exception.
> [!WARNING]
> The same rules apply for `Strings` and `Bytes`. We highlighted the behavior on `DynArrays` as that's where we expect the allocation strategy might be the most problematic.
## 4. Calling convention is pass-by-value
All the arguments are copied 1:1 during internal function calls and passed to the callee. Any modification to the argument in the callee does not propagate to the caller, i.e., references aren't used although Vyper documentation has a section on [Reference Types](https://docs.vyperlang.org/en/stable/types.html#reference-types).
During a call to `foo` the assertion passes:
```vyper
def bar(array: DynArray[uint256, 10]):
array = []
@external
def foo():
array: DynArray[uint256, 10] = [1, 2]
self.bar(array)
assert len(array) == 2
```
## 5. Assignments are copying
The assignment operator in Vyper always performs a copy even for the types the documentation calls [Reference Types](https://docs.vyperlang.org/en/stable/types.html#reference-types). The principle is the same as for the pass-by-value calling convention.
## 6. Immutables and __init__ function might not be initialized/called
Vyper enforces only syntactic assignment[^3] to immutable variables and a syntactic call to `__init__` functions. If this assignment/call appears, e.g., inside an if statement, the assignment/call might not happen. In the following example, the immutable isn't assigned, and the module isn't initialized:
[^3]: Syntactic assignment represents a concept where the compiler checks for the presence of the assignment/call, not its guaranteed execution path.
```vyper
i: immutable(uint256)
import lib1
initializes: lib1
@deploy
def __init__():
if False:
lib1.__init__()
i = 42
```
> [!NOTE]
> This is a deliberate design decision, forcing the assignments/calls would limit the flexibility of the constructor.
## 7. Rounding behavior of `//`
Integer division has different rounding semantics than Python for negative numbers: Vyper rounds towards zero, while Python rounds towards negative infinity. For example, `-1 // 2` will return `-1` in Python, but `0` in Vyper (it's `0` also in Solidity).
## 8. `default_return_value` gets evaluated after call finishes
Vyper allows the specification of `default_return_value` for external calls if the callee contract returns nothing. This default is an argument of the function call, but maybe surprisingly, it is lazily evaluated after the call finishes (it's not evaluated in case it isn't needed).
In the following example, the call to `foo` passes the assertion because the default is never evaluated:
```vyper
interface Foo:
def test() -> uint256: nonpayable
external_call_counter: uint256
@external
def test() -> uint256:
return 0
@external
def foo():
res: uint256 = extcall Foo(self).test(
default_return_value = self.get_default_id()
)
assert self.external_call_counter == 0
def get_default_id() -> uint256:
counter: uint256 = self.external_call_counter
self.external_call_counter += 1
return counter
```
> [!NOTE]
> See this [link](https://github.com/vyperlang/vyper/issues/4450#issuecomment-2589328227) for the rationale of this behavior.
## 9. Internal functions and `@payable`/ `@nonpayable` decorators
`@payable` decorator on an external function means that if the message call targeting it has an attached value, the function won't revert. Nonpayable functions will revert if a non-zero value is attached.
Internal functions can also be marked with these decorators, but the semantics **are different**. If an internal function is `@payable`, the function can read `msg.value`, but `@nonpayable` can't. A `@nonpayable` internal function can be called from `@payable` external one, but it can't read `msg.value`. The important thing is that internal functions won't revert in the presence of non-zero value (even if `nonpayable`).
## 10. Nonreentrant internal functions
Even internal functions can be marked with the `@nonreenrant` decorator. However, calling an internal `@nonreentrant` function from another function that already holds the lock will cause a revert. This happens because the second function attempts to acquire a lock that is already held within the current execution path, even though no external reentrant call has occurred.
The following example shows that if we call `foo`, the call will revert because `bar` uses the same lock which `foo` already locked:
```vyper
@nonreentrant
def bar():
pass
@external
@nonreentrant
def foo():
self.bar()
```
> [!NOTE]
> We are discussing removing the decorator for `internal` functions. The removal is implemented in [this](https://github.com/vyperlang/vyper/pull/4573) pull request.
## 11. No private visibility
All state variables in Vyper modules are accessible and modifiable by any module that imports them.
This might be especially dangerous if the importer modifies some important state variables on which invariants of the imported module are dependent.
Here, the importer `main` modifies the storage of `lib1`.
`lib1.vy`:
```vyper
i: uint256
@deploy
def __init__():
self.i = 42
```
`main.vy`:
```vyper
import lib1
initializes: lib1
@deploy
def __init__():
lib1.__init__()
lib1.i = 666
```
> [!NOTE]
> This is a deliberate design decision to increase interoperability between libraries. However, there's an ongoing [discussion](https://github.com/vyperlang/vyper/issues/4400) of adding some form of encapsulation.
## 12. Default arguments are inserted by the callee, not by the caller
Functions in Vyper support default arguments. If an external function has default arguments, the compiler generates overload functions for each default argument (each overload supporting one additional default). Thus, defaults are implemented via adding overloads, not by filling the arguments at callsite.
The following example shows what external function in `x` actually gets called.
Assume this interface `IFoo.vyi`:
```vyper
@external
def withdraw(
assets: uint256,
receiver: address = msg.sender,
owner: address = msg.sender,
) -> uint256:
...
```
`main.vy`:
```vyper
import IFoo
@external
def foo(x: IFoo):
extcall x.withdraw(0, self, self) # keccak256("withdraw(uint256,address,address)")[:4] = 0xb460af94
extcall x.withdraw(0) # keccak256("withdraw(uint256)")[:4] = 0x2e1a7d4d
```
The comments in `main.vy` highlight what function in `x` gets actually called.
> [!NOTE]
> Vyper compiler provides the flag `-f method_identifiers` which exposes all the method identifiers of the contract and might be useful to inspect how the defaults behave.
## 13. Functions have to be explicitly exported
Vyper automatically exposes the interface of the compilation target[^1]. The external functions (and public getters) from imports must be explicitly exported using the `exports` directive to make them exposed. The following contract doesn't expose `foo` from `lib1`, i.e., the function isn't part of the runtime code and can't be called.
`lib1.vy`:
```vyper
i: uint256
@external
def foo():
pass
@deploy
def __init__():
self.i = 42
```
`main.vy`:
```vyper
import lib1
initializes: lib1
@deploy
def __init__():
lib1.__init__()
```
[^1]: A compilation target is the target module which the user requests to compile (and which represents the final contract).
## 14. `send` function behavior
Vyper has a built-in `send` for transferring Ether, which doesn't forward any gas in the call but relies on the gas stipend (2300 gas) provided by the EVM[^2] when doing Ether transfers. The gas stipend is provided only if the value transferred is non-null. This means that if `send` is called with `value=0`, the call will revert due to lack of gas. This behavior is different from Solidity, which checks whether the attached value is `0`, and if it is, it forwards 2300 gas in the call.
[^2]: See [this](https://github.com/ethereum/execution-specs/blob/6c337f7c579e38170813d10cf4685afe0f04c869/src/ethereum/cancun/vm/gas.py#L227) link regarding the EVM stipend.
## 15. Venom codegen can be enabled via \--venom
Venom is Vyper's new experimental and very efficient backend. It can be easily enabled with the `--venom` flag, which might give a false sense of security. The backend hasn't yet been audited and is recommended for use with care.
## 16. Stateless modules can be initialized
`__init__` function should initialize the module's state. Vyper requires that all the stateful modules that the importer uses are initialized. However, even stateless modules can be initialized. This can create a confusing situation where the developer thinks that he added a state to the imported module where, in fact, he has forgotten to do so.
> [!NOTE]
> There's open [discussion](https://github.com/vyperlang/vyper/issues/3934) of this issue on Github.
## 17. Vyper's flags versus Solidity's enums
Vyper offers a user-defined `Flag` type, similar to `enums` in Solidity, but there are important differences. In Solidity, the enum's options are represented as unsigned integers starting from 0 and increasing by 1. Maximum number of members is 256. They support comparisons, but arithmetic and bitwise operators can't be applied.
Flags in Vyper also have at most 256 members. The members are represented by `uint256` values in the form of 2^n where `n` is the index of the member in the range `0 <= n <= 255`. They support comparisons with the additional inclusion of `in` and `not in` operators. Further, they support bitwise operators. Because different 2^n powers represent the members, we can easily do bitwise operations on them, e.g., for the `flag Roles` `(Roles.MANAGER | Roles.USER)` would combine the two roles.
## 18. Semantics of flags
The [item 16](#16.Vyper's-flags-versus-Solidity's-enums
) presented the `Flag` type and how it relates to the Solidity enum. We will explore some intricacies of the `Flag` type in this item.
Assume we have the following flag type and that the `deployer` receives the MANAGER and ADMIN roles:
```vyper
flag Roles:
MANAGER # 0b001
ADMIN # 0b010
USER # 0b100
roles: HashMap[address, Roles]
@deploy
def __init__():
self.roles[msg.sender] = (Roles.MANAGER | Roles.ADMIN)
```
Now, let us try to write a function that checks if the `msg.sender` has either the MANAGER or ADMIN role (which the deployer satisfies).
```vyper
def only_admin_or_manager():
assert (
self.roles[msg.sender] == Roles.ADMIN
or self.roles[msg.sender] == Roles.MANAGER
)
```
Our deployer wouldn't pass this check because his role comprises both MANAGER and ADMIN roles; thus, there are 2 bits flipped in his role (0b011). Thus, the assertion will fail if we compare with just the individual roles.
Now, let us assume the `in` operator. Assume the `msg.sender` has the roles `(Roles.MANAGER | Roles.USER)` and assume the following usage of the `in` operator:
```vyper
def only_admin_and_manager():
assert (self.roles[msg.sender] in (Roles.ADMIN | Roles.MANAGER))
```
This assertion will pass despite the user having the `Roles.USER` (not in the right-hand-side) and lacking the role `roles.ADMIN
> [!NOTE]
> See this [link](https://github.com/vyperlang/vyper/issues/4277) for further discussion of the semantics. The semantics will likely change in the upcomming [breaking release](https://github.com/vyperlang/vyper/issues/4572) `0.5.0`.
## 19. Pragma evaluation
Vyper allows to use `# pragma` comments to configure the compilation process. Users can set attributes like version, optimization level, or the target EVM version.
Vyper also allows composing contracts from multiple modules, and each module can have its own pragmas. In such cases, the settings are derived only from the compilation target (while checking the compiler version pragma for all modules). So, for example, if the user sets different optimization levels in different modules, the compilation process will pass just fine and will use only the settings from the compilation target.
## 20. Evaluation of iterable in `for` statements
Vyper allows multiple flavors of for-loop iteration. One of them is iterating over iterable. Let's consider literal iterables and the following contract:
```vyper
x: uint256
trace: DynArray[uint256, 3]
@external
def test() -> DynArray[uint256, 3]:
for i: uint256 in [self.use_side_effect(), self.use_side_effect(), self.use_side_effect()]:
self.x += 1
self.trace.append(i)
return self.trace
@view
def use_side_effect() -> uint256:
return self.x
```
If we call `test()`, the result will be `[0, 0, 0]` because the list gets evaluated once before entering the loop, and the result is stored. The loop then iterates over this stored value, so it does not read the side-effect produced by `self.x += 1`.
## 21. Decoding Behavior with Out-of-Bounds Calldata/Code Reads
According to the EVM specs, when using `CALLDATACOPY`, `CALLDATALOAD` and `CODECOPY`, for out of bound bytes, 0s will be copied.
These opcodes are abstracted away by both Solidity and Vyper, and, among other uses, are used in the ABI decoder when:
1. decoding external functions arguments,
2. decoding constructor arguments.
Solidity always makes sure that the data to be decoded fits in the provided buffer which is bounded by `calldatasize`/`codesize`.
Vyper, on the other hand, does check that the static part of the to-be-decoded type fits in the provided buffer (e.g., for a Dynamic Array, it will check that the head fits) but not for the dynamic part.
> [!IMPORTANT]
> The check in Vyper is strict for reads from other data locations like memory or storage, where reading 'dirty' bytes could occur and which aren't fully user-controlled.
Let's assume contracts in Vyper and Solidity which expose function `foo` which takes a dynamic array as argument from `calldata`.
```vyper
store: DynArray[uint256, 10]
@external
def foo(a:DynArray[uint256, 10]):
self.store = a
```
```solidity
contract Foo{
uint256[] store;
function foo(uint256[] calldata a) public {
store = a;
}
}
```
Now, let's craft data for a low-level `call` targetting the function `foo`. According to abi-spec, the array is encoded as a tuple - the `head` points to the array's length (which in this case is `2`). Vyper will decode these 2 array elements from the zero bytes that follow. Solidity will revert.
```Python
method_id = "8b44cef1"
head = "0000000000000000000000000000000000000000000000000000000000000020"
length = "0000000000000000000000000000000000000000000000000000000000000002"
elem0 = ""
elem1 = ""
# the following low-level call will pass
# and the contents of store will be [0, 0]
boa.env.raw_call(
foo.address,
data=bytes.fromhex(method_id + head + length + elem0 + elem1)
)
```
> [!NOTE]
> Vyper employs this strategy because the caller could have provided these arguments by his own in the first place. It's also a form of optimization. It could become problematic if the caller incorrectly encoded the arguments and the decoding routine read the zero bytes where the original arguments should have been.
> [!NOTE]
> This item was suggested and co-authored with [@trocher](https://github.com/trocher).
## Conclusion
A list of 21 potentially problematic Vyper constructs was presented. The focus was drawn onto items whose semantics might differ from what might be the developer's immediate intuition or experience from other languages.
If I was to point out only a few items to really look out for it would be
1. [1) Order of evaluation](#1.-Order-of-evaluation),
2. [3) DynArray memory allocation](#3-dos-due-to-memory-allocation-of-dynarrays),
3. [4) Calling convention is pass-by-value](#4-calling-convention-is-pass-by-value)
4. [9) Payability of internal functions](#8-internal-functions-and-payable-nonpayable-decorators),
5. [12) Default arguments](#11-default-arguments-are-inserted-by-the-callee-not-by-the-caller)
6. and [18) Semantics of Flags](#17-semantics-of-flags).
Many of the items are being actively resolved by the compiler team to make the language as secure and readable as possible.
## Acknowledgements
I took some examples from the Vyper docs and the issue tracker. Many of the recommendations here are already present within the docs. Some issues described here were raised during audits or by the language users.
I'd like to thank [@pcaversaccio](https://github.com/pcaversaccio) for helping improve the article. Valuable feedback was also provided by [@trocher](https://github.com/trocher) who also co-authored and suggested the item 21.
I'd also like to recommend the [Secureum race](https://hackmd.io/@trocher/ByQK9Vlnkx ) by [@trocher](https://github.com/trocher). Some of his examples were used as inspiration when writing this article.