Skip to content

VIP: stateful singleton modules with ownership hierarchy #3722

@charles-cooper

Description

@charles-cooper

Simple Summary

extend the import system by allowing "stateful modules" (that is, modules with top-level state variables). introduce a constraint system on the import system which maximizes safety + usability.

this is one of two proposals exploring the stateful module design space; the other is #3723.

Motivation

re-using code which encapsulates state is in general a useful feature to have for a language! however, in a contract-oriented programming context, this is a double edged sword because reasoning about storage is fundamentally difficult, especially when storage accesses are hidden behind a layer of abstraction. consider two basic approaches to the problem:

  1. each module gets a singleton instantiation in the storage allocator. this follows the One-Def Rule for modules and is probably the most intuitive for programmers. however, this hurts reasonability because a module's storage could be changed anywhere inside the import graph, as in the following example:
import dep1  # has a storage variable, counter
import dep2  # imports dep1. dep2.bar() modifies counter

@external
def foo():
    dep1.counter += 1
    dep2.bar()  # tramples dep1.counter!

this has a further issue which we will discuss in a bit, which is that access to dep1's __init__() function is uncontrolled. that is, it could be called multiple times in the import graph. this is a correctness problem, because programmers expect constructors to be called at most one time.

  1. the user controls instantiations by explicitly instantiating instances of a module. each of these is a fresh instantiation in the storage allocater. this has multiple benefits. if you instantiate a module, you are guaranteed that nobody else in the import graph can modify it. however, it hurts sharing of global state, which is a design consideration for some use cases. the simplest example of this would be a library which encapsulates re-entrancy protection (note this is a straw-man, because vyper already has a builtin for reentrancy protection).
import Lock  # Lock.acquire() and Lock.release() modify Lock._key
import Foo  # Foo.foo() uses Lock by calling Lock.acquire()/Lock.release()

_lock: Lock  # fresh instance of Lock
_foo: Foo  # fresh instance of Foo

export _foo.foo  # hypothetical syntax, cf. https://github.com/vyperlang/vyper/pull/3698

@external
def bar():
    self._lock.acquire()
    ...
    call SomeContract  # !! can re-enter to Foo.foo, because Foo.Lock._key and Lock._key are separate
    self._lock.release()

the other benefit here would be clear access to imported __init__() functions. since each instantiation is local, it is straightforward to enforce that __init__() is called one time for each instantiation. (in the above example, self._lock.__init__(...) and self._foo.__init__(...) would have to be called in the main __init__() function.

enumerated, the issues brought up above are:

  • state trampling
  • constraints on __init__()
  • state sharing

this proposal proposes a third option, which draws inspiration from linear type systems and the rust borrow checker.

the design proposed here is to enforce the one-def rule, but to address the issues above, additionally introduce an ownership system which allows the compiler to enforce constraints on how module state is written and initialized.

note on a design choice:

  • the new top-level statement type owns: some_module is a design requirement which allows the programmer to control where the module is laid out in storage.

Useful Definitions/Terminology

  • an affine type is one that can be used at most once
  • a linear type is one that must be used exactly once
  • the import graph is a directed acyclic graph which is traversed during import resolution
  • declaring variables produces a compile-time side effect in the storage allocator
  • a "nested import" is an import within an import
  • a "region" is an area of storage which can be touched by an effect
  • a module is a bundle of code-and-storage-layout functionality. there is currently a 1-to-1 correspondence in vyper between files and modules.
  • a compilation target is the module which is passed to the compiler as the "main" module.

Specification

Final Specification.

this proposal introduces an effects hierarchy for interacting with modules: initializes and uses. these correspond to the terminology owns and borrows from linear type systems, respectively.

the basic rules here are:

  1. ownership is modeled as an affine constraint, which is promoted to a linear constraint if any other effects are used from the module. that is,
  • a module might be imported but no stateful functions are accessed, so initialization is allowed but not required.
  • if a stateful function is reachable from the compilation target, then it must be initialized exactly one time in the import graph.
  1. there is a one-to-one correspondence between ownership and initialization. that is, if module initializes module2, then module2.__init__() must be called in module.__init__() . declaring ownership "seals off" access to module2.__init__(). it is envisioned that it will probably be used sparingly or near the top of the import graph.
  2. you cannot touch modules from an __init__() function unless they are already owned.
  3. you cannot touch state from a module unless it is used.
  4. initializes implies uses.
  5. the initializer declaration for a module must include all direct dependencies, e.g. if module1 declares uses: module2, then the initializer for module1 must be declared like initializes: module1[module2 := module2].

Original Specification

for historical/research purposes, the original spec is below. this was the design with seals: but not uses:. this original design is superseded by the design described here: #3722 (comment).

this proposal introduces an effects hierarchy for interacting with modules: owns and seals. an alternative name for owns could be initializes. owns is used here since it is the terminology used in linear type systems.

the basic rules here are:

  1. ownership is modeled as an affine constraint, which is promoted to a linear constraint if any other effects are used from the module. that is,
  • a module might be imported but no stateful functions are accessed, so initialization is allowed but not required.
  • if a stateful function is reachable from the compilation target, then it must be owned exactly one time in the import graph.
  1. there is a one-to-one correspondence between ownership and initialization. that is, if module owns module2, then module2.__init__() must be called in module.__init__() . declaring ownership "seals off" access to module2.__init__(). it is envisioned that it will probably be used sparingly or near the top of the import graph.
  2. you cannot touch modules from an __init__() function unless they are already owned.
  3. if a module seals module2, no other modules can write to it (or directly call mutating functions on module2).
  4. a module can only be owned once. seals: implies ownership.

note that seals: can be considered as an extension to the ownership system. in other words, the seals: semantics is not required to be implemented.

some examples, with a tentative syntax:

import dep1  # has a storage variable, counter
import dep2  # imports dep1. dep2.bar() modifies counter

seals: dep1

def __init__():
    dep1.__init__(...)

@external
def foo():
    dep1.counter += 1

@external
def foo1():
    dep1.update_counter()

# counterfactual example, this does not compile:
@external
def foo2():
    dep1.counter += 1
    dep2.bar()  # not allowed! dep2.bar() modifies dep1
# Bar.vy

import Lock
import Foo

x: uint256

# declare ownership of Lock!
# this would be an error if Foo declared ownership of Lock
# this statement also controls the location of Lock in the storage layout -- it comes after `x`.
owns: Lock  # own, but do not seal lock

exports: Foo.foo

def __init__():
    Lock.__init__(...)  # omitting this would be an error!

@external
def bar():
    Lock.acquire()
    ...  # do stuff, maybe call an external contract
    Lock.release()

an obligatory token example:

###
# Owned.vy
owner: address

def __init__():
    self.owner = msg.sender

def check_owner():
    assert msg.sender == self.owner
###

###
# BaseToken.vy
totalSupply: uint256
balances: HashMap[address, uint256]

def __init__(initial_supply: uint256):
    self.totalSupply += initial_supply
    self.balances[msg.sender] += initial_supply

@external
def transfer(recipient: address, amount: uint256):
    self.balances[msg.sender] -= amount  # safesub
    self.balances[recipient] += amount
###

###
# Mint.vy
import BaseToken
import Owned

@external
def mint(recipient: address, amount: uint256):
    Owned.check_owner()
    self._mint_to(recipient, amount)

@internal
def _mint_to(recipient: address, amount: uint256):
    BaseToken.totalSupply += amount
    BaseToken.balances[recipient] += amount
###

###
# Contract.vy
import Owned
import Mint
import BaseToken

owns: Owned
owns: BaseToken
seals: Mint  # hygiene - seal Mint

def __init__():
    BaseToken.__init__(100)  # required by `owns: BaseToken`
    Owned.__init__()  # required by `owns: Owned`
    Mint.__init__()  # required by `seals: Mint`

export: Mint.mint
export: BaseToken.transfer

note an alternative design for this hypothetical project could be for Mint to own: Owned and be responsible for calling its constructor. then Contract.vy would not be able to own: Owned. this is left as a design choice to library writers, when to "seal" ownership of modules and when to leave them open. for illustration, this is what that design would look like:

# Owned and BaseToken look the same.
###
# Mint.vy
import Owned
import BaseToken

own: Owned
own: BaseToken

def __init__(initial_supply: uint256):
    Owned.__init__()
    BaseToken.__init__(initial_supply)


@external
def mint(recipient: address, amount: uint256):
    Owned.check_owner()
    self._mint_to(recipient, amount)

@internal
def _mint_to(recipient: address, amount: uint256):
    BaseToken.totalSupply += amount
    BaseToken.balances[recipient] += amount
###

###
# Contract.vy
import Mint
import BaseToken

owns: Mint

owns: BaseToken  # this line will raise an error!

def __init__():
    BaseToken.__init__()  # error! Mint already initializes BaseToken
    Owned.__init__()  # error! Mint already initializes Owned

    Mint.__init__(100)  # that's better

Backwards Compatibility

does not change any existing language features, fully backwards compatible

Dependencies

References

Copyright

Copyright and related rights waived via CC0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions