vault: add documentation

This commit is contained in:
Mark Spanbroek 2025-02-11 13:52:08 +01:00
parent 439d3772db
commit 43cd44330b
6 changed files with 176 additions and 0 deletions

View File

@ -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);

View File

@ -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) {

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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;