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 }