From e49abc410477fcdeeac7e76fe8271a3d8e4235d3 Mon Sep 17 00:00:00 2001 From: markspanbroek Date: Wed, 16 Apr 2025 11:57:07 +0200 Subject: [PATCH] Vault (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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íř * 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íř * vault: rephrase Co-Authored-By: Adam Uhlíř --------- Co-authored-by: Adam Uhlíř Co-authored-by: Eric <5089238+emizzle@users.noreply.github.com> --- contracts/Vault.sol | 248 +++++++ contracts/vault/Accounts.sol | 110 +++ contracts/vault/Funds.sol | 62 ++ contracts/vault/Timestamps.sol | 45 ++ contracts/vault/TokenFlows.sol | 55 ++ contracts/vault/VaultBase.sol | 292 ++++++++ package-lock.json | 14 +- package.json | 6 +- test/Marketplace.test.js | 4 +- test/Vault.tests.js | 1145 ++++++++++++++++++++++++++++++++ test/evm.js | 16 +- test/vault.js | 8 + 12 files changed, 1991 insertions(+), 14 deletions(-) create mode 100644 contracts/Vault.sol create mode 100644 contracts/vault/Accounts.sol create mode 100644 contracts/vault/Funds.sol create mode 100644 contracts/vault/Timestamps.sol create mode 100644 contracts/vault/TokenFlows.sol create mode 100644 contracts/vault/VaultBase.sol create mode 100644 test/Vault.tests.js create mode 100644 test/vault.js diff --git a/contracts/Vault.sol b/contracts/Vault.sol new file mode 100644 index 0000000..63667b8 --- /dev/null +++ b/contracts/Vault.sol @@ -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(); +} diff --git a/contracts/vault/Accounts.sol b/contracts/vault/Accounts.sol new file mode 100644 index 0000000..24aff9f --- /dev/null +++ b/contracts/vault/Accounts.sol @@ -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); + } + } +} diff --git a/contracts/vault/Funds.sol b/contracts/vault/Funds.sol new file mode 100644 index 0000000..06bbfa8 --- /dev/null +++ b/contracts/vault/Funds.sol @@ -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; + } +} diff --git a/contracts/vault/Timestamps.sol b/contracts/vault/Timestamps.sol new file mode 100644 index 0000000..dc63fa4 --- /dev/null +++ b/contracts/vault/Timestamps.sol @@ -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)); + } +} diff --git a/contracts/vault/TokenFlows.sol b/contracts/vault/TokenFlows.sol new file mode 100644 index 0000000..d62c38d --- /dev/null +++ b/contracts/vault/TokenFlows.sol @@ -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); + } +} diff --git a/contracts/vault/VaultBase.sol b/contracts/vault/VaultBase.sol new file mode 100644 index 0000000..06df5b5 --- /dev/null +++ b/contracts/vault/VaultBase.sol @@ -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(); +} diff --git a/package-lock.json b/package-lock.json index 702aea9..1b83a12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 062aa3b..301f338 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/Marketplace.test.js b/test/Marketplace.test.js index f465c53..a6ab152 100644 --- a/test/Marketplace.test.js +++ b/test/Marketplace.test.js @@ -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 () { diff --git a/test/Vault.tests.js b/test/Vault.tests.js new file mode 100644 index 0000000..5043ddf --- /dev/null +++ b/test/Vault.tests.js @@ -0,0 +1,1145 @@ +const { expect } = require("chai") +const { ethers } = require("hardhat") +const { randomBytes, hexlify } = ethers.utils +const { + currentTime, + advanceTimeTo, + mine, + setAutomine, + setNextBlockTimestamp, + snapshot, + revert, +} = require("./evm") +const { FundStatus } = require("./vault") + +describe("Vault", function () { + const fund = randomBytes(32) + + let token + let vault + let controller + let holder, holder2, holder3 + + beforeEach(async function () { + await snapshot() + const TestToken = await ethers.getContractFactory("TestToken") + token = await TestToken.deploy() + const Vault = await ethers.getContractFactory("Vault") + vault = await Vault.deploy(token.address) + ;[controller, holder, holder2, holder3] = await ethers.getSigners() + await token.mint(controller.address, 1_000_000) + }) + + afterEach(async function () { + await revert() + }) + + describe("account ids", function () { + let address + let discriminator + + beforeEach(async function () { + address = holder.address + discriminator = hexlify(randomBytes(12)) + }) + + it("encodes the account holder and a discriminator in an account id", async function () { + const account = await vault.encodeAccountId(address, discriminator) + const decoded = await vault.decodeAccountId(account) + expect(decoded[0]).to.equal(address) + expect(decoded[1]).to.equal(discriminator) + }) + }) + + describe("when a fund has no lock set", function () { + let account + + beforeEach(async function () { + account = await vault.encodeAccountId(holder.address, randomBytes(12)) + }) + + it("does not have any balances", async function () { + const balance = await vault.getBalance(fund, account) + const designated = await vault.getDesignatedBalance(fund, account) + expect(balance).to.equal(0) + expect(designated).to.equal(0) + }) + + it("allows a lock to be set", async function () { + const expiry = (await currentTime()) + 80 + const maximum = (await currentTime()) + 100 + await vault.lock(fund, expiry, maximum) + expect(await vault.getFundStatus(fund)).to.equal(FundStatus.Locked) + expect(await vault.getLockExpiry(fund)).to.equal(expiry) + }) + + it("does not allow a lock with expiry past maximum", async function () { + let maximum = (await currentTime()) + 100 + const locking = vault.lock(fund, maximum + 1, maximum) + await expect(locking).to.be.revertedWith("InvalidExpiry") + }) + + describe("fund is not locked", function () { + testFundThatIsNotLocked() + }) + }) + + describe("when a fund is locked", function () { + let expiry + let maximum + let account + + beforeEach(async function () { + const beginning = (await currentTime()) + 10 + expiry = beginning + 80 + maximum = beginning + 100 + account = await vault.encodeAccountId(holder.address, randomBytes(12)) + await setAutomine(false) + await setNextBlockTimestamp(beginning) + await vault.lock(fund, expiry, maximum) + }) + + describe("locking", function () { + beforeEach(async function () { + await setAutomine(true) + }) + + it("cannot set lock when already locked", async function () { + await expect(vault.lock(fund, expiry, maximum)).to.be.revertedWith( + "AlreadyLocked" + ) + }) + + it("can extend a lock expiry up to its maximum", async function () { + await vault.extendLock(fund, expiry + 1) + expect(await vault.getLockExpiry(fund)).to.equal(expiry + 1) + await vault.extendLock(fund, maximum) + expect(await vault.getLockExpiry(fund)).to.equal(maximum) + }) + + it("cannot extend a lock past its maximum", async function () { + const extending = vault.extendLock(fund, maximum + 1) + await expect(extending).to.be.revertedWith("InvalidExpiry") + }) + + it("cannot move expiry to an earlier time", async function () { + const extending = vault.extendLock(fund, expiry - 1) + await expect(extending).to.be.revertedWith("InvalidExpiry") + }) + + it("does not delete lock when no tokens remain", async function () { + await token.connect(controller).approve(vault.address, 30) + await vault.deposit(fund, account, 30) + await vault.burnAccount(fund, account) + expect(await vault.getFundStatus(fund)).to.equal(FundStatus.Locked) + expect(await vault.getLockExpiry(fund)).to.not.equal(0) + }) + }) + + describe("depositing", function () { + const amount = 1000 + + let account + + beforeEach(async function () { + account = await vault.encodeAccountId(holder.address, randomBytes(12)) + await setAutomine(true) + }) + + it("accepts deposits of tokens", async function () { + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account, amount) + const balance = await vault.getBalance(fund, account) + expect(balance).to.equal(amount) + }) + + it("keeps custody of tokens that are deposited", async function () { + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account, amount) + expect(await token.balanceOf(vault.address)).to.equal(amount) + }) + + it("deposit fails when tokens cannot be transferred", async function () { + await token.connect(controller).approve(vault.address, amount - 1) + const depositing = vault.deposit(fund, account, amount) + await expect(depositing).to.be.revertedWith( + "ERC20InsufficientAllowance" + ) + }) + + it("adds multiple deposits to the balance", async function () { + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account, amount / 2) + await vault.deposit(fund, account, amount / 2) + const balance = await vault.getBalance(fund, account) + expect(balance).to.equal(amount) + }) + + it("separates deposits from different accounts with the same holder", async function () { + const address = holder.address + const account1 = await vault.encodeAccountId(address, randomBytes(12)) + const account2 = await vault.encodeAccountId(address, randomBytes(12)) + await token.connect(controller).approve(vault.address, 3) + await vault.deposit(fund, account1, 1) + await vault.deposit(fund, account2, 2) + expect(await vault.getBalance(fund, account1)).to.equal(1) + expect(await vault.getBalance(fund, account2)).to.equal(2) + }) + + it("separates deposits from different funds", async function () { + const fund1 = randomBytes(32) + const fund2 = randomBytes(32) + await vault.lock(fund1, expiry, maximum) + await vault.lock(fund2, expiry, maximum) + await token.connect(controller).approve(vault.address, 3) + await vault.deposit(fund1, account, 1) + await vault.deposit(fund2, account, 2) + expect(await vault.getBalance(fund1, account)).to.equal(1) + expect(await vault.getBalance(fund2, account)).to.equal(2) + }) + + it("separates deposits from different controllers", async function () { + const controller1 = holder2 + const controller2 = holder3 + const vault1 = vault.connect(controller1) + const vault2 = vault.connect(controller2) + await vault1.lock(fund, expiry, maximum) + await vault2.lock(fund, expiry, maximum) + await token.mint(controller1.address, 1000) + await token.mint(controller2.address, 1000) + await token.connect(controller1).approve(vault.address, 1) + await token.connect(controller2).approve(vault.address, 2) + await vault1.deposit(fund, account, 1) + await vault2.deposit(fund, account, 2) + expect(await vault1.getBalance(fund, account)).to.equal(1) + expect(await vault2.getBalance(fund, account)).to.equal(2) + }) + }) + + describe("designating", function () { + const amount = 1000 + + let account, account2 + + beforeEach(async function () { + account = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account, amount) + }) + + it("can designate tokens for the account holder", async function () { + await setAutomine(true) + await vault.designate(fund, account, amount) + expect(await vault.getDesignatedBalance(fund, account)).to.equal(amount) + }) + + it("can designate part of the balance", async function () { + await setAutomine(true) + await vault.designate(fund, account, 10) + expect(await vault.getDesignatedBalance(fund, account)).to.equal(10) + }) + + it("adds up designated tokens", async function () { + await setAutomine(true) + await vault.designate(fund, account, 10) + await vault.designate(fund, account, 10) + expect(await vault.getDesignatedBalance(fund, account)).to.equal(20) + }) + + it("does not change the balance", async function () { + await setAutomine(true) + await vault.designate(fund, account, 10) + expect(await vault.getBalance(fund, account)).to.equal(amount) + }) + + it("cannot designate more than the undesignated balance", async function () { + await setAutomine(true) + await vault.designate(fund, account, amount) + await expect(vault.designate(fund, account, 1)).to.be.revertedWith( + "InsufficientBalance" + ) + }) + + it("cannot designate tokens that are flowing", async function () { + await vault.flow(fund, account, account2, 5) + setAutomine(true) + await vault.designate(fund, account, 500) + const designating = vault.designate(fund, account, 1) + await expect(designating).to.be.revertedWith("InsufficientBalance") + }) + }) + + describe("transfering", function () { + const amount = 1000 + + let account1, account2, account3 + + beforeEach(async function () { + account1 = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + account3 = await vault.encodeAccountId(holder3.address, randomBytes(12)) + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account1, amount) + }) + + it("can transfer tokens from one recipient to the other", async function () { + await setAutomine(true) + await vault.transfer(fund, account1, account2, amount) + expect(await vault.getBalance(fund, account1)).to.equal(0) + expect(await vault.getBalance(fund, account2)).to.equal(amount) + }) + + it("can transfer part of a balance", async function () { + await setAutomine(true) + await vault.transfer(fund, account1, account2, 10) + expect(await vault.getBalance(fund, account1)).to.equal(amount - 10) + expect(await vault.getBalance(fund, account2)).to.equal(10) + }) + + it("can transfer out funds that were transfered in", async function () { + await setAutomine(true) + await vault.transfer(fund, account1, account2, amount) + await vault.transfer(fund, account2, account3, amount) + expect(await vault.getBalance(fund, account2)).to.equal(0) + expect(await vault.getBalance(fund, account3)).to.equal(amount) + }) + + it("can transfer to self", async function () { + await setAutomine(true) + await vault.transfer(fund, account1, account1, amount) + expect(await vault.getBalance(fund, account1)).to.equal(amount) + }) + + it("does not transfer more than the balance", async function () { + await setAutomine(true) + await expect( + vault.transfer(fund, account1, account2, amount + 1) + ).to.be.revertedWith("InsufficientBalance") + }) + + it("does not transfer designated tokens", async function () { + await setAutomine(true) + await vault.designate(fund, account1, 1) + await expect( + vault.transfer(fund, account1, account2, amount) + ).to.be.revertedWith("InsufficientBalance") + }) + + it("does not transfer tokens that are flowing", async function () { + await vault.flow(fund, account1, account2, 5) + setAutomine(true) + await vault.transfer(fund, account1, account2, 500) + await expect( + vault.transfer(fund, account1, account2, 1) + ).to.be.revertedWith("InsufficientBalance") + }) + }) + + describe("flowing", function () { + const deposit = 1000 + + let account1, account2, account3 + + beforeEach(async function () { + account1 = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + account3 = await vault.encodeAccountId(holder3.address, randomBytes(12)) + await token.connect(controller).approve(vault.address, deposit) + await vault.deposit(fund, account1, deposit) + }) + + async function getBalance(account) { + return await vault.getBalance(fund, account) + } + + it("moves tokens over time", async function () { + await vault.flow(fund, account1, account2, 2) + mine() + const start = await currentTime() + await advanceTimeTo(start + 2) + expect(await getBalance(account1)).to.equal(deposit - 4) + expect(await getBalance(account2)).to.equal(4) + await advanceTimeTo(start + 4) + expect(await getBalance(account1)).to.equal(deposit - 8) + expect(await getBalance(account2)).to.equal(8) + }) + + it("can move tokens to several different accounts", async function () { + await vault.flow(fund, account1, account2, 1) + await vault.flow(fund, account1, account3, 2) + await mine() + const start = await currentTime() + await advanceTimeTo(start + 2) + expect(await getBalance(account1)).to.equal(deposit - 6) + expect(await getBalance(account2)).to.equal(2) + expect(await getBalance(account3)).to.equal(4) + await advanceTimeTo(start + 4) + expect(await getBalance(account1)).to.equal(deposit - 12) + expect(await getBalance(account2)).to.equal(4) + expect(await getBalance(account3)).to.equal(8) + }) + + it("allows flows to be diverted to other account", async function () { + await vault.flow(fund, account1, account2, 3) + await vault.flow(fund, account2, account3, 1) + await mine() + const start = await currentTime() + await advanceTimeTo(start + 2) + expect(await getBalance(account1)).to.equal(deposit - 6) + expect(await getBalance(account2)).to.equal(4) + expect(await getBalance(account3)).to.equal(2) + await advanceTimeTo(start + 4) + expect(await getBalance(account1)).to.equal(deposit - 12) + expect(await getBalance(account2)).to.equal(8) + expect(await getBalance(account3)).to.equal(4) + }) + + it("allows flow to be reversed back to the sender", async function () { + await vault.flow(fund, account1, account2, 3) + await vault.flow(fund, account2, account1, 3) + await mine() + const start = await currentTime() + await advanceTimeTo(start + 2) + expect(await getBalance(account1)).to.equal(deposit) + expect(await getBalance(account2)).to.equal(0) + await advanceTimeTo(start + 4) + expect(await getBalance(account1)).to.equal(deposit) + expect(await getBalance(account2)).to.equal(0) + }) + + it("can change flows over time", async function () { + await vault.flow(fund, account1, account2, 1) + await vault.flow(fund, account1, account3, 2) + await mine() + const start = await currentTime() + setNextBlockTimestamp(start + 4) + await vault.flow(fund, account3, account2, 1) + await mine() + expect(await getBalance(account1)).to.equal(deposit - 12) + expect(await getBalance(account2)).to.equal(4) + expect(await getBalance(account3)).to.equal(8) + await advanceTimeTo(start + 8) + expect(await getBalance(account1)).to.equal(deposit - 24) + expect(await getBalance(account2)).to.equal(12) + expect(await getBalance(account3)).to.equal(12) + await advanceTimeTo(start + 12) + expect(await getBalance(account1)).to.equal(deposit - 36) + expect(await getBalance(account2)).to.equal(20) + expect(await getBalance(account3)).to.equal(16) + }) + + it("designates tokens that flow into the account", async function () { + await vault.flow(fund, account1, account2, 3) + await mine() + const start = await currentTime() + await advanceTimeTo(start + 7) + expect(await vault.getDesignatedBalance(fund, account2)).to.equal(21) + }) + + it("designates tokens that flow back to the sender", async function () { + await vault.flow(fund, account1, account1, 3) + await mine() + const start = await currentTime() + await advanceTimeTo(start + 7) + expect(await vault.getBalance(fund, account1)).to.equal(deposit) + expect(await vault.getDesignatedBalance(fund, account1)).to.equal(21) + }) + + it("flows longer when lock is extended", async function () { + await vault.flow(fund, account1, account2, 2) + await mine() + const start = await currentTime() + await vault.extendLock(fund, maximum) + await mine() + await advanceTimeTo(maximum) + const total = (maximum - start) * 2 + expect(await getBalance(account1)).to.equal(deposit - total) + expect(await getBalance(account2)).to.equal(total) + await advanceTimeTo(maximum + 10) + expect(await getBalance(account1)).to.equal(deposit - total) + expect(await getBalance(account2)).to.equal(total) + }) + + it("rejects flow when insufficient available tokens", async function () { + setAutomine(true) + await expect( + vault.flow(fund, account1, account2, 11) + ).to.be.revertedWith("InsufficientBalance") + }) + + it("rejects total flows exceeding available tokens", async function () { + await vault.flow(fund, account1, account2, 10) + setAutomine(true) + await expect( + vault.flow(fund, account1, account2, 1) + ).to.be.revertedWith("InsufficientBalance") + }) + + it("cannot flow designated tokens", async function () { + await vault.designate(fund, account1, 500) + await vault.flow(fund, account1, account2, 5) + setAutomine(true) + await expect( + vault.flow(fund, account1, account2, 1) + ).to.be.revertedWith("InsufficientBalance") + }) + }) + + describe("burning", function () { + const dead = "0x000000000000000000000000000000000000dead" + const amount = 1000 + + let account1, account2, account3 + + beforeEach(async function () { + account1 = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + account3 = await vault.encodeAccountId(holder3.address, randomBytes(12)) + await setAutomine(true) + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account1, amount) + }) + + describe("burn designated", function () { + const designated = 100 + + beforeEach(async function () { + await vault.designate(fund, account1, designated) + }) + + it("burns a number of designated tokens", async function () { + await vault.burnDesignated(fund, account1, 10) + expect(await vault.getDesignatedBalance(fund, account1)).to.equal( + designated - 10 + ) + expect(await vault.getBalance(fund, account1)).to.equal(amount - 10) + }) + + it("can burn all of the designated tokens", async function () { + await vault.burnDesignated(fund, account1, designated) + expect(await vault.getDesignatedBalance(fund, account1)).to.equal(0) + expect(await vault.getBalance(fund, account1)).to.equal( + amount - designated + ) + }) + + it("moves burned tokens to address 0xdead", async function () { + const before = await token.balanceOf(dead) + await vault.burnDesignated(fund, account1, 10) + const after = await token.balanceOf(dead) + expect(after - before).to.equal(10) + }) + + it("can burn designated when tokens are flowing", async function () { + await vault.flow(fund, account1, account2, 5) + await expect(vault.burnDesignated(fund, account1, designated)).not.to + .be.reverted + }) + + it("cannot burn more than all designated tokens", async function () { + await expect( + vault.burnDesignated(fund, account1, designated + 1) + ).to.be.revertedWith("InsufficientBalance") + }) + }) + + describe("burn account", function () { + it("can burn an account", async function () { + await vault.burnAccount(fund, account1) + expect(await vault.getBalance(fund, account1)).to.equal(0) + }) + + it("also burns the designated tokens", async function () { + await vault.designate(fund, account1, 10) + await vault.burnAccount(fund, account1) + expect(await vault.getDesignatedBalance(fund, account1)).to.equal(0) + }) + + it("moves account tokens to address 0xdead", async function () { + await vault.designate(fund, account1, 10) + const before = await token.balanceOf(dead) + await vault.burnAccount(fund, account1) + const after = await token.balanceOf(dead) + expect(after - before).to.equal(amount) + }) + + it("does not burn tokens from other accounts with the same holder", async function () { + const account1a = await vault.encodeAccountId( + holder.address, + randomBytes(12) + ) + await vault.transfer(fund, account1, account1a, 10) + await vault.burnAccount(fund, account1) + expect(await vault.getBalance(fund, account1a)).to.equal(10) + }) + + it("cannot burn tokens that are flowing", async function () { + await vault.flow(fund, account1, account2, 5) + const burning1 = vault.burnAccount(fund, account1) + await expect(burning1).to.be.revertedWith("FlowNotZero") + const burning2 = vault.burnAccount(fund, account2) + await expect(burning2).to.be.revertedWith("FlowNotZero") + }) + + it("can burn tokens that are no longer flowing", async function () { + await vault.flow(fund, account1, account2, 5) + await vault.flow(fund, account2, account1, 5) + await expect(vault.burnAccount(fund, account1)).not.to.be.reverted + }) + }) + }) + + describe("freezing", function () { + const deposit = 1000 + + let account1, account2, account3 + + beforeEach(async function () { + account1 = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + account3 = await vault.encodeAccountId(holder3.address, randomBytes(12)) + await token.approve(vault.address, deposit) + await vault.deposit(fund, account1, deposit) + }) + + it("can freeze a fund", async function () { + await setAutomine(true) + await vault.freezeFund(fund) + expect(await vault.getFundStatus(fund)).to.equal(FundStatus.Frozen) + }) + + it("stops all token flows", async function () { + await vault.flow(fund, account1, account2, 10) + await vault.flow(fund, account2, account3, 3) + await mine() + const start = await currentTime() + await setNextBlockTimestamp(start + 10) + await vault.freezeFund(fund) + await mine() + await advanceTimeTo(start + 20) + expect(await vault.getBalance(fund, account1)).to.equal(deposit - 100) + expect(await vault.getBalance(fund, account2)).to.equal(70) + expect(await vault.getBalance(fund, account3)).to.equal(30) + }) + }) + + describe("withdrawing", function () { + const amount = 1000 + + let account1, account2 + + beforeEach(async function () { + account1 = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + await setAutomine(true) + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account1, amount) + }) + + it("does not allow withdrawal before lock expires", async function () { + await setNextBlockTimestamp(expiry - 1) + const withdrawing = vault.withdraw(fund, account1) + await expect(withdrawing).to.be.revertedWith("FundNotUnlocked") + }) + + it("disallows withdrawal for everyone in the fund", async function () { + await vault.transfer(fund, account1, account2, amount / 2) + let withdrawing1 = vault.withdraw(fund, account1) + let withdrawing2 = vault.withdraw(fund, account2) + await expect(withdrawing1).to.be.revertedWith("FundNotUnlocked") + await expect(withdrawing2).to.be.revertedWith("FundNotUnlocked") + }) + }) + }) + + describe("when a fund lock is expiring", function () { + let expiry + let maximum + let account1, account2, account3 + + beforeEach(async function () { + const beginning = (await currentTime()) + 10 + expiry = beginning + 80 + maximum = beginning + 100 + account1 = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + account3 = await vault.encodeAccountId(holder3.address, randomBytes(12)) + await setAutomine(false) + await setNextBlockTimestamp(beginning) + await vault.lock(fund, expiry, maximum) + }) + + async function expire() { + await setNextBlockTimestamp(expiry) + } + + it("unlocks the funds", async function () { + await mine() + expect(await vault.getFundStatus(fund)).to.equal(FundStatus.Locked) + await expire() + await mine() + expect(await vault.getFundStatus(fund)).to.equal(FundStatus.Withdrawing) + }) + + describe("locking", function () { + beforeEach(async function () { + await setAutomine(true) + }) + + it("cannot set lock when lock expired", async function () { + await expire() + const locking = vault.lock(fund, expiry, maximum) + await expect(locking).to.be.revertedWith("AlreadyLocked") + }) + + it("deletes lock when no tokens remain", async function () { + await token.connect(controller).approve(vault.address, 30) + await vault.deposit(fund, account1, 30) + await vault.transfer(fund, account1, account2, 20) + await vault.transfer(fund, account2, account3, 10) + // some designated tokens are burned + await vault.designate(fund, account2, 10) + await vault.burnDesignated(fund, account2, 5) + // some holder is burned + await vault.burnAccount(fund, account2) + await expire() + // some tokens are withdrawn + await vault.withdraw(fund, account1) + expect(await vault.getFundStatus(fund)).to.equal(FundStatus.Withdrawing) + expect(await vault.getLockExpiry(fund)).not.to.equal(0) + // remainder of the tokens are withdrawn by recipient + await vault + .connect(holder3) + .withdrawByRecipient(controller.address, fund, account3) + expect(await vault.getFundStatus(fund)).to.equal(FundStatus.Inactive) + expect(await vault.getLockExpiry(fund)).to.equal(0) + }) + }) + + describe("flowing", function () { + const deposit = 1000 + + beforeEach(async function () { + await token.connect(controller).approve(vault.address, deposit) + await vault.deposit(fund, account1, deposit) + }) + + describe("unlocked flows", function () { + let total + + beforeEach(async function () { + await vault.flow(fund, account1, account2, 2) + await mine() + const start = await currentTime() + total = (expiry - start) * 2 + await advanceTimeTo(expiry) + }) + + it("stops flows when lock expires", async function () { + let balance1, balance2 + balance1 = await vault.getBalance(fund, account1) + balance2 = await vault.getBalance(fund, account2) + expect(balance1).to.equal(deposit - total) + expect(balance2).to.equal(total) + await advanceTimeTo(expiry + 10) + balance1 = await vault.getBalance(fund, account1) + balance2 = await vault.getBalance(fund, account2) + expect(balance1).to.equal(deposit - total) + expect(balance2).to.equal(total) + }) + + it("allows flowing tokens to be withdrawn", async function () { + const balance1Before = await token.balanceOf(holder.address) + const balance2Before = await token.balanceOf(holder2.address) + await vault.withdraw(fund, account1) + await vault.withdraw(fund, account2) + await mine() + const balance1After = await token.balanceOf(holder.address) + const balance2After = await token.balanceOf(holder2.address) + expect(balance1After - balance1Before).to.equal(deposit - total) + expect(balance2After - balance2Before).to.equal(total) + }) + }) + + describe("unlocked frozen flows", function () { + let total + + beforeEach(async function () { + await vault.flow(fund, account1, account2, 2) + await mine() + const start = await currentTime() + await setNextBlockTimestamp(start + 10) + await vault.freezeFund(fund) + await mine() + const frozenAt = await currentTime() + total = (frozenAt - start) * 2 + await advanceTimeTo(expiry) + }) + + it("stops flows at the time they were frozen", async function () { + const balance1 = await vault.getBalance(fund, account1) + const balance2 = await vault.getBalance(fund, account2) + expect(balance1).to.equal(deposit - total) + expect(balance2).to.equal(total) + }) + + it("allows frozen flows to be withdrawn", async function () { + balance1Before = await token.balanceOf(holder.address) + balance2Before = await token.balanceOf(holder2.address) + await vault.withdraw(fund, account1) + await vault.withdraw(fund, account2) + await mine() + balance1After = await token.balanceOf(holder.address) + balance2After = await token.balanceOf(holder2.address) + expect(balance1After - balance1Before).to.equal(deposit - total) + expect(balance2After - balance2Before).to.equal(total) + }) + }) + }) + + describe("withdrawing", function () { + const amount = 1000 + + beforeEach(async function () { + setAutomine(true) + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account1, amount) + await token.connect(controller).approve(vault.address, amount) + await vault.deposit(fund, account2, amount) + }) + + it("allows controller to withdraw for a recipient", async function () { + await expire() + const before = await token.balanceOf(holder.address) + await vault.withdraw(fund, account1) + const after = await token.balanceOf(holder.address) + expect(after - before).to.equal(amount) + }) + + it("allows account holder to withdraw for itself", async function () { + await expire() + const before = await token.balanceOf(holder.address) + await vault + .connect(holder) + .withdrawByRecipient(controller.address, fund, account1) + const after = await token.balanceOf(holder.address) + expect(after - before).to.equal(amount) + }) + + it("does not allow anyone else to withdraw for the account holder", async function () { + await expire() + await expect( + vault + .connect(holder2) + .withdrawByRecipient(controller.address, fund, account1) + ).to.be.revertedWith("OnlyAccountHolder") + }) + + it("empties the balance when withdrawing", async function () { + await expire() + await vault.withdraw(fund, account1) + expect(await vault.getBalance(fund, account1)).to.equal(0) + }) + + it("does not withdraw other accounts from the same holder", async function () { + const account1a = await vault.encodeAccountId( + holder.address, + randomBytes(12) + ) + await vault.transfer(fund, account1, account1a, 10) + await expire() + await vault.withdraw(fund, account1) + expect(await vault.getBalance(fund, account1a)).to.equal(10) + }) + + it("allows designated tokens to be withdrawn", async function () { + await vault.designate(fund, account1, 10) + await expire() + const before = await token.balanceOf(holder.address) + await vault.withdraw(fund, account1) + const after = await token.balanceOf(holder.address) + expect(after - before).to.equal(amount) + }) + + it("does not withdraw designated tokens more than once", async function () { + await vault.designate(fund, account1, 10) + await expire() + await vault.withdraw(fund, account1) + const before = await token.balanceOf(holder.address) + await vault.withdraw(fund, account1) + const after = await token.balanceOf(holder.address) + expect(after).to.equal(before) + }) + + it("can withdraw funds that were transfered in", async function () { + await vault.transfer(fund, account1, account3, amount) + await expire() + const before = await token.balanceOf(holder3.address) + await vault.withdraw(fund, account3) + const after = await token.balanceOf(holder3.address) + expect(after - before).to.equal(amount) + }) + + it("cannot withdraw funds that were transfered out", async function () { + await vault.transfer(fund, account1, account3, amount) + await expire() + const before = await token.balanceOf(holder.address) + await vault.withdraw(fund, account1) + const after = await token.balanceOf(holder.address) + expect(after).to.equal(before) + }) + + it("cannot withdraw more than once", async function () { + await expire() + await vault.withdraw(fund, account1) + const before = await token.balanceOf(holder.address) + await vault.withdraw(fund, account1) + const after = await token.balanceOf(holder.address) + expect(after).to.equal(before) + }) + + it("cannot withdraw burned tokens", async function () { + await vault.burnAccount(fund, account1) + await expire() + const before = await token.balanceOf(holder.address) + await vault.withdraw(fund, account1) + const after = await token.balanceOf(holder.address) + expect(after).to.equal(before) + }) + }) + + describe("fund is not locked", function () { + beforeEach(async function () { + setAutomine(true) + await expire() + }) + + testFundThatIsNotLocked() + }) + }) + + describe("when a fund is frozen", function () { + const amount = 1000 + + let expiry + let account + + beforeEach(async function () { + expiry = (await currentTime()) + 100 + account = await vault.encodeAccountId(holder.address, randomBytes(12)) + await token.connect(controller).approve(vault.address, amount) + await vault.lock(fund, expiry, expiry) + await vault.deposit(fund, account, amount) + await vault.freezeFund(fund) + }) + + it("does not allow setting a lock", async function () { + const locking = vault.lock(fund, expiry, expiry) + await expect(locking).to.be.revertedWith("FundAlreadyLocked") + }) + + it("does not allow withdrawal", async function () { + const withdrawing = vault.withdraw(fund, account) + await expect(withdrawing).to.be.revertedWith("FundNotUnlocked") + }) + + it("unlocks when the lock expires", async function () { + await advanceTimeTo(expiry) + expect(await vault.getFundStatus(fund)).to.equal(FundStatus.Withdrawing) + }) + + testFundThatIsNotLocked() + }) + + function testFundThatIsNotLocked() { + let account, account2 + + beforeEach(async function () { + account = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + }) + + it("does not allow extending of lock", async function () { + await expect( + vault.extendLock(fund, (await currentTime()) + 1) + ).to.be.revertedWith("FundNotLocked") + }) + + it("does not allow depositing of tokens", async function () { + const amount = 1000 + await token.connect(controller).approve(vault.address, amount) + await expect(vault.deposit(fund, account, amount)).to.be.revertedWith( + "FundNotLocked" + ) + }) + + it("does not allow designating tokens", async function () { + await expect(vault.designate(fund, account, 0)).to.be.revertedWith( + "FundNotLocked" + ) + }) + + it("does not allow transfer of tokens", async function () { + await expect( + vault.transfer(fund, account, account2, 0) + ).to.be.revertedWith("FundNotLocked") + }) + + it("does not allow new token flows to start", async function () { + await expect(vault.flow(fund, account, account2, 0)).to.be.revertedWith( + "FundNotLocked" + ) + }) + + it("does not allow burning of designated tokens", async function () { + await expect(vault.burnDesignated(fund, account, 1)).to.be.revertedWith( + "FundNotLocked" + ) + }) + + it("does not allow burning of accounts", async function () { + await expect(vault.burnAccount(fund, account)).to.be.revertedWith( + "FundNotLocked" + ) + }) + + it("does not allow freezing of a fund", async function () { + await expect(vault.freezeFund(fund)).to.be.revertedWith("FundNotLocked") + }) + } + + describe("pausing", function () { + let owner + let owner2 + let other + + beforeEach(async function () { + ;[owner, owner2, other] = await ethers.getSigners() + }) + + it("allows the vault to be paused by the owner", async function () { + await expect(vault.connect(owner).pause()).not.to.be.reverted + }) + + it("allows the vault to be unpaused by the owner", async function () { + await vault.connect(owner).pause() + await expect(vault.connect(owner).unpause()).not.to.be.reverted + }) + + it("does not allow pause to be called by others", async function () { + await expect(vault.connect(other).pause()).to.be.revertedWith( + "UnauthorizedAccount" + ) + }) + + it("does not allow unpause to be called by others", async function () { + await vault.connect(owner).pause() + await expect(vault.connect(other).unpause()).to.be.revertedWith( + "UnauthorizedAccount" + ) + }) + + it("allows the ownership to change", async function () { + await vault.connect(owner).pause() + await vault.connect(owner).transferOwnership(owner2.address) + await expect(vault.connect(owner2).unpause()).not.to.be.reverted + }) + + it("allows the ownership to be renounced", async function () { + await vault.connect(owner).renounceOwnership() + await expect(vault.connect(owner).pause()).to.be.revertedWith( + "UnauthorizedAccount" + ) + }) + + describe("when the vault is paused", function () { + let expiry + let maximum + let account1, account2 + + beforeEach(async function () { + expiry = (await currentTime()) + 80 + maximum = (await currentTime()) + 100 + account1 = await vault.encodeAccountId(holder.address, randomBytes(12)) + account2 = await vault.encodeAccountId(holder2.address, randomBytes(12)) + await vault.lock(fund, expiry, maximum) + await token.approve(vault.address, 1000) + await vault.deposit(fund, account1, 1000) + await vault.designate(fund, account1, 100) + await vault.connect(owner).pause() + }) + + it("only allows a recipient to withdraw itself", async function () { + await advanceTimeTo(expiry) + await expect( + vault + .connect(holder) + .withdrawByRecipient(controller.address, fund, account1) + ).not.to.be.reverted + }) + + it("does not allow funds to be locked", async function () { + const fund = randomBytes(32) + const expiry = (await currentTime()) + 100 + await expect(vault.lock(fund, expiry, expiry)).to.be.revertedWith( + "EnforcedPause" + ) + }) + + it("does not allow extending of lock", async function () { + await expect(vault.extendLock(fund, maximum)).to.be.revertedWith( + "EnforcedPause" + ) + }) + + it("does not allow depositing of tokens", async function () { + await token.approve(vault.address, 100) + await expect(vault.deposit(fund, account1, 100)).to.be.revertedWith( + "EnforcedPause" + ) + }) + + it("does not allow designating tokens", async function () { + await expect(vault.designate(fund, account1, 10)).to.be.revertedWith( + "EnforcedPause" + ) + }) + + it("does not allow transfer of tokens", async function () { + await expect( + vault.transfer(fund, account1, account2, 10) + ).to.be.revertedWith("EnforcedPause") + }) + + it("does not allow new token flows to start", async function () { + await expect( + vault.flow(fund, account1, account2, 1) + ).to.be.revertedWith("EnforcedPause") + }) + + it("does not allow burning of designated tokens", async function () { + await expect( + vault.burnDesignated(fund, account1, 10) + ).to.be.revertedWith("EnforcedPause") + }) + + it("does not allow burning of accounts", async function () { + await expect(vault.burnAccount(fund, account1)).to.be.revertedWith( + "EnforcedPause" + ) + }) + + it("does not allow freezing of funds", async function () { + await expect(vault.freezeFund(fund)).to.be.revertedWith("EnforcedPause") + }) + + it("does not allow a controller to withdraw for a recipient", async function () { + await advanceTimeTo(expiry) + await expect(vault.withdraw(fund, account1)).to.be.revertedWith( + "EnforcedPause" + ) + }) + }) + }) +}) diff --git a/test/evm.js b/test/evm.js index 85aa141..cf25d7b 100644 --- a/test/evm.js +++ b/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, diff --git a/test/vault.js b/test/vault.js new file mode 100644 index 0000000..753b3a5 --- /dev/null +++ b/test/vault.js @@ -0,0 +1,8 @@ +const FundStatus = { + Inactive: 0, + Locked: 1, + Frozen: 2, + Withdrawing: 3, +} + +module.exports = { FundStatus }