vault: allow for multiple accounts for one account holder

This commit is contained in:
Mark Spanbroek 2025-02-20 14:59:41 +01:00
parent b64c64aff8
commit 95a698c574
4 changed files with 446 additions and 335 deletions

View File

@ -11,8 +11,7 @@ import "./vault/VaultBase.sol";
///
/// A vault keeps track of funds for a smart contract. This smart contract is
/// called the controller of the funds. Each controller has its own independent
/// set of funds. Each fund has a number of recipients, and every recipient has
/// its own account within the fund.
/// set of funds. Each fund has a number of accounts.
///
/// Vault -> Controller -> Fund -> Account
///
@ -21,6 +20,8 @@ import "./vault/VaultBase.sol";
///
/// An account has a balance, of which a part can be designated. Designated
/// tokens can no longer be transfered to another account.
/// 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
@ -28,8 +29,8 @@ import "./vault/VaultBase.sol";
/// 3. the controller deposits ERC20 tokens into the fund
/// 4. the controller transfers tokens between accounts in the fund
/// 5. the fund unlocks after a while, freezing the account balances
/// 6. the controller withdraws ERC20 tokens from the fund for a recipient, or
/// the recipient initiates the withdrawal itself
/// 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:
@ -40,34 +41,49 @@ import "./vault/VaultBase.sol";
/// that they can no longer be reassigned to an attacker
/// - when storing collateral, it can be designated for the collateral provider,
/// ensuring that it cannot be reassigned to an attacker
/// - malicious upgrades to a fund controller cannot prevent recipients from
/// withdrawing their tokens
/// - 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) {}
/// The amount of tokens that are currently assigned to a recipient in a fund.
/// 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 account
) public pure returns (address holder, bytes12 discriminator) {
return Accounts.decodeId(account);
}
/// 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(
Fund fund,
Recipient recipient
) public view returns (uint128) {
function getBalance(Fund fund, AccountId id) public view returns (uint128) {
Controller controller = Controller.wrap(msg.sender);
Balance memory balance = _getBalance(controller, fund, recipient);
Balance memory balance = _getBalance(controller, fund, id);
return balance.available + balance.designated;
}
/// The amount of tokens that are currently designated to a recipient in a
/// fund. These tokens can no longer be transfered to other accounts.
/// The amount of tokens that are currently designated in an account
/// These tokens can no longer be transfered to other accounts.
function getDesignatedBalance(
Fund fund,
Recipient recipient
AccountId id
) public view returns (uint128) {
Controller controller = Controller.wrap(msg.sender);
Balance memory balance = _getBalance(controller, fund, recipient);
Balance memory balance = _getBalance(controller, fund, id);
return balance.designated;
}
@ -107,45 +123,44 @@ contract Vault is VaultBase, Pausable, Ownable {
}
/// Deposits an amount of tokens into the vault, and adds them to the balance
/// of the recipient. ERC20 tokens are transfered from the caller to the vault
/// of the account. ERC20 tokens are transfered from the caller to the vault
/// contract.
/// Only allowed when the fund is locked.
function deposit(
Fund fund,
Recipient recipient,
AccountId id,
uint128 amount
) public whenNotPaused {
Controller controller = Controller.wrap(msg.sender);
_deposit(controller, fund, recipient, amount);
_deposit(controller, fund, id, amount);
}
/// Takes an amount of tokens from the recipient's balance and designates them
/// for the recipient. These tokens are no longer available to be transfered
/// to other accounts.
/// 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(
Fund fund,
Recipient recipient,
AccountId id,
uint128 amount
) public whenNotPaused {
Controller controller = Controller.wrap(msg.sender);
_designate(controller, fund, recipient, amount);
_designate(controller, fund, id, amount);
}
/// Transfers an amount of tokens from the acount of one recipient to the
/// other.
/// Transfers an amount of tokens from one account to the other.
/// Only allowed when the fund is locked.
function transfer(
Fund fund,
Recipient from,
Recipient to,
AccountId from,
AccountId to,
uint128 amount
) public whenNotPaused {
Controller controller = Controller.wrap(msg.sender);
_transfer(controller, fund, from, to, amount);
}
/// Transfers tokens from the account of one recipient to the other over time.
/// 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.
@ -154,31 +169,31 @@ contract Vault is VaultBase, Pausable, Ownable {
/// fund unlocks, even if the lock expiry time is extended to its maximum.
function flow(
Fund fund,
Recipient from,
Recipient to,
AccountId from,
AccountId to,
TokensPerSecond rate
) public whenNotPaused {
Controller controller = Controller.wrap(msg.sender);
_flow(controller, fund, from, to, rate);
}
/// Burns an amount of designated tokens from the account of the recipient.
/// Burns an amount of designated tokens from the account.
/// Only allowed when the fund is locked.
function burnDesignated(
Fund fund,
Recipient recipient,
AccountId account,
uint128 amount
) public whenNotPaused {
Controller controller = Controller.wrap(msg.sender);
_burnDesignated(controller, fund, recipient, amount);
_burnDesignated(controller, fund, account, amount);
}
/// Burns all tokens from the account of the recipient.
/// 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(Fund fund, Recipient recipient) public whenNotPaused {
function burnAccount(Fund fund, AccountId account) public whenNotPaused {
Controller controller = Controller.wrap(msg.sender);
_burnAccount(controller, fund, recipient);
_burnAccount(controller, fund, account);
}
/// Burns all tokens from all accounts in a fund.
@ -188,23 +203,28 @@ contract Vault is VaultBase, Pausable, Ownable {
_burnFund(controller, fund);
}
/// Transfers all ERC20 tokens in the recipient's account out of the vault to
/// the recipient address.
/// Transfers all ERC20 tokens in the account out of the vault to the account
/// owner.
/// Only allowed when the fund is unlocked.
/// The recipient can also withdraw itself, so when designing a smart
/// 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(Fund fund, Recipient recipient) public whenNotPaused {
function withdraw(Fund fund, AccountId account) public whenNotPaused {
Controller controller = Controller.wrap(msg.sender);
_withdraw(controller, fund, recipient);
_withdraw(controller, fund, account);
}
/// Allows a recipient to withdraw its tokens from a fund directly, bypassing
/// the need to ask the controller of the fund to initiate the withdrawal.
/// 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, Fund fund) public {
Recipient recipient = Recipient.wrap(msg.sender);
_withdraw(controller, fund, recipient);
function withdrawByRecipient(
Controller controller,
Fund fund,
AccountId account
) public {
(address holder, ) = Accounts.decodeId(account);
_withdraw(controller, fund, account);
}
function pause() public onlyOwner {

View File

@ -4,6 +4,10 @@ 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;
@ -37,6 +41,26 @@ library Accounts {
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(

View File

@ -24,17 +24,17 @@ import "./Locks.sol";
/// The account invariant ensures that the outgoing token flow can be sustained
/// for the maximum time that a fund can be locked:
///
/// ( controller Controller, fund Fund, recipient Recipient:
/// ( controller Controller, fund Fund, account AccountId:
/// flow.outgoing * (lock.maximum - flow.updated) <= balance.available
/// where lock = _locks[controller][fund])
/// and flow = _accounts[controller][fund][recipient].flow
/// and balance = _accounts[controller][fund][recipient].balance
/// and flow = _accounts[controller][fund][account].flow
/// and balance = _accounts[controller][fund][account].balance
///
/// The flow invariant ensures that incoming and outgoing flow rates match:
///
/// ( controller Controller, fund Fund:
/// ( recipient Recipient: accounts[recipient].flow.incoming) =
/// ( recipient Recipient: accounts[recipient].flow.outgoing)
/// ( account AccountId: accounts[account].flow.incoming) =
/// ( account AccountId: accounts[account].flow.outgoing)
/// where accounts = _accounts[controller][fund])
///
abstract contract VaultBase {
@ -48,13 +48,11 @@ abstract contract VaultBase {
type Controller is address;
/// Unique identifier for a fund, chosen by the controller
type Fund is bytes32;
/// Receives the balance of an account when withdrawing
type Recipient is address;
/// Each fund has its own time lock
mapping(Controller => mapping(Fund => Lock)) private _locks;
/// Each recipient has its own account in a fund
mapping(Controller => mapping(Fund => mapping(Recipient => Account)))
/// Each account holder has its own set of accounts in a fund
mapping(Controller => mapping(Fund => mapping(AccountId => Account)))
private _accounts;
constructor(IERC20 token) {
@ -78,17 +76,17 @@ abstract contract VaultBase {
function _getBalance(
Controller controller,
Fund fund,
Recipient recipient
AccountId id
) internal view returns (Balance memory) {
Lock memory lock = _locks[controller][fund];
LockStatus lockStatus = lock.status();
if (lockStatus == LockStatus.Locked) {
Account memory account = _accounts[controller][fund][recipient];
Account memory account = _accounts[controller][fund][id];
account.update(Timestamps.currentTime());
return account.balance;
}
if (lockStatus == LockStatus.Unlocked) {
Account memory account = _accounts[controller][fund][recipient];
Account memory account = _accounts[controller][fund][id];
account.update(lock.expiry);
return account.balance;
}
@ -125,13 +123,13 @@ abstract contract VaultBase {
function _deposit(
Controller controller,
Fund fund,
Recipient recipient,
AccountId id,
uint128 amount
) internal {
Lock storage lock = _locks[controller][fund];
require(lock.status() == LockStatus.Locked, VaultFundNotLocked());
Account storage account = _accounts[controller][fund][recipient];
Account storage account = _accounts[controller][fund][id];
account.balance.available += amount;
lock.value += amount;
@ -146,27 +144,27 @@ abstract contract VaultBase {
function _designate(
Controller controller,
Fund fund,
Recipient recipient,
AccountId id,
uint128 amount
) internal {
Lock memory lock = _locks[controller][fund];
require(lock.status() == LockStatus.Locked, VaultFundNotLocked());
Account memory account = _accounts[controller][fund][recipient];
Account memory account = _accounts[controller][fund][id];
require(amount <= account.balance.available, VaultInsufficientBalance());
account.balance.available -= amount;
account.balance.designated += amount;
_checkAccountInvariant(account, lock);
_accounts[controller][fund][recipient] = account;
_accounts[controller][fund][id] = account;
}
function _transfer(
Controller controller,
Fund fund,
Recipient from,
Recipient to,
AccountId from,
AccountId to,
uint128 amount
) internal {
Lock memory lock = _locks[controller][fund];
@ -186,8 +184,8 @@ abstract contract VaultBase {
function _flow(
Controller controller,
Fund fund,
Recipient from,
Recipient to,
AccountId from,
AccountId to,
TokensPerSecond rate
) internal {
Lock memory lock = _locks[controller][fund];
@ -206,13 +204,13 @@ abstract contract VaultBase {
function _burnDesignated(
Controller controller,
Fund fund,
Recipient recipient,
AccountId id,
uint128 amount
) internal {
Lock storage lock = _locks[controller][fund];
require(lock.status() == LockStatus.Locked, VaultFundNotLocked());
Account storage account = _accounts[controller][fund][recipient];
Account storage account = _accounts[controller][fund][id];
require(account.balance.designated >= amount, VaultInsufficientBalance());
account.balance.designated -= amount;
@ -225,18 +223,18 @@ abstract contract VaultBase {
function _burnAccount(
Controller controller,
Fund fund,
Recipient recipient
AccountId id
) internal {
Lock storage lock = _locks[controller][fund];
require(lock.status() == LockStatus.Locked, VaultFundNotLocked());
Account memory account = _accounts[controller][fund][recipient];
Account memory account = _accounts[controller][fund][id];
require(account.flow.incoming == account.flow.outgoing, VaultFlowNotZero());
uint128 amount = account.balance.available + account.balance.designated;
lock.value -= amount;
delete _accounts[controller][fund][recipient];
delete _accounts[controller][fund][id];
_token.safeTransfer(address(0xdead), amount);
}
@ -250,15 +248,11 @@ abstract contract VaultBase {
_token.safeTransfer(address(0xdead), lock.value);
}
function _withdraw(
Controller controller,
Fund fund,
Recipient recipient
) internal {
function _withdraw(Controller controller, Fund fund, AccountId id) internal {
Lock memory lock = _locks[controller][fund];
require(lock.status() == LockStatus.Unlocked, VaultFundNotUnlocked());
Account memory account = _accounts[controller][fund][recipient];
Account memory account = _accounts[controller][fund][id];
account.update(lock.expiry);
uint128 amount = account.balance.available + account.balance.designated;
@ -270,9 +264,10 @@ abstract contract VaultBase {
_locks[controller][fund] = lock;
}
delete _accounts[controller][fund][recipient];
delete _accounts[controller][fund][id];
_token.safeTransfer(Recipient.unwrap(recipient), amount);
(address owner, ) = Accounts.decodeId(id);
_token.safeTransfer(owner, amount);
}
function _checkLockInvariant(Lock memory lock) private pure {

File diff suppressed because it is too large Load Diff