From 43cd44330b58156060988cd5b73c05cc10026110 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 11 Feb 2025 13:52:08 +0100 Subject: [PATCH] vault: add documentation --- contracts/Vault.sol | 88 ++++++++++++++++++++++++++++++++++ contracts/vault/Accounts.sol | 24 ++++++++++ contracts/vault/Locks.sol | 18 +++++++ contracts/vault/Timestamps.sol | 6 +++ contracts/vault/TokenFlows.sol | 5 ++ contracts/vault/VaultBase.sol | 35 ++++++++++++++ 6 files changed, 176 insertions(+) diff --git a/contracts/Vault.sol b/contracts/Vault.sol index 540035a..76b7d43 100644 --- a/contracts/Vault.sol +++ b/contracts/Vault.sol @@ -3,9 +3,53 @@ pragma solidity 0.8.28; import "./vault/VaultBase.sol"; +/// A vault provides a means for smart contracts to control allocation of ERC20 +/// tokens without the need to hold the ERC20 tokens themselves, thereby +/// decreasing their own attack surface. +/// +/// A vault keeps track of funds for a smart contract. This smart contract is +/// called the controller of the funds. Each controller has its own independent +/// set of funds. Each fund has a number of recipients, and every recipient has +/// its own account within the fund. +/// +/// Vault -> Controller -> Fund -> Account +/// +/// Funds are identified by a unique 32 byte identifier, chosen by the +/// controller. +/// +/// An account has a balance, of which a part can be designated. Designated +/// tokens can no longer be transfered to another account. +/// +/// A typical flow in which a controller uses the vault to handle funds: +/// 1. the controller chooses a unique id for the fund +/// 2. the controller locks the fund for an amount of time +/// 3. the controller deposits ERC20 tokens into the fund +/// 4. the controller transfers tokens between accounts in the fund +/// 5. the fund unlocks after a while, freezing the account balances +/// 6. the controller withdraws ERC20 tokens from the fund for a recipient, or +/// the recipient initiates the withdrawal itself +/// +/// The vault makes it harder for an attacker to extract funds, through several +/// mechanisms: +/// - tokens in a fund can only be reassigned while the fund is time-locked, and +/// only be withdrawn after the lock unlocks, delaying an attacker's attempt +/// at extraction of tokens from the vault +/// - tokens in a fund can not be reassigned when the lock unlocks, ensuring +/// that they can no longer be reassigned to an attacker +/// - when storing collateral, it can be designated for the collateral provider, +/// ensuring that it cannot be reassigned to an attacker +/// - malicious upgrades to a fund controller cannot prevent recipients from +/// withdrawing their tokens +/// - burning tokens in a fund ensures that these tokens can no longer be +/// extracted by an attacker +/// contract Vault is VaultBase { + constructor(IERC20 token) VaultBase(token) {} + /// The amount of tokens that are currently assigned to a recipient in a fund. + /// This includes available and designated tokens. Available tokens can be + /// transfered to other accounts, but designated tokens cannot. function getBalance( Fund fund, Recipient recipient @@ -15,6 +59,8 @@ contract Vault is VaultBase { return balance.available + balance.designated; } + /// The amount of tokens that are currently designated to a recipient in a + /// fund. These tokens can no longer be transfered to other accounts. function getDesignatedBalance( Fund fund, Recipient recipient @@ -24,31 +70,49 @@ contract Vault is VaultBase { return balance.designated; } + /// Returns the status of the lock on the fund. Most operations on the vault + /// can only be done by the controller when the funds are locked. Withdrawal + /// can only be done when the funds are unlocked. function getLockStatus(Fund fund) public view returns (LockStatus) { Controller controller = Controller.wrap(msg.sender); return _getLockStatus(controller, fund); } + /// Returns the expiry time of the lock on the fund. A locked fund unlocks + /// automatically at this timestamp. function getLockExpiry(Fund fund) public view returns (Timestamp) { Controller controller = Controller.wrap(msg.sender); return _getLockExpiry(controller, fund); } + /// Locks the fund until the expiry timestamp. The lock expiry can be extended + /// later, but no more than the maximum timestamp. function lock(Fund fund, Timestamp expiry, Timestamp maximum) public { Controller controller = Controller.wrap(msg.sender); _lock(controller, fund, expiry, maximum); } + /// Delays unlocking of a locked fund. The new expiry should be later than + /// the existing expiry, but no later than the maximum timestamp that was + /// provided when locking the fund. function extendLock(Fund fund, Timestamp expiry) public { Controller controller = Controller.wrap(msg.sender); _extendLock(controller, fund, expiry); } + /// Deposits an amount of tokens into the vault, and adds them to the balance + /// of the recipient. ERC20 tokens are transfered from the caller to the vault + /// contract. + /// Only allowed when the fund is locked. function deposit(Fund fund, Recipient recipient, uint128 amount) public { Controller controller = Controller.wrap(msg.sender); _deposit(controller, fund, recipient, amount); } + /// Takes an amount of tokens from the recipient's balance and designates them + /// for the recipient. These tokens are no longer available to be transfered + /// to other accounts. + /// Only allowed when the fund is locked. function designate( Fund fund, Recipient recipient, @@ -58,6 +122,9 @@ contract Vault is VaultBase { _designate(controller, fund, recipient, amount); } + /// Transfers an amount of tokens from the acount of one recipient to the + /// other. + /// Only allowed when the fund is locked. function transfer( Fund fund, Recipient from, @@ -68,6 +135,12 @@ contract Vault is VaultBase { _transfer(controller, fund, from, to, amount); } + /// Transfers tokens from the account of one recipient to the other over time. + /// Every second a number of tokens are transfered, until the fund is + /// unlocked. + /// Only allowed when the fund is locked. + /// Only allowed when the balance is sufficient to sustain the flow until the + /// fund unlocks, even if the lock expiry time is extended to its maximum. function flow( Fund fund, Recipient from, @@ -78,26 +151,41 @@ contract Vault is VaultBase { _flow(controller, fund, from, to, rate); } + /// Burns an amount of designated tokens from the account of the recipient. + /// Only allowed when the fund is locked. function burnDesignated(Fund fund, Recipient recipient, uint128 amount) public { Controller controller = Controller.wrap(msg.sender); _burnDesignated(controller, fund, recipient, amount); } + /// Burns all tokens from the account of the recipient. + /// Only allowed when the fund is locked. function burnAccount(Fund fund, Recipient recipient) public { Controller controller = Controller.wrap(msg.sender); _burnAccount(controller, fund, recipient); } + /// Burns all tokens from all accounts in a fund. + /// Only allowed when the fund is locked. function burnFund(Fund fund) public { Controller controller = Controller.wrap(msg.sender); _burnFund(controller, fund); } + /// Transfers all ERC20 tokens in the recipient's account out of the vault to + /// the recipient address. + /// Only allowed when the fund is unlocked. + /// ⚠️ The recipient can also withdraw itself, so when designing a smart + /// contract that controls funds in the vault, don't assume that only this + /// smart contract can initiate a withdrawal ⚠️ function withdraw(Fund fund, Recipient recipient) public { Controller controller = Controller.wrap(msg.sender); _withdraw(controller, fund, recipient); } + /// Allows a recipient to withdraw its tokens from a fund directly, bypassing + /// the need to ask the controller of the fund to initiate the withdrawal. + /// Only allowed when the fund is unlocked. function withdrawByRecipient(Controller controller, Fund fund) public { Recipient recipient = Recipient.wrap(msg.sender); _withdraw(controller, fund, recipient); diff --git a/contracts/vault/Accounts.sol b/contracts/vault/Accounts.sol index d0ed2d2..e822e21 100644 --- a/contracts/vault/Accounts.sol +++ b/contracts/vault/Accounts.sol @@ -4,19 +4,31 @@ pragma solidity 0.8.28; import "./TokenFlows.sol"; import "./Timestamps.sol"; +/// Records the token balance and the incoming and outgoing token flows struct Account { Balance balance; Flow flow; } +/// The account balance. Fits in 32 bytes to minimize storage costs. +/// A uint128 is used to record the amount of tokens, which should be more than +/// enough. Given a standard 18 decimal places for the ERC20 token, this still +/// allows for 10^20 whole coins. struct Balance { + /// Available tokens can be transfered uint128 available; + /// Designated tokens can no longer be transfered uint128 designated; } +/// The incoming and outgoing flows of an account. Fits in 32 bytes to minimize +/// storage costs. struct Flow { + /// Rate of outgoing tokens TokensPerSecond outgoing; + /// Rate of incoming tokens TokensPerSecond incoming; + /// Last time that the flow was updated Timestamp updated; } @@ -25,6 +37,8 @@ library Accounts { using TokenFlows for TokensPerSecond; using Timestamps for Timestamp; + /// Calculates whether the available balance is sufficient to sustain the + /// outgoing flow of tokens until the specified timestamp function isSolventAt( Account memory account, Timestamp timestamp @@ -34,6 +48,10 @@ library Accounts { return outgoing <= account.balance.available; } + /// Updates the available and designated balances by accumulating the + /// outgoing and incoming flows up until the specified timestamp. Outgoing + /// tokens are deducted from the available balance. Incoming tokens are added + /// to the designated tokens. function update(Account memory account, Timestamp timestamp) internal pure { Duration duration = account.flow.updated.until(timestamp); account.balance.available -= account.flow.outgoing.accumulate(duration); @@ -41,11 +59,17 @@ library Accounts { account.flow.updated = timestamp; } + /// Starts an incoming flow of tokens at the specified rate. If there already + /// is a flow of incoming tokens, then its rate is increased accordingly. function flowIn(Account memory account, TokensPerSecond rate) internal view { account.update(Timestamps.currentTime()); account.flow.incoming = account.flow.incoming + rate; } + /// Starts an outgoing flow of tokens at the specified rate. If there is + /// already a flow of incoming tokens, then these are used to pay for the + /// outgoing flow. If there are insuffient incoming tokens, then the outgoing + /// rate is increased. function flowOut(Account memory account, TokensPerSecond rate) internal view { account.update(Timestamps.currentTime()); if (rate <= account.flow.incoming) { diff --git a/contracts/vault/Locks.sol b/contracts/vault/Locks.sol index fff8416..7c192e4 100644 --- a/contracts/vault/Locks.sol +++ b/contracts/vault/Locks.sol @@ -3,17 +3,35 @@ pragma solidity 0.8.28; import "./Timestamps.sol"; +/// A time-lock for funds struct Lock { + /// The lock unlocks at this time Timestamp expiry; + /// The expiry can be extended no further than this Timestamp maximum; + /// The total amount of tokens locked up in the fund uint128 value; + /// Indicates whether the fund was burned bool burned; } +/// A lock can go through the following states: +/// +/// ---------------------------------------- +/// | | +/// --> NoLock ---> Locked ---> UnLocked -- +/// \ +/// ---> Burned +/// enum LockStatus { + /// Indicates that no lock is set. This is the initial state, or the state + /// after all tokens have been withdrawn. NoLock, + /// Indicates that the funds are locked. Withdrawing tokens is not allowed. Locked, + /// Indicates that the lock is unlocked. Withdrawing is allowed. Unlocked, + /// Indicates that all tokens in the fund are burned Burned } diff --git a/contracts/vault/Timestamps.sol b/contracts/vault/Timestamps.sol index 786cd2d..27343ac 100644 --- a/contracts/vault/Timestamps.sol +++ b/contracts/vault/Timestamps.sol @@ -1,7 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; +/// Represents a moment in time, represented as unix time (number of seconds +/// since 1970). Uses a uint40 to facilitate efficient packing in structs. A +/// uint40 allows times to be represented for the coming 30 000 years. type Timestamp is uint40; +/// Represents a duration of time in seconds type Duration is uint40; using {_timestampEquals as ==} for Timestamp global; @@ -21,10 +25,12 @@ function _timestampAtMost(Timestamp a, Timestamp b) pure returns (bool) { } library Timestamps { + /// Returns the current block timestamp converted to a Timestamp type function currentTime() internal view returns (Timestamp) { return Timestamp.wrap(uint40(block.timestamp)); } + /// Calculates the duration from start until end function until( Timestamp start, Timestamp end diff --git a/contracts/vault/TokenFlows.sol b/contracts/vault/TokenFlows.sol index 96aa585..d62c38d 100644 --- a/contracts/vault/TokenFlows.sol +++ b/contracts/vault/TokenFlows.sol @@ -3,6 +3,9 @@ pragma solidity 0.8.28; import "./Timestamps.sol"; +/// Represents a flow of tokens. Uses a uint96 to represent the flow rate, which +/// should be more than enough. Given a standard 18 decimal places for the +/// ERC20 token, this still allows for a rate of 10^10 whole coins per second. type TokensPerSecond is uint96; using {_tokensPerSecondMinus as -} for TokensPerSecond global; @@ -41,6 +44,8 @@ function _tokensPerSecondAtMost( } library TokenFlows { + /// Calculates how many tokens are accumulated when a token flow is maintained + /// for a duration of time. function accumulate( TokensPerSecond rate, Duration duration diff --git a/contracts/vault/VaultBase.sol b/contracts/vault/VaultBase.sol index 44e1e9a..3c639a8 100644 --- a/contracts/vault/VaultBase.sol +++ b/contracts/vault/VaultBase.sol @@ -6,6 +6,36 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "./Accounts.sol"; import "./Locks.sol"; +/// Records account balances and token flows. Accounts are separated into funds. +/// Funds are kept separate between controllers. +/// +/// A fund can only be manipulated by a controller when it is locked. Tokens can +/// only be withdrawn when a fund is unlocked. +/// +/// The vault maintains a number of invariants to ensure its integrity. +/// +/// The lock invariant ensures that there is a maximum time that a fund can be +/// locked: +/// +/// (∀ controller ∈ Controller, fund ∈ Fund: +/// lock.expiry <= lock.maximum +/// where lock = _locks[controller][fund]) +/// +/// The account invariant ensures that the outgoing token flow can be sustained +/// for the maximum time that a fund can be locked: +/// +/// (∀ controller ∈ Controller, fund ∈ Fund, recipient ∈ Recipient: +/// account.isSolventAt(lock.maximum) +/// where account = _accounts[controller][fund][recipient] +/// and lock = _locks[controller][fund]) +/// +/// The flow invariant ensures that incoming and outgoing flow rates match: +/// +/// (∀ controller ∈ Controller, fund ∈ Fund: +/// (∑ recipient ∈ Recipient: accounts[recipient].flow.incoming) = +/// (∑ recipient ∈ Recipient: accounts[recipient].flow.outgoing) +/// where accounts = _accounts[controller][fund]) +/// abstract contract VaultBase { using SafeERC20 for IERC20; using Accounts for Account; @@ -13,11 +43,16 @@ abstract contract VaultBase { IERC20 internal immutable _token; + /// Represents a smart contract that can redistribute and burn tokens in funds type Controller is address; + /// Unique identifier for a fund, chosen by the controller type Fund is bytes32; + /// Receives the balance of an account when withdrawing type Recipient is address; + /// Each fund has its own time lock mapping(Controller => mapping(Fund => Lock)) private _locks; + /// Each recipient has its own account in a fund mapping(Controller => mapping(Fund => mapping(Recipient => Account))) private _accounts;