mirror of
https://github.com/logos-storage/logos-storage-contracts-eth.git
synced 2026-01-02 13:23:10 +00:00
Vault (#220)
* vault: deposit and withdraw * vault: change data structure to be recipient oriented * vault: burning funds * vault: transfer tokens from one recipient to the other * vault: designate tokens for a single recipient * vault: lock up tokens until expiry time * vault: lock is deleted upon withdrawal * vault: simplify test setup * vault: remove duplication in tests * vault: further test for locks * vault: allow recipient to withdraw * vault: flow tokens from one recipient to the other * vault: designate tokens that flow * vault: move flow accumulation calculation into VaultBase * vault: use custom operators to improve readability * vault: stop flowing when lock expires * vault: reject flow when insufficient tokens available * vault: do not allow flow when lock already expired * vault: allow automine to be disabled in time sensitive tests * vault: improve naming of public functions * vault: flow to multiple recipients - changes balance from uint256 -> uint128 so that entire Balance can be read or written with a single operation - moves Lock to library - simplifies lock checks * vault: reject negative flows * vault: make tests a bit more robust * vault: change flows over time * vault: check Lock invariant before writing * vault: allow flows to be diverted to others * vault: simplify example flow rates in test * vault: disallow transfer of flowing tokens * vault: cannot burn flowing tokens * vault: delete flow when burning or withdrawing * vault: fix flaky time sensitive tests Ensures that setting of lock and starting of flow happen in the same block. Therefore hardhat cannot occasionally increase the timestamp between the two operations. This makes predicting the balances over time much easier. * vault: disallow designating of flowing tokens * vault: document setAutomine() * vault: delete lock all tokens are withdrawn or burned * vault: cleanup * vault: reorder tests * vault: only allow deposit, transfer, etc when locked * vault: reorder functions in roughly chronological order * vault: rename context -> fund * vault: rename balance -> account * vault: combine account and flow mappings * vault: _getAccount updates to the latest timestamp * vault: simplify _getAccount() * vault: reordering * vault: formatting * vault: do not delete lock when burning * vault: combine Account and Flow structs * vault: cleanup * vault: split flow into incoming and outgoing - no need to deal with signed integers anymore - allows flow to self to designate tokens over time * vault: fix transfer to self * vault: remove _getAccount() - no longer calculate flow updates when not needed - use account.update(timestamp) where needed - use _getBalance() to view current balance * vault: rename error * vault: reduce size of timestamp further * vault: prevent approval hijacking - transfer ERC20 funds into the vault from the controller, not from the user - prevents an attacker from hijacking a user's ERC20 approval to move tokens into a part of the vault that is controlled by the attacker * vault: extract common tests for unlocked funds * vault: burn entire fund * vault: transfer tokens to 0xdead when fund is burned * vault: do not expose Lock internals on public api * vault: formatting * vault: test lock state transitions * vault: clean up errors * vault: rename burn -> burnAccount, burnAll -> burnFund * vault: burn part of designated tokens * vault: burn designated/fund allowed when flowing * vault: prefix errors with 'Vault' * vault: cleanup * vault: remove dead code * vault: add documentation * vault: fix accounting of locked value when burning designated tokens * vault: update documentation * update openzeppelin contracts to 5.2.0 * vault: format all solidity files * vault: cleanup tests * vault: pausing and unpausing * vault: rename account->holder in tests * vault: allow for multiple accounts for one account holder * vault: only allow account holder to withdraw for itself * vault: freezeFund() instead of burnFund() * vault: rename Fund -> FundId * vault: rename lock states - NoLock -> Inactive - Unlocked -> Withdrawing * vault: rename Lock -> Fund * vault: clarification Co-Authored-by: Adam Uhlíř <adam@uhlir.dev> * vault: rename update() -> accumulateFlows() Reason: update() is too generic, and can easily be interpreted as changing the on-chain state, whereas it actually updates the in-memory struct. Co-Authored-By: Eric <5089238+emizzle@users.noreply.github.com> Co-Authored-By: Adam Uhlíř <adam@uhlir.dev> * vault: rephrase Co-Authored-By: Adam Uhlíř <adam@uhlir.dev> --------- Co-authored-by: Adam Uhlíř <adam@uhlir.dev> Co-authored-by: Eric <5089238+emizzle@users.noreply.github.com>
This commit is contained in:
parent
0bf138512b
commit
e49abc4104
248
contracts/Vault.sol
Normal file
248
contracts/Vault.sol
Normal file
@ -0,0 +1,248 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.28;
|
||||
|
||||
import "@openzeppelin/contracts/access/Ownable.sol";
|
||||
import "@openzeppelin/contracts/utils/Pausable.sol";
|
||||
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 accounts.
|
||||
///
|
||||
/// 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, although they can be
|
||||
/// burned.
|
||||
/// Accounts are identified by the address of the account holder, and an id that
|
||||
/// can be used to create different accounts for the same holder.
|
||||
///
|
||||
/// 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 an account holder,
|
||||
/// or the account holder 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 account holders
|
||||
/// 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, Pausable, Ownable {
|
||||
constructor(IERC20 token) VaultBase(token) Ownable(msg.sender) {}
|
||||
|
||||
/// Creates an account id that encodes the address of the account holder, and
|
||||
/// a discriminator. The discriminator can be used to create different
|
||||
/// accounts within a fund that all belong to the same account holder.
|
||||
function encodeAccountId(
|
||||
address holder,
|
||||
bytes12 discriminator
|
||||
) public pure returns (AccountId) {
|
||||
return Accounts.encodeId(holder, discriminator);
|
||||
}
|
||||
|
||||
/// Extracts the address of the account holder and the discriminator from the
|
||||
/// account id.
|
||||
function decodeAccountId(
|
||||
AccountId id
|
||||
) public pure returns (address holder, bytes12 discriminator) {
|
||||
return Accounts.decodeId(id);
|
||||
}
|
||||
|
||||
/// The amount of tokens that are currently in an account.
|
||||
/// This includes available and designated tokens. Available tokens can be
|
||||
/// transfered to other accounts, but designated tokens cannot.
|
||||
function getBalance(
|
||||
FundId fundId,
|
||||
AccountId accountId
|
||||
) public view returns (uint128) {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
Balance memory balance = _getBalance(controller, fundId, accountId);
|
||||
return balance.available + balance.designated;
|
||||
}
|
||||
|
||||
/// The amount of tokens that are currently designated in an account
|
||||
/// These tokens can no longer be transfered to other accounts.
|
||||
function getDesignatedBalance(
|
||||
FundId fundId,
|
||||
AccountId accountId
|
||||
) public view returns (uint128) {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
Balance memory balance = _getBalance(controller, fundId, accountId);
|
||||
return balance.designated;
|
||||
}
|
||||
|
||||
/// Returns the status of the fund. Most operations on the vault can only be
|
||||
/// done by the controller when the funds are locked. Withdrawals can only be
|
||||
/// done in the withdrawing state.
|
||||
function getFundStatus(FundId fundId) public view returns (FundStatus) {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
return _getFundStatus(controller, fundId);
|
||||
}
|
||||
|
||||
/// Returns the expiry time of the lock on the fund. A locked fund unlocks
|
||||
/// automatically at this timestamp.
|
||||
function getLockExpiry(FundId fundId) public view returns (Timestamp) {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
return _getLockExpiry(controller, fundId);
|
||||
}
|
||||
|
||||
/// Locks the fund until the expiry timestamp. The lock expiry can be extended
|
||||
/// later, but no more than the maximum timestamp.
|
||||
function lock(
|
||||
FundId fundId,
|
||||
Timestamp expiry,
|
||||
Timestamp maximum
|
||||
) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_lock(controller, fundId, 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.
|
||||
/// Only allowed when the lock has not unlocked yet.
|
||||
function extendLock(FundId fundId, Timestamp expiry) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_extendLock(controller, fundId, expiry);
|
||||
}
|
||||
|
||||
/// Deposits an amount of tokens into the vault, and adds them to the balance
|
||||
/// of the account. ERC20 tokens are transfered from the caller to the vault
|
||||
/// contract.
|
||||
/// Only allowed when the fund is locked.
|
||||
function deposit(
|
||||
FundId fundId,
|
||||
AccountId accountId,
|
||||
uint128 amount
|
||||
) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_deposit(controller, fundId, accountId, amount);
|
||||
}
|
||||
|
||||
/// Takes an amount of tokens from the account balance and designates them
|
||||
/// for the account holder. These tokens are no longer available to be
|
||||
/// transfered to other accounts.
|
||||
/// Only allowed when the fund is locked.
|
||||
function designate(
|
||||
FundId fundId,
|
||||
AccountId accountId,
|
||||
uint128 amount
|
||||
) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_designate(controller, fundId, accountId, amount);
|
||||
}
|
||||
|
||||
/// Transfers an amount of tokens from one account to the other.
|
||||
/// Only allowed when the fund is locked.
|
||||
function transfer(
|
||||
FundId fundId,
|
||||
AccountId from,
|
||||
AccountId to,
|
||||
uint128 amount
|
||||
) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_transfer(controller, fundId, from, to, amount);
|
||||
}
|
||||
|
||||
/// Transfers tokens from one account the other over time.
|
||||
/// Every second a number of tokens are transfered, until the fund is
|
||||
/// unlocked. After flowing into an account, these tokens become designated
|
||||
/// tokens, so they cannot be transfered again.
|
||||
/// 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(
|
||||
FundId fundId,
|
||||
AccountId from,
|
||||
AccountId to,
|
||||
TokensPerSecond rate
|
||||
) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_flow(controller, fundId, from, to, rate);
|
||||
}
|
||||
|
||||
/// Burns an amount of designated tokens from the account.
|
||||
/// Only allowed when the fund is locked.
|
||||
function burnDesignated(
|
||||
FundId fundId,
|
||||
AccountId accountId,
|
||||
uint128 amount
|
||||
) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_burnDesignated(controller, fundId, accountId, amount);
|
||||
}
|
||||
|
||||
/// Burns all tokens from the account.
|
||||
/// Only allowed when the fund is locked.
|
||||
/// Only allowed when no funds are flowing into or out of the account.
|
||||
function burnAccount(
|
||||
FundId fundId,
|
||||
AccountId accountId
|
||||
) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_burnAccount(controller, fundId, accountId);
|
||||
}
|
||||
|
||||
/// Freezes a fund. Stops all tokens flows and disallows any operations on the
|
||||
/// fund until it unlocks.
|
||||
/// Only allowed when the fund is locked.
|
||||
function freezeFund(FundId fundId) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_freezeFund(controller, fundId);
|
||||
}
|
||||
|
||||
/// Transfers all ERC20 tokens in the account out of the vault to the account
|
||||
/// owner.
|
||||
/// Only allowed when the fund is unlocked.
|
||||
/// ⚠️ The account holder 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(FundId fund, AccountId accountId) public whenNotPaused {
|
||||
Controller controller = Controller.wrap(msg.sender);
|
||||
_withdraw(controller, fund, accountId);
|
||||
}
|
||||
|
||||
/// Allows an account holder 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,
|
||||
FundId fund,
|
||||
AccountId accountId
|
||||
) public {
|
||||
(address holder, ) = Accounts.decodeId(accountId);
|
||||
require(msg.sender == holder, VaultOnlyAccountHolder());
|
||||
_withdraw(controller, fund, accountId);
|
||||
}
|
||||
|
||||
function pause() public onlyOwner {
|
||||
_pause();
|
||||
}
|
||||
|
||||
function unpause() public onlyOwner {
|
||||
_unpause();
|
||||
}
|
||||
|
||||
error VaultOnlyAccountHolder();
|
||||
}
|
||||
110
contracts/vault/Accounts.sol
Normal file
110
contracts/vault/Accounts.sol
Normal file
@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.28;
|
||||
|
||||
import "./TokenFlows.sol";
|
||||
import "./Timestamps.sol";
|
||||
|
||||
/// Used to identify an account. The first 20 bytes consist of the address of
|
||||
/// the account holder, and the last 12 bytes consist of a discriminator value.
|
||||
type AccountId is bytes32;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
library Accounts {
|
||||
using Accounts for Account;
|
||||
using TokenFlows for TokensPerSecond;
|
||||
using Timestamps for Timestamp;
|
||||
|
||||
/// Creates an account id from the account holder address and a discriminator.
|
||||
/// The discriminiator can be used to create different accounts that belong to
|
||||
/// the same account holder.
|
||||
function encodeId(
|
||||
address holder,
|
||||
bytes12 discriminator
|
||||
) internal pure returns (AccountId) {
|
||||
bytes32 left = bytes32(bytes20(holder));
|
||||
bytes32 right = bytes32(uint256(uint96(discriminator)));
|
||||
return AccountId.wrap(left | right);
|
||||
}
|
||||
|
||||
/// Extracts the account holder and the discriminator from the the account id
|
||||
function decodeId(AccountId id) internal pure returns (address, bytes12) {
|
||||
bytes32 unwrapped = AccountId.unwrap(id);
|
||||
address holder = address(bytes20(unwrapped));
|
||||
bytes12 discriminator = bytes12(uint96(uint256(unwrapped)));
|
||||
return (holder, discriminator);
|
||||
}
|
||||
|
||||
/// 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
|
||||
) internal pure returns (bool) {
|
||||
Duration duration = account.flow.updated.until(timestamp);
|
||||
uint128 outgoing = account.flow.outgoing.accumulate(duration);
|
||||
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 accumulateFlows(
|
||||
Account memory account,
|
||||
Timestamp timestamp
|
||||
) internal pure {
|
||||
Duration duration = account.flow.updated.until(timestamp);
|
||||
account.balance.available -= account.flow.outgoing.accumulate(duration);
|
||||
account.balance.designated += account.flow.incoming.accumulate(duration);
|
||||
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.accumulateFlows(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.accumulateFlows(Timestamps.currentTime());
|
||||
if (rate <= account.flow.incoming) {
|
||||
account.flow.incoming = account.flow.incoming - rate;
|
||||
} else {
|
||||
account.flow.outgoing = account.flow.outgoing + rate;
|
||||
account.flow.outgoing = account.flow.outgoing - account.flow.incoming;
|
||||
account.flow.incoming = TokensPerSecond.wrap(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
62
contracts/vault/Funds.sol
Normal file
62
contracts/vault/Funds.sol
Normal file
@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.28;
|
||||
|
||||
import "./Timestamps.sol";
|
||||
|
||||
struct Fund {
|
||||
/// The time-lock unlocks at this time
|
||||
Timestamp lockExpiry;
|
||||
/// The lock expiry can be extended no further than this
|
||||
Timestamp lockMaximum;
|
||||
/// Indicates whether fund is frozen, and at what time
|
||||
Timestamp frozenAt;
|
||||
/// The total amount of tokens in the fund
|
||||
uint128 value;
|
||||
}
|
||||
|
||||
/// A fund can go through the following states:
|
||||
///
|
||||
/// -----------------------------------------------
|
||||
/// | |
|
||||
/// --> Inactive ---> Locked -----> Withdrawing --
|
||||
/// \ ^
|
||||
/// \ /
|
||||
/// --> Frozen --
|
||||
///
|
||||
enum FundStatus {
|
||||
/// Indicates that the fund is inactive and contains no tokens. This is the
|
||||
/// initial state, or the state after all tokens have been withdrawn.
|
||||
Inactive,
|
||||
/// Indicates that a time-lock is set and withdrawing tokens is not allowed. A
|
||||
/// fund needs to be locked for deposits, transfers, flows and burning to be
|
||||
/// allowed.
|
||||
Locked,
|
||||
/// Indicates that a locked fund is frozen. Flows have stopped, nothing is
|
||||
/// allowed until the fund unlocks.
|
||||
Frozen,
|
||||
/// Indicates the fund has unlocked and withdrawing is allowed. Other
|
||||
/// operations are no longer allowed.
|
||||
Withdrawing
|
||||
}
|
||||
|
||||
library Funds {
|
||||
function status(Fund memory fund) internal view returns (FundStatus) {
|
||||
if (Timestamps.currentTime() < fund.lockExpiry) {
|
||||
if (fund.frozenAt != Timestamp.wrap(0)) {
|
||||
return FundStatus.Frozen;
|
||||
}
|
||||
return FundStatus.Locked;
|
||||
}
|
||||
if (fund.lockMaximum == Timestamp.wrap(0)) {
|
||||
return FundStatus.Inactive;
|
||||
}
|
||||
return FundStatus.Withdrawing;
|
||||
}
|
||||
|
||||
function flowEnd(Fund memory fund) internal pure returns (Timestamp) {
|
||||
if (fund.frozenAt != Timestamp.wrap(0)) {
|
||||
return fund.frozenAt;
|
||||
}
|
||||
return fund.lockExpiry;
|
||||
}
|
||||
}
|
||||
45
contracts/vault/Timestamps.sol
Normal file
45
contracts/vault/Timestamps.sol
Normal file
@ -0,0 +1,45 @@
|
||||
// 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;
|
||||
using {_timestampNotEqual as !=} for Timestamp global;
|
||||
using {_timestampLessThan as <} for Timestamp global;
|
||||
using {_timestampAtMost as <=} for Timestamp global;
|
||||
|
||||
function _timestampEquals(Timestamp a, Timestamp b) pure returns (bool) {
|
||||
return Timestamp.unwrap(a) == Timestamp.unwrap(b);
|
||||
}
|
||||
|
||||
function _timestampNotEqual(Timestamp a, Timestamp b) pure returns (bool) {
|
||||
return Timestamp.unwrap(a) != Timestamp.unwrap(b);
|
||||
}
|
||||
|
||||
function _timestampLessThan(Timestamp a, Timestamp b) pure returns (bool) {
|
||||
return Timestamp.unwrap(a) < Timestamp.unwrap(b);
|
||||
}
|
||||
|
||||
function _timestampAtMost(Timestamp a, Timestamp b) pure returns (bool) {
|
||||
return Timestamp.unwrap(a) <= Timestamp.unwrap(b);
|
||||
}
|
||||
|
||||
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
|
||||
) internal pure returns (Duration) {
|
||||
return Duration.wrap(Timestamp.unwrap(end) - Timestamp.unwrap(start));
|
||||
}
|
||||
}
|
||||
55
contracts/vault/TokenFlows.sol
Normal file
55
contracts/vault/TokenFlows.sol
Normal file
@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
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;
|
||||
using {_tokensPerSecondPlus as +} for TokensPerSecond global;
|
||||
using {_tokensPerSecondEquals as ==} for TokensPerSecond global;
|
||||
using {_tokensPerSecondAtMost as <=} for TokensPerSecond global;
|
||||
|
||||
function _tokensPerSecondMinus(
|
||||
TokensPerSecond a,
|
||||
TokensPerSecond b
|
||||
) pure returns (TokensPerSecond) {
|
||||
return
|
||||
TokensPerSecond.wrap(TokensPerSecond.unwrap(a) - TokensPerSecond.unwrap(b));
|
||||
}
|
||||
|
||||
function _tokensPerSecondPlus(
|
||||
TokensPerSecond a,
|
||||
TokensPerSecond b
|
||||
) pure returns (TokensPerSecond) {
|
||||
return
|
||||
TokensPerSecond.wrap(TokensPerSecond.unwrap(a) + TokensPerSecond.unwrap(b));
|
||||
}
|
||||
|
||||
function _tokensPerSecondEquals(
|
||||
TokensPerSecond a,
|
||||
TokensPerSecond b
|
||||
) pure returns (bool) {
|
||||
return TokensPerSecond.unwrap(a) == TokensPerSecond.unwrap(b);
|
||||
}
|
||||
|
||||
function _tokensPerSecondAtMost(
|
||||
TokensPerSecond a,
|
||||
TokensPerSecond b
|
||||
) pure returns (bool) {
|
||||
return TokensPerSecond.unwrap(a) <= TokensPerSecond.unwrap(b);
|
||||
}
|
||||
|
||||
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
|
||||
) internal pure returns (uint128) {
|
||||
return uint128(TokensPerSecond.unwrap(rate)) * Duration.unwrap(duration);
|
||||
}
|
||||
}
|
||||
292
contracts/vault/VaultBase.sol
Normal file
292
contracts/vault/VaultBase.sol
Normal file
@ -0,0 +1,292 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.28;
|
||||
|
||||
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
||||
import "./Accounts.sol";
|
||||
import "./Funds.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, fundId ∈ FundId:
|
||||
/// fund.lockExpiry <= fund.lockMaximum
|
||||
/// where fund = _funds[controller][fundId])
|
||||
///
|
||||
/// The account invariant ensures that the outgoing token flow can be sustained
|
||||
/// for the maximum time that a fund can be locked:
|
||||
///
|
||||
/// (∀ controller ∈ Controller, fundId ∈ FundId, accountId ∈ AccountId:
|
||||
/// flow.outgoing * (fund.lockMaximum - flow.updated) <= balance.available
|
||||
/// where fund = _funds[controller][fundId])
|
||||
/// and flow = _accounts[controller][fundId][accountId].flow
|
||||
/// and balance = _accounts[controller][fundId][accountId].balance
|
||||
///
|
||||
/// The flow invariant ensures that incoming and outgoing flow rates match:
|
||||
///
|
||||
/// (∀ controller ∈ Controller, fundId ∈ FundId:
|
||||
/// (∑ accountId ∈ AccountId: accounts[accountId].flow.incoming) =
|
||||
/// (∑ accountId ∈ AccountId: accounts[accountId].flow.outgoing)
|
||||
/// where accounts = _accounts[controller][fundId])
|
||||
///
|
||||
abstract contract VaultBase {
|
||||
using SafeERC20 for IERC20;
|
||||
using Accounts for Account;
|
||||
using Funds for Fund;
|
||||
|
||||
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 FundId is bytes32;
|
||||
|
||||
/// Each controller has its own set of funds
|
||||
mapping(Controller => mapping(FundId => Fund)) private _funds;
|
||||
/// Each account holder has its own set of accounts in a fund
|
||||
mapping(Controller => mapping(FundId => mapping(AccountId => Account)))
|
||||
private _accounts;
|
||||
|
||||
constructor(IERC20 token) {
|
||||
_token = token;
|
||||
}
|
||||
|
||||
function _getFundStatus(
|
||||
Controller controller,
|
||||
FundId fundId
|
||||
) internal view returns (FundStatus) {
|
||||
return _funds[controller][fundId].status();
|
||||
}
|
||||
|
||||
function _getLockExpiry(
|
||||
Controller controller,
|
||||
FundId fundId
|
||||
) internal view returns (Timestamp) {
|
||||
return _funds[controller][fundId].lockExpiry;
|
||||
}
|
||||
|
||||
function _getBalance(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
AccountId accountId
|
||||
) internal view returns (Balance memory) {
|
||||
Fund memory fund = _funds[controller][fundId];
|
||||
FundStatus status = fund.status();
|
||||
if (status == FundStatus.Locked) {
|
||||
Account memory account = _accounts[controller][fundId][accountId];
|
||||
account.accumulateFlows(Timestamps.currentTime());
|
||||
return account.balance;
|
||||
}
|
||||
if (status == FundStatus.Withdrawing || status == FundStatus.Frozen) {
|
||||
Account memory account = _accounts[controller][fundId][accountId];
|
||||
account.accumulateFlows(fund.flowEnd());
|
||||
return account.balance;
|
||||
}
|
||||
return Balance({available: 0, designated: 0});
|
||||
}
|
||||
|
||||
function _lock(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
Timestamp expiry,
|
||||
Timestamp maximum
|
||||
) internal {
|
||||
Fund memory fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Inactive, VaultFundAlreadyLocked());
|
||||
fund.lockExpiry = expiry;
|
||||
fund.lockMaximum = maximum;
|
||||
_checkLockInvariant(fund);
|
||||
_funds[controller][fundId] = fund;
|
||||
}
|
||||
|
||||
function _extendLock(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
Timestamp expiry
|
||||
) internal {
|
||||
Fund memory fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Locked, VaultFundNotLocked());
|
||||
require(fund.lockExpiry <= expiry, VaultInvalidExpiry());
|
||||
fund.lockExpiry = expiry;
|
||||
_checkLockInvariant(fund);
|
||||
_funds[controller][fundId] = fund;
|
||||
}
|
||||
|
||||
function _deposit(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
AccountId accountId,
|
||||
uint128 amount
|
||||
) internal {
|
||||
Fund storage fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Locked, VaultFundNotLocked());
|
||||
|
||||
Account storage account = _accounts[controller][fundId][accountId];
|
||||
|
||||
account.balance.available += amount;
|
||||
fund.value += amount;
|
||||
|
||||
_token.safeTransferFrom(
|
||||
Controller.unwrap(controller),
|
||||
address(this),
|
||||
amount
|
||||
);
|
||||
}
|
||||
|
||||
function _designate(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
AccountId accountId,
|
||||
uint128 amount
|
||||
) internal {
|
||||
Fund memory fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Locked, VaultFundNotLocked());
|
||||
|
||||
Account memory account = _accounts[controller][fundId][accountId];
|
||||
require(amount <= account.balance.available, VaultInsufficientBalance());
|
||||
|
||||
account.balance.available -= amount;
|
||||
account.balance.designated += amount;
|
||||
_checkAccountInvariant(account, fund);
|
||||
|
||||
_accounts[controller][fundId][accountId] = account;
|
||||
}
|
||||
|
||||
function _transfer(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
AccountId from,
|
||||
AccountId to,
|
||||
uint128 amount
|
||||
) internal {
|
||||
Fund memory fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Locked, VaultFundNotLocked());
|
||||
|
||||
Account memory sender = _accounts[controller][fundId][from];
|
||||
require(amount <= sender.balance.available, VaultInsufficientBalance());
|
||||
|
||||
sender.balance.available -= amount;
|
||||
_checkAccountInvariant(sender, fund);
|
||||
|
||||
_accounts[controller][fundId][from] = sender;
|
||||
|
||||
_accounts[controller][fundId][to].balance.available += amount;
|
||||
}
|
||||
|
||||
function _flow(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
AccountId from,
|
||||
AccountId to,
|
||||
TokensPerSecond rate
|
||||
) internal {
|
||||
Fund memory fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Locked, VaultFundNotLocked());
|
||||
|
||||
Account memory sender = _accounts[controller][fundId][from];
|
||||
sender.flowOut(rate);
|
||||
_checkAccountInvariant(sender, fund);
|
||||
_accounts[controller][fundId][from] = sender;
|
||||
|
||||
Account memory receiver = _accounts[controller][fundId][to];
|
||||
receiver.flowIn(rate);
|
||||
_accounts[controller][fundId][to] = receiver;
|
||||
}
|
||||
|
||||
function _burnDesignated(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
AccountId accountId,
|
||||
uint128 amount
|
||||
) internal {
|
||||
Fund storage fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Locked, VaultFundNotLocked());
|
||||
|
||||
Account storage account = _accounts[controller][fundId][accountId];
|
||||
require(account.balance.designated >= amount, VaultInsufficientBalance());
|
||||
|
||||
account.balance.designated -= amount;
|
||||
|
||||
fund.value -= amount;
|
||||
|
||||
_token.safeTransfer(address(0xdead), amount);
|
||||
}
|
||||
|
||||
function _burnAccount(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
AccountId accountId
|
||||
) internal {
|
||||
Fund storage fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Locked, VaultFundNotLocked());
|
||||
|
||||
Account memory account = _accounts[controller][fundId][accountId];
|
||||
require(account.flow.incoming == account.flow.outgoing, VaultFlowNotZero());
|
||||
uint128 amount = account.balance.available + account.balance.designated;
|
||||
|
||||
fund.value -= amount;
|
||||
|
||||
delete _accounts[controller][fundId][accountId];
|
||||
|
||||
_token.safeTransfer(address(0xdead), amount);
|
||||
}
|
||||
|
||||
function _freezeFund(Controller controller, FundId fundId) internal {
|
||||
Fund storage fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Locked, VaultFundNotLocked());
|
||||
|
||||
fund.frozenAt = Timestamps.currentTime();
|
||||
}
|
||||
|
||||
function _withdraw(
|
||||
Controller controller,
|
||||
FundId fundId,
|
||||
AccountId accountId
|
||||
) internal {
|
||||
Fund memory fund = _funds[controller][fundId];
|
||||
require(fund.status() == FundStatus.Withdrawing, VaultFundNotUnlocked());
|
||||
|
||||
Account memory account = _accounts[controller][fundId][accountId];
|
||||
account.accumulateFlows(fund.flowEnd());
|
||||
uint128 amount = account.balance.available + account.balance.designated;
|
||||
|
||||
fund.value -= amount;
|
||||
|
||||
if (fund.value == 0) {
|
||||
delete _funds[controller][fundId];
|
||||
} else {
|
||||
_funds[controller][fundId] = fund;
|
||||
}
|
||||
|
||||
delete _accounts[controller][fundId][accountId];
|
||||
|
||||
(address owner, ) = Accounts.decodeId(accountId);
|
||||
_token.safeTransfer(owner, amount);
|
||||
}
|
||||
|
||||
function _checkLockInvariant(Fund memory fund) private pure {
|
||||
require(fund.lockExpiry <= fund.lockMaximum, VaultInvalidExpiry());
|
||||
}
|
||||
|
||||
function _checkAccountInvariant(
|
||||
Account memory account,
|
||||
Fund memory fund
|
||||
) private pure {
|
||||
require(account.isSolventAt(fund.lockMaximum), VaultInsufficientBalance());
|
||||
}
|
||||
|
||||
error VaultInsufficientBalance();
|
||||
error VaultInvalidExpiry();
|
||||
error VaultFundNotLocked();
|
||||
error VaultFundNotUnlocked();
|
||||
error VaultFundAlreadyLocked();
|
||||
error VaultFlowNotZero();
|
||||
}
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -9,7 +9,7 @@
|
||||
"devDependencies": {
|
||||
"@nomiclabs/hardhat-ethers": "^2.2.1",
|
||||
"@nomiclabs/hardhat-waffle": "^2.0.3",
|
||||
"@openzeppelin/contracts": "^4.8.0",
|
||||
"@openzeppelin/contracts": "^5.2.0",
|
||||
"@stdlib/stats-binomial-test": "^0.0.7",
|
||||
"chai": "^4.3.7",
|
||||
"ethereum-waffle": "^3.4.4",
|
||||
@ -1764,9 +1764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openzeppelin/contracts": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.8.0.tgz",
|
||||
"integrity": "sha512-AGuwhRRL+NaKx73WKRNzeCxOCOCxpaqF+kp8TJ89QzAipSwZy/NoflkWaL9bywXFRhIzXt8j38sfF7KBKCPWLw==",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.2.0.tgz",
|
||||
"integrity": "sha512-bxjNie5z89W1Ea0NZLZluFh8PrFNn9DH8DQlujEok2yjsOlraUPKID5p1Wk3qdNbf6XkQ1Os2RvfiHrrXLHWKA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@pnpm/config.env-replace": {
|
||||
@ -24578,9 +24578,9 @@
|
||||
}
|
||||
},
|
||||
"@openzeppelin/contracts": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.8.0.tgz",
|
||||
"integrity": "sha512-AGuwhRRL+NaKx73WKRNzeCxOCOCxpaqF+kp8TJ89QzAipSwZy/NoflkWaL9bywXFRhIzXt8j38sfF7KBKCPWLw==",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.2.0.tgz",
|
||||
"integrity": "sha512-bxjNie5z89W1Ea0NZLZluFh8PrFNn9DH8DQlujEok2yjsOlraUPKID5p1Wk3qdNbf6XkQ1Os2RvfiHrrXLHWKA==",
|
||||
"dev": true
|
||||
},
|
||||
"@pnpm/config.env-replace": {
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
"fuzz": "hardhat compile && fuzzing/fuzz.sh",
|
||||
"start": "hardhat node --export deployment-localhost.json",
|
||||
"compile": "hardhat compile",
|
||||
"format": "prettier --write contracts/**/*.sol test/**/*.js",
|
||||
"format:check": "prettier --check contracts/**/*.sol test/**/*.js",
|
||||
"format": "prettier --write contracts/*.sol contracts/**/*.sol test/**/*.js",
|
||||
"format:check": "prettier --check contracts/*.sol contracts/**/*.sol test/**/*.js",
|
||||
"lint": "solhint contracts/**.sol",
|
||||
"deploy": "hardhat deploy",
|
||||
"verify": "npm run verify:marketplace && npm run verify:state_changes",
|
||||
@ -17,7 +17,7 @@
|
||||
"devDependencies": {
|
||||
"@nomiclabs/hardhat-ethers": "^2.2.1",
|
||||
"@nomiclabs/hardhat-waffle": "^2.0.3",
|
||||
"@openzeppelin/contracts": "^4.8.0",
|
||||
"@openzeppelin/contracts": "^5.2.0",
|
||||
"@stdlib/stats-binomial-test": "^0.0.7",
|
||||
"chai": "^4.3.7",
|
||||
"ethereum-waffle": "^3.4.4",
|
||||
|
||||
@ -215,7 +215,7 @@ describe("Marketplace", function () {
|
||||
let insufficient = maxPrice(request) - 1
|
||||
await token.approve(marketplace.address, insufficient)
|
||||
await expect(marketplace.requestStorage(request)).to.be.revertedWith(
|
||||
"ERC20: insufficient allowance"
|
||||
"ERC20InsufficientAllowance"
|
||||
)
|
||||
})
|
||||
|
||||
@ -455,7 +455,7 @@ describe("Marketplace", function () {
|
||||
await marketplace.reserveSlot(slot.request, slot.index)
|
||||
await expect(
|
||||
marketplace.fillSlot(slot.request, slot.index, proof)
|
||||
).to.be.revertedWith("ERC20: insufficient allowance")
|
||||
).to.be.revertedWith("ERC20InsufficientAllowance")
|
||||
})
|
||||
|
||||
it("collects only requested collateral and not more", async function () {
|
||||
|
||||
1145
test/Vault.tests.js
Normal file
1145
test/Vault.tests.js
Normal file
File diff suppressed because it is too large
Load Diff
16
test/evm.js
16
test/evm.js
@ -5,13 +5,24 @@ let snapshots = []
|
||||
async function snapshot() {
|
||||
const id = await ethers.provider.send("evm_snapshot")
|
||||
const time = await currentTime()
|
||||
snapshots.push({ id, time })
|
||||
const automine = await ethers.provider.send("hardhat_getAutomine")
|
||||
snapshots.push({ id, time, automine })
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
const { id, time } = snapshots.pop()
|
||||
const { id, time, automine } = snapshots.pop()
|
||||
await ethers.provider.send("evm_revert", [id])
|
||||
await ethers.provider.send("evm_setNextBlockTimestamp", [time])
|
||||
await ethers.provider.send("evm_setAutomine", [automine])
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables Hardhat's automine mode.
|
||||
*
|
||||
* When automine mode is disabled, transactions that revert are silently ignored!
|
||||
*/
|
||||
async function setAutomine(enabled) {
|
||||
await ethers.provider.send("evm_setAutomine", [enabled])
|
||||
}
|
||||
|
||||
async function mine() {
|
||||
@ -46,6 +57,7 @@ async function setNextBlockTimestamp(timestamp) {
|
||||
module.exports = {
|
||||
snapshot,
|
||||
revert,
|
||||
setAutomine,
|
||||
mine,
|
||||
ensureMinimumBlockHeight,
|
||||
currentTime,
|
||||
|
||||
8
test/vault.js
Normal file
8
test/vault.js
Normal file
@ -0,0 +1,8 @@
|
||||
const FundStatus = {
|
||||
Inactive: 0,
|
||||
Locked: 1,
|
||||
Frozen: 2,
|
||||
Withdrawing: 3,
|
||||
}
|
||||
|
||||
module.exports = { FundStatus }
|
||||
Loading…
x
Reference in New Issue
Block a user