mirror of
https://github.com/logos-storage/logos-storage-contracts-eth.git
synced 2026-01-04 14:23:10 +00:00
* vault: deposit and withdraw * vault: change data structure to be recipient oriented * vault: burning funds * vault: transfer tokens from one recipient to the other * vault: designate tokens for a single recipient * vault: lock up tokens until expiry time * vault: lock is deleted upon withdrawal * vault: simplify test setup * vault: remove duplication in tests * vault: further test for locks * vault: allow recipient to withdraw * vault: flow tokens from one recipient to the other * vault: designate tokens that flow * vault: move flow accumulation calculation into VaultBase * vault: use custom operators to improve readability * vault: stop flowing when lock expires * vault: reject flow when insufficient tokens available * vault: do not allow flow when lock already expired * vault: allow automine to be disabled in time sensitive tests * vault: improve naming of public functions * vault: flow to multiple recipients - changes balance from uint256 -> uint128 so that entire Balance can be read or written with a single operation - moves Lock to library - simplifies lock checks * vault: reject negative flows * vault: make tests a bit more robust * vault: change flows over time * vault: check Lock invariant before writing * vault: allow flows to be diverted to others * vault: simplify example flow rates in test * vault: disallow transfer of flowing tokens * vault: cannot burn flowing tokens * vault: delete flow when burning or withdrawing * vault: fix flaky time sensitive tests Ensures that setting of lock and starting of flow happen in the same block. Therefore hardhat cannot occasionally increase the timestamp between the two operations. This makes predicting the balances over time much easier. * vault: disallow designating of flowing tokens * vault: document setAutomine() * vault: delete lock all tokens are withdrawn or burned * vault: cleanup * vault: reorder tests * vault: only allow deposit, transfer, etc when locked * vault: reorder functions in roughly chronological order * vault: rename context -> fund * vault: rename balance -> account * vault: combine account and flow mappings * vault: _getAccount updates to the latest timestamp * vault: simplify _getAccount() * vault: reordering * vault: formatting * vault: do not delete lock when burning * vault: combine Account and Flow structs * vault: cleanup * vault: split flow into incoming and outgoing - no need to deal with signed integers anymore - allows flow to self to designate tokens over time * vault: fix transfer to self * vault: remove _getAccount() - no longer calculate flow updates when not needed - use account.update(timestamp) where needed - use _getBalance() to view current balance * vault: rename error * vault: reduce size of timestamp further * vault: prevent approval hijacking - transfer ERC20 funds into the vault from the controller, not from the user - prevents an attacker from hijacking a user's ERC20 approval to move tokens into a part of the vault that is controlled by the attacker * vault: extract common tests for unlocked funds * vault: burn entire fund * vault: transfer tokens to 0xdead when fund is burned * vault: do not expose Lock internals on public api * vault: formatting * vault: test lock state transitions * vault: clean up errors * vault: rename burn -> burnAccount, burnAll -> burnFund * vault: burn part of designated tokens * vault: burn designated/fund allowed when flowing * vault: prefix errors with 'Vault' * vault: cleanup * vault: remove dead code * vault: add documentation * vault: fix accounting of locked value when burning designated tokens * vault: update documentation * update openzeppelin contracts to 5.2.0 * vault: format all solidity files * vault: cleanup tests * vault: pausing and unpausing * vault: rename account->holder in tests * vault: allow for multiple accounts for one account holder * vault: only allow account holder to withdraw for itself * vault: freezeFund() instead of burnFund() * vault: rename Fund -> FundId * vault: rename lock states - NoLock -> Inactive - Unlocked -> Withdrawing * vault: rename Lock -> Fund * vault: clarification Co-Authored-by: Adam Uhlíř <adam@uhlir.dev> * vault: rename update() -> accumulateFlows() Reason: update() is too generic, and can easily be interpreted as changing the on-chain state, whereas it actually updates the in-memory struct. Co-Authored-By: Eric <5089238+emizzle@users.noreply.github.com> Co-Authored-By: Adam Uhlíř <adam@uhlir.dev> * vault: rephrase Co-Authored-By: Adam Uhlíř <adam@uhlir.dev> --------- Co-authored-by: Adam Uhlíř <adam@uhlir.dev> Co-authored-by: Eric <5089238+emizzle@users.noreply.github.com>
293 lines
8.9 KiB
Solidity
293 lines
8.9 KiB
Solidity
// 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();
|
|
}
|