From 450e5308d94904fb902dbcaa5338d4d7cf963df2 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Wed, 5 Feb 2025 13:56:15 +0100 Subject: [PATCH] vault: split flow into incoming and outgoing - no need to deal with signed integers anymore - allows flow to self to designate tokens over time --- contracts/Vault.sol | 4 +- contracts/vault/Accounts.sol | 77 ++++++++++--------- contracts/vault/Timestamps.sol | 8 ++ .../{TokensPerSecond.sol => TokenFlows.sol} | 26 +++++-- contracts/vault/VaultBase.sol | 59 +++++++------- test/Vault.tests.js | 16 ++-- 6 files changed, 108 insertions(+), 82 deletions(-) rename contracts/vault/{TokensPerSecond.sol => TokenFlows.sol} (76%) diff --git a/contracts/Vault.sol b/contracts/Vault.sol index 610bd59..dfa7de0 100644 --- a/contracts/Vault.sol +++ b/contracts/Vault.sol @@ -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) { diff --git a/contracts/vault/Accounts.sol b/contracts/vault/Accounts.sol index e4135c2..d0ed2d2 100644 --- a/contracts/vault/Accounts.sol +++ b/contracts/vault/Accounts.sol @@ -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)); - } } diff --git a/contracts/vault/Timestamps.sol b/contracts/vault/Timestamps.sol index 6e2dcda..5cc4058 100644 --- a/contracts/vault/Timestamps.sol +++ b/contracts/vault/Timestamps.sol @@ -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)); + } } diff --git a/contracts/vault/TokensPerSecond.sol b/contracts/vault/TokenFlows.sol similarity index 76% rename from contracts/vault/TokensPerSecond.sol rename to contracts/vault/TokenFlows.sol index 0dc4a30..579b55c 100644 --- a/contracts/vault/TokensPerSecond.sol +++ b/contracts/vault/TokenFlows.sol @@ -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); + } +} diff --git a/contracts/vault/VaultBase.sol b/contracts/vault/VaultBase.sol index 2e2773f..42a0015 100644 --- a/contracts/vault/VaultBase.sol +++ b/contracts/vault/VaultBase.sol @@ -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(); } diff --git a/test/Vault.tests.js b/test/Vault.tests.js index d286e94..4ab0183 100644 --- a/test/Vault.tests.js +++ b/test/Vault.tests.js @@ -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(