vault: split flow into incoming and outgoing

- no need to deal with signed integers anymore
- allows flow to self to designate tokens over time
This commit is contained in:
Mark Spanbroek 2025-02-05 13:56:15 +01:00
parent 297ec7f6b3
commit 450e5308d9
6 changed files with 108 additions and 82 deletions

View File

@ -13,7 +13,7 @@ contract Vault is VaultBase {
) public view returns (uint128) {
Controller controller = Controller.wrap(msg.sender);
Account memory account = _getAccount(controller, fund, recipient);
return account.available + account.designated;
return account.balance.available + account.balance.designated;
}
function getDesignatedBalance(
@ -22,7 +22,7 @@ contract Vault is VaultBase {
) public view returns (uint128) {
Controller controller = Controller.wrap(msg.sender);
Account memory account = _getAccount(controller, fund, recipient);
return account.designated;
return account.balance.designated;
}
function getLock(Fund fund) public view returns (Lock memory) {

View File

@ -1,52 +1,59 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "./TokensPerSecond.sol";
import "./TokenFlows.sol";
import "./Timestamps.sol";
struct Account {
Balance balance;
Flow flow;
}
struct Balance {
uint128 available;
uint128 designated;
TokensPerSecond flow;
Timestamp flowUpdated;
}
struct Flow {
TokensPerSecond outgoing;
TokensPerSecond incoming;
Timestamp updated;
}
library Accounts {
function isValidAt(
using Accounts for Account;
using TokenFlows for TokensPerSecond;
using Timestamps for Timestamp;
function isSolventAt(
Account memory account,
Timestamp timestamp
) internal pure returns (bool) {
if (account.flow < TokensPerSecond.wrap(0)) {
return uint128(-accumulateFlow(account, timestamp)) <= account.available;
Duration duration = account.flow.updated.until(timestamp);
uint128 outgoing = account.flow.outgoing.accumulate(duration);
return outgoing <= account.balance.available;
}
function update(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;
}
function flowIn(Account memory account, TokensPerSecond rate) internal view {
account.update(Timestamps.currentTime());
account.flow.incoming = account.flow.incoming + rate;
}
function flowOut(Account memory account, TokensPerSecond rate) internal view {
account.update(Timestamps.currentTime());
if (rate <= account.flow.incoming) {
account.flow.incoming = account.flow.incoming - rate;
} else {
return true;
account.flow.outgoing = account.flow.outgoing + rate;
account.flow.outgoing = account.flow.outgoing - account.flow.incoming;
account.flow.incoming = TokensPerSecond.wrap(0);
}
}
function at(
Account memory account,
Timestamp timestamp
) internal pure returns (Account memory) {
Account memory result = account;
if (result.flow != TokensPerSecond.wrap(0)) {
int128 accumulated = accumulateFlow(result, timestamp);
if (accumulated >= 0) {
result.designated += uint128(accumulated);
} else {
result.available -= uint128(-accumulated);
}
}
result.flowUpdated = timestamp;
return result;
}
function accumulateFlow(
Account memory account,
Timestamp timestamp
) private pure returns (int128) {
int128 rate = TokensPerSecond.unwrap(account.flow);
Timestamp start = account.flowUpdated;
Timestamp end = timestamp;
uint64 duration = Timestamp.unwrap(end) - Timestamp.unwrap(start);
return rate * int128(uint128(duration));
}
}

View File

@ -2,6 +2,7 @@
pragma solidity 0.8.28;
type Timestamp is uint64;
type Duration is uint64;
using {_timestampEquals as ==} for Timestamp global;
using {_timestampNotEqual as !=} for Timestamp global;
@ -39,4 +40,11 @@ library Timestamps {
return b;
}
}
function until(
Timestamp start,
Timestamp end
) internal pure returns (Duration) {
return Duration.wrap(Timestamp.unwrap(end) - Timestamp.unwrap(start));
}
}

View File

@ -3,22 +3,16 @@ pragma solidity 0.8.28;
import "./Timestamps.sol";
type TokensPerSecond is int128;
type TokensPerSecond is uint96;
using {_tokensPerSecondNegate as -} for TokensPerSecond global;
using {_tokensPerSecondMinus as -} for TokensPerSecond global;
using {_tokensPerSecondPlus as +} for TokensPerSecond global;
using {_tokensPerSecondEquals as ==} for TokensPerSecond global;
using {_tokensPerSecondNotEqual as !=} for TokensPerSecond global;
using {_tokensPerSecondAtLeast as >=} for TokensPerSecond global;
using {_tokensPerSecondAtMost as <=} for TokensPerSecond global;
using {_tokensPerSecondLessThan as <} for TokensPerSecond global;
function _tokensPerSecondNegate(
TokensPerSecond rate
) pure returns (TokensPerSecond) {
return TokensPerSecond.wrap(-TokensPerSecond.unwrap(rate));
}
function _tokensPerSecondMinus(
TokensPerSecond a,
TokensPerSecond b
@ -56,9 +50,25 @@ function _tokensPerSecondAtLeast(
return TokensPerSecond.unwrap(a) >= TokensPerSecond.unwrap(b);
}
function _tokensPerSecondAtMost(
TokensPerSecond a,
TokensPerSecond b
) pure returns (bool) {
return TokensPerSecond.unwrap(a) <= TokensPerSecond.unwrap(b);
}
function _tokensPerSecondLessThan(
TokensPerSecond a,
TokensPerSecond b
) pure returns (bool) {
return TokensPerSecond.unwrap(a) < TokensPerSecond.unwrap(b);
}
library TokenFlows {
function accumulate(
TokensPerSecond rate,
Duration duration
) internal pure returns (uint128) {
return uint128(TokensPerSecond.unwrap(rate)) * Duration.unwrap(duration);
}
}

View File

@ -5,7 +5,7 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "./Accounts.sol";
import "./Timestamps.sol";
import "./TokensPerSecond.sol";
import "./TokenFlows.sol";
import "./Locks.sol";
using SafeERC20 for IERC20;
@ -46,7 +46,8 @@ abstract contract VaultBase {
Timestamps.currentTime(),
lock.expiry
);
return account.at(timestamp);
account.update(timestamp);
return account;
}
function _lock(
@ -88,7 +89,7 @@ abstract contract VaultBase {
Recipient recipient = Recipient.wrap(from);
Account storage account = _accounts[controller][fund][recipient];
account.available += amount;
account.balance.available += amount;
lock.value += amount;
_token.safeTransferFrom(from, address(this), amount);
@ -105,9 +106,9 @@ abstract contract VaultBase {
Account memory account = _accounts[controller][fund][recipient];
require(amount <= account.available, InsufficientBalance());
account.available -= amount;
account.designated += amount;
require(amount <= account.balance.available, InsufficientBalance());
account.balance.available -= amount;
account.balance.designated += amount;
_checkAccountInvariant(account, lock);
@ -124,17 +125,17 @@ abstract contract VaultBase {
Lock memory lock = _locks[controller][fund];
require(lock.isLocked(), LockRequired());
Account memory senderAccount = _getAccount(controller, fund, from);
Account memory receiverAccount = _getAccount(controller, fund, to);
Account memory sender = _getAccount(controller, fund, from);
Account memory receiver = _getAccount(controller, fund, to);
require(amount <= senderAccount.available, InsufficientBalance());
senderAccount.available -= amount;
receiverAccount.available += amount;
require(amount <= sender.balance.available, InsufficientBalance());
sender.balance.available -= amount;
receiver.balance.available += amount;
_checkAccountInvariant(senderAccount, lock);
_checkAccountInvariant(sender, lock);
_accounts[controller][fund][from] = senderAccount;
_accounts[controller][fund][to] = receiverAccount;
_accounts[controller][fund][from] = sender;
_accounts[controller][fund][to] = receiver;
}
function _flow(
@ -144,21 +145,17 @@ abstract contract VaultBase {
Recipient to,
TokensPerSecond rate
) internal {
require(rate >= TokensPerSecond.wrap(0), NegativeFlow());
Lock memory lock = _locks[controller][fund];
require(lock.isLocked(), LockRequired());
Account memory senderAccount = _getAccount(controller, fund, from);
Account memory receiverAccount = _getAccount(controller, fund, to);
Account memory sender = _accounts[controller][fund][from];
sender.flowOut(rate);
_checkAccountInvariant(sender, lock);
_accounts[controller][fund][from] = sender;
senderAccount.flow = senderAccount.flow - rate;
receiverAccount.flow = receiverAccount.flow + rate;
_checkAccountInvariant(senderAccount, lock);
_accounts[controller][fund][from] = senderAccount;
_accounts[controller][fund][to] = receiverAccount;
Account memory receiver = _accounts[controller][fund][to];
receiver.flowIn(rate);
_accounts[controller][fund][to] = receiver;
}
function _burn(
@ -170,9 +167,12 @@ abstract contract VaultBase {
require(lock.isLocked(), LockRequired());
Account memory account = _getAccount(controller, fund, recipient);
require(account.flow == TokensPerSecond.wrap(0), CannotBurnFlowingTokens());
require(
account.flow.incoming == account.flow.outgoing,
CannotBurnFlowingTokens()
);
uint128 amount = account.available + account.designated;
uint128 amount = account.balance.available + account.balance.designated;
lock.value -= amount;
@ -190,7 +190,7 @@ abstract contract VaultBase {
require(!lock.isLocked(), Locked());
Account memory account = _getAccount(controller, fund, recipient);
uint128 amount = account.available + account.designated;
uint128 amount = account.balance.available + account.balance.designated;
lock.value -= amount;
@ -213,7 +213,7 @@ abstract contract VaultBase {
Account memory account,
Lock memory lock
) private pure {
require(account.isValidAt(lock.maximum), InsufficientBalance());
require(account.isSolventAt(lock.maximum), InsufficientBalance());
}
error InsufficientBalance();
@ -222,6 +222,5 @@ abstract contract VaultBase {
error ExpiryPastMaximum();
error InvalidExpiry();
error LockRequired();
error NegativeFlow();
error CannotBurnFlowingTokens();
}

View File

@ -420,6 +420,15 @@ describe("Vault", function () {
expect(await vault.getDesignatedBalance(fund, address2)).to.equal(21)
})
it("designates tokens that flow back to the sender", async function () {
await vault.flow(fund, address1, address1, 3)
await mine()
const start = await currentTime()
await advanceTimeTo(start + 7)
expect(await vault.getBalance(fund, address1)).to.equal(deposit)
expect(await vault.getDesignatedBalance(fund, address1)).to.equal(21)
})
it("flows longer when lock is extended", async function () {
await vault.flow(fund, address1, address2, 2)
await mine()
@ -435,13 +444,6 @@ describe("Vault", function () {
expect(await getBalance(address2)).to.equal(total)
})
it("rejects negative flows", async function () {
setAutomine(true)
await expect(
vault.flow(fund, address1, address2, -1)
).to.be.revertedWith("NegativeFlow")
})
it("rejects flow when insufficient available tokens", async function () {
setAutomine(true)
await expect(