diff --git a/contracts/Portfolio.sol b/contracts/Portfolio.sol index 850bf0a7..ed096361 100644 --- a/contracts/Portfolio.sol +++ b/contracts/Portfolio.sol @@ -3,6 +3,8 @@ pragma solidity 0.8.19; import "solmate/tokens/ERC1155.sol"; import "./libraries/PortfolioLib.sol"; +import "./libraries/PositionLib.sol"; +import "./libraries/PoolLib.sol"; import "./interfaces/IERC20.sol"; import "./interfaces/IPortfolio.sol"; import "./interfaces/IPortfolioRegistry.sol"; @@ -73,6 +75,8 @@ contract Portfolio is ERC1155, IPortfolio { /// @inheritdoc IPortfolioState mapping(uint64 => PortfolioPool) public pools; + mapping(address => mapping(uint64 => PortfolioPosition)) public positions; + /// @inheritdoc IPortfolioState mapping(address => mapping(address => uint24)) public getPairId; @@ -298,6 +302,7 @@ contract Portfolio is ERC1155, IPortfolio { _postLock(); } + /// @inheritdoc IPortfolioActions function allocate( bool useMax, @@ -461,8 +466,12 @@ contract Portfolio is ERC1155, IPortfolio { * If allocating to an instantiated pool, a minimum amount of liquidity is permanently * burned to prevent the pool from reaching 0 liquidity. */ - function _changeLiquidity(ChangeLiquidityParams memory args) internal { - PortfolioPool storage pool = pools[args.poolId]; + function _changeLiquidity(ChangeLiquidityParams memory args) internal returns (uint256 feeAsset, uint256 feeQuote, uint256 invariantGrowth) { + (PortfolioPool storage pool, PortfolioPosition storage position) = (pools[args.poolId], positions[args.owner][args.poolId]); + + (feeAsset, feeQuote, invariantGrowth) = position.syncPositionFees( + pool.feeGrowthGlobalAsset, pool.feeGrowthGlobalQuote, pool.invariantGrowthGlobal + ); (uint128 deltaAssetWad, uint128 deltaQuoteWad) = (args.deltaAsset.safeCastTo128(), args.deltaQuote.safeCastTo128()); @@ -496,6 +505,7 @@ contract Portfolio is ERC1155, IPortfolio { _burn(args.owner, args.poolId, uint256(-int256(positionLiquidity))); } + position.changePositionLiquidity(args.timestamp, args.deltaLiquidity); pools[args.poolId].changePoolLiquidity(args.deltaLiquidity); (address asset, address quote) = (args.tokenAsset, args.tokenQuote); @@ -549,11 +559,13 @@ contract Portfolio is ERC1155, IPortfolio { inter.decimalsOutput = pair.decimalsQuote; inter.tokenInput = pair.tokenAsset; inter.tokenOutput = pair.tokenQuote; + inter.feeGrowthGlobal = pool.feeGrowthGlobalAsset; } else { inter.decimalsInput = pair.decimalsQuote; inter.decimalsOutput = pair.decimalsAsset; inter.tokenInput = pair.tokenQuote; inter.tokenOutput = pair.tokenAsset; + inter.feeGrowthGlobal = pool.feeGrowthGlobalQuote; } // Overwrites the input argument with the token surplus, if available. @@ -568,6 +580,7 @@ contract Portfolio is ERC1155, IPortfolio { inter.prevInvariant = invariant; inter.amountInputUnit = input; inter.amountOutputUnit = output; + inter.liquidity = pool.liquidity; // Converts input and output amounts to WAD units for the swap math. inter = inter.toWad(); @@ -586,13 +599,9 @@ contract Portfolio is ERC1155, IPortfolio { ); { - // Use the priority fee if the pool controller is the caller. - uint256 feeBps = pool.controller == msg.sender - ? pool.priorityFeeBasisPoints - : pool.feeBasisPoints; - // Compute the respective fee and protocol fee amounts. // Compute the reserves after applying the desired swap amounts and fees. + bool sellAsset = args.sellAsset; uint256 adjustedVirtualX; uint256 adjustedVirtualY; ( @@ -601,14 +610,14 @@ contract Portfolio is ERC1155, IPortfolio { adjustedVirtualX, adjustedVirtualY ) = orderInWad.computeSwapResult( - pool.virtualX, pool.virtualY, feeBps, protocolFee + pool.virtualX, pool.virtualY, pool.feeBasisPoints, protocolFee ); // ====== Invariant Check ====== // bool validInvariant; (validInvariant, inter.nextInvariant) = strategy.validateSwap( - poolId, inter.prevInvariant, adjustedVirtualX, adjustedVirtualY + poolId, inter.prevInvariant, adjustedVirtualX, adjustedVirtualY ); if (!validInvariant) { @@ -628,6 +637,17 @@ contract Portfolio is ERC1155, IPortfolio { inter.amountInputUnit - inter.protocolFeeAmountUnit, inter.amountOutputUnit ); + // returns false if the invariant is > 0 + bool valid = strategy.checkInvariant(poolId, pool); + if (!valid) { + // If the invariant is > 0, the pool is segmented and the fees are not reinvested. + pool.adjustReserves( + !args.sellAsset, + 0, + inter.feeAmountUnit + ); + inter.feeGrowthGlobal += inter.feeAmountUnit; + } // Increasing reserves requires Portfolio's balance of tokens to also increases by the end of `settlement`. _increaseReserves(inter.tokenInput, inter.amountInputUnit); @@ -737,8 +757,6 @@ contract Portfolio is ERC1155, IPortfolio { reserveX: reserveXPerWad, reserveY: reserveYPerWad, feeBasisPoints: feeBasisPoints, - priorityFeeBasisPoints: priorityFeeBasisPoints, - controller: controller, strategy: strategy }); diff --git a/contracts/PositionRenderer.sol b/contracts/PositionRenderer.sol index 6cf66048..673b3d94 100644 --- a/contracts/PositionRenderer.sol +++ b/contracts/PositionRenderer.sol @@ -143,7 +143,7 @@ contract PositionRenderer { uint16 feeBasisPoints, uint16 priorityFeeBasisPoints, address controller, - address strategy + address strategy,,, ) = IPortfolio(msg.sender).pools(uint64(id)); uint256 spotPriceWad = IPortfolio(msg.sender).getSpotPrice(uint64(id)); diff --git a/contracts/interfaces/IPortfolio.sol b/contracts/interfaces/IPortfolio.sol index a776e19d..75817f64 100644 --- a/contracts/interfaces/IPortfolio.sol +++ b/contracts/interfaces/IPortfolio.sol @@ -266,7 +266,10 @@ interface IPortfolioState { uint16 feeBasisPoints, uint16 priorityFeeBasisPoints, address controller, - address strategy + address strategy, + uint256 invariantGrowthGlobal, + uint256 feeGrowthGlobalAsset, + uint256 feeGrowthGlobalQuote ); } diff --git a/contracts/interfaces/IStrategy.sol b/contracts/interfaces/IStrategy.sol index 88361904..5e0ac9d7 100644 --- a/contracts/interfaces/IStrategy.sol +++ b/contracts/interfaces/IStrategy.sol @@ -2,7 +2,9 @@ pragma solidity >=0.8.0; import { Order } from "../libraries/SwapLib.sol"; +import { SwapState } from "../libraries/SwapLib.sol"; import { IPortfolioStrategy } from "./IPortfolio.sol"; +import { PortfolioPool } from "../libraries/PoolLib.sol"; /** * @title @@ -85,4 +87,22 @@ interface IStrategy is IPortfolioStrategy { uint256 reserveX, uint256 reserveY ) external view returns (bool, int256); + + /** + * @notice + * Checks the validity of the invariant of the in-memory pool during swap execution. + * + * @dev + * Critical function that is responsible for the economic validity of the protocol. + * Swaps should not push the invariant over 0 in many pools + * This tells portfolio if it should segment fees or not + * + * @param poolId Id of the pool. + * @param pool intermediate pool struct in swap execution + * @return success Whether the invariant is positive. + */ + function checkInvariant( + uint64 poolId, + PortfolioPool memory pool + ) external view returns (bool); } diff --git a/contracts/libraries/AssemblyLib.sol b/contracts/libraries/AssemblyLib.sol index df226eed..7d2d3c36 100644 --- a/contracts/libraries/AssemblyLib.sol +++ b/contracts/libraries/AssemblyLib.sol @@ -173,4 +173,23 @@ library AssemblyLib { upper = data >> 4; lower = data & 0x0f; } + + /** + * @notice Computes the difference between two checkpoints. + * @dev Underflows. + * @custom:example + * ``` + * uint256 distance = computeCheckpointDistance(50, 25); + * assertEq(distance, 25); + * ``` + */ + function computeCheckpointDistance( + uint256 present, + uint256 past + ) internal pure returns (uint256 distance) { + // Underflow by design, as these are checkpoints which can measure the distance even if underflowed. + assembly { + distance := sub(present, past) + } + } } diff --git a/contracts/libraries/PoolLib.sol b/contracts/libraries/PoolLib.sol index 526415a3..37de0b4c 100644 --- a/contracts/libraries/PoolLib.sol +++ b/contracts/libraries/PoolLib.sol @@ -206,6 +206,9 @@ struct PortfolioPool { uint16 priorityFeeBasisPoints; address controller; // Address that can call `changeParameters()`. address strategy; + uint256 invariantGrowthGlobal; // Cumulative sum of positive invariant growth. + uint256 feeGrowthGlobalAsset; // Cumulative sum of fee's denominated in the `asset` with positive invariant. + uint256 feeGrowthGlobalQuote; // Cumulative sum of fee's denominated in the `quote` with positive invariant. } // ----------------- // @@ -216,8 +219,6 @@ function createPool( uint256 reserveX, uint256 reserveY, uint256 feeBasisPoints, - uint256 priorityFeeBasisPoints, - address controller, address strategy ) { // Check if the pool has already been created. @@ -235,17 +236,6 @@ function createPool( } self.feeBasisPoints = feeBasisPoints.safeCastTo16(); - // Controller is not required, so it can remain uninitialized at the zero address. - bool controlled = controller != address(0); - if (controlled) { - if (!priorityFeeBasisPoints.isBetween(MIN_FEE, feeBasisPoints)) { - revert PoolLib_InvalidPriorityFee(priorityFeeBasisPoints); - } - - self.controller = controller; - self.priorityFeeBasisPoints = priorityFeeBasisPoints.safeCastTo16(); - } - self.strategy = strategy; } diff --git a/contracts/libraries/PositionLib.sol b/contracts/libraries/PositionLib.sol new file mode 100644 index 00000000..94379154 --- /dev/null +++ b/contracts/libraries/PositionLib.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.19; + +import "solmate/utils/SafeCastLib.sol"; +import "solmate/utils/FixedPointMathLib.sol"; +import "./AssemblyLib.sol"; + +using { + changePositionLiquidity, + syncPositionFees, + getTimeSinceChanged +} for PortfolioPosition global; + +struct PortfolioPosition { + uint128 freeLiquidity; + uint32 lastTimestamp; + uint256 invariantGrowthLast; // Increases when the invariant increases from a positive value. + uint256 feeGrowthAssetLast; + uint256 feeGrowthQuoteLast; + uint128 tokensOwedAsset; + uint128 tokensOwedQuote; + uint128 invariantOwed; // Not used by Portfolio, but can be used by a pool controller. +} + +/** + * @dev Liquidity must be altered after syncing positions and not before. + */ +function syncPositionFees( + PortfolioPosition storage self, + uint256 feeGrowthAsset, + uint256 feeGrowthQuote, + uint256 invariantGrowth +) + returns ( + uint256 feeAssetEarned, + uint256 feeQuoteEarned, + uint256 feeInvariantEarned + ) +{ + // fee growth current - position fee growth last + uint256 differenceAsset = AssemblyLib.computeCheckpointDistance( + feeGrowthAsset, self.feeGrowthAssetLast + ); + uint256 differenceQuote = AssemblyLib.computeCheckpointDistance( + feeGrowthQuote, self.feeGrowthQuoteLast + ); + uint256 differenceInvariant = AssemblyLib.computeCheckpointDistance( + invariantGrowth, self.invariantGrowthLast + ); + + // fee growth per liquidity * position liquidity + feeAssetEarned = + FixedPointMathLib.mulWadDown(differenceAsset, self.freeLiquidity); + feeQuoteEarned = + FixedPointMathLib.mulWadDown(differenceQuote, self.freeLiquidity); + feeInvariantEarned = + FixedPointMathLib.mulWadDown(differenceInvariant, self.freeLiquidity); + + self.feeGrowthAssetLast = feeGrowthAsset; + self.feeGrowthQuoteLast = feeGrowthQuote; + self.invariantGrowthLast = invariantGrowth; + + self.tokensOwedAsset += SafeCastLib.safeCastTo128(feeAssetEarned); + self.tokensOwedQuote += SafeCastLib.safeCastTo128(feeQuoteEarned); + self.invariantOwed += SafeCastLib.safeCastTo128(feeInvariantEarned); +} + +function changePositionLiquidity( + PortfolioPosition storage self, + uint256 timestamp, + int128 liquidityDelta +) { + self.lastTimestamp = uint32(timestamp); + self.freeLiquidity = + AssemblyLib.addSignedDelta(self.freeLiquidity, liquidityDelta); +} + +function getTimeSinceChanged( + PortfolioPosition memory self, + uint256 timestamp +) pure returns (uint256 distance) { + return timestamp - self.lastTimestamp; +} diff --git a/contracts/libraries/SwapLib.sol b/contracts/libraries/SwapLib.sol index 0cc10b1f..b5a50dc1 100644 --- a/contracts/libraries/SwapLib.sol +++ b/contracts/libraries/SwapLib.sol @@ -201,6 +201,10 @@ struct SwapState { address tokenOutput; uint8 decimalsInput; uint8 decimalsOutput; + uint256 feeGrowthGlobal; + int256 invariantGrowthGlobal; + uint256 liquidity; + bool segmentFees; } using { toWad, fromWad } for SwapState global; diff --git a/contracts/strategies/NormalStrategy.sol b/contracts/strategies/NormalStrategy.sol index 1ce03ef0..adc15e6c 100644 --- a/contracts/strategies/NormalStrategy.sol +++ b/contracts/strategies/NormalStrategy.sol @@ -6,6 +6,7 @@ import "../libraries/BisectionLib.sol"; import "../libraries/PortfolioLib.sol"; import "./INormalStrategy.sol"; import "./NormalStrategyLib.sol"; +import "forge-std/console2.sol"; /// @dev Emitted when a hook is called by a non-portfolio address. error NormalStrategy_NotPortfolio(); @@ -46,7 +47,7 @@ contract NormalStrategy is INormalStrategy { _; } - // ====== Required ====== // + // ====== Required ====== / /// @inheritdoc IStrategy function afterCreate( @@ -178,8 +179,7 @@ contract NormalStrategy is INormalStrategy { sellAsset: sellAsset }), timestamp: block.timestamp, - protocolFee: IPortfolio(portfolio).protocolFee(), - swapper: swapper + protocolFee: IPortfolio(portfolio).protocolFee() }); uint256 outputDec = sellAsset ? pair.decimalsQuote : pair.decimalsAsset; @@ -292,13 +292,23 @@ contract NormalStrategy is INormalStrategy { config: configs[order.poolId], order: order, timestamp: timestamp, - protocolFee: IPortfolio(portfolio).protocolFee(), - swapper: swapper + protocolFee: IPortfolio(portfolio).protocolFee() }); success = _validateSwap(prevInvariant, postInvariant); } + function checkInvariant(uint64 poolId, PortfolioPool memory pool) + public + view + returns (bool) + { + int256 invariant = pool.getInvariantDown(configs[poolId]); + console2.log("invariant in checkInvariant", invariant); + return invariant < 0; + + } + /// @inheritdoc IPortfolioStrategy function getInvariant(uint64 poolId) public diff --git a/contracts/strategies/NormalStrategyLib.sol b/contracts/strategies/NormalStrategyLib.sol index 3f5bb482..69be56cd 100644 --- a/contracts/strategies/NormalStrategyLib.sol +++ b/contracts/strategies/NormalStrategyLib.sol @@ -737,8 +737,7 @@ library NormalStrategyLib { PortfolioConfig memory config, Order memory order, uint256 timestamp, - uint256 protocolFee, - address swapper + uint256 protocolFee ) internal view @@ -754,9 +753,7 @@ library NormalStrategyLib { // Compute the next invariant if the swap amounts are non zero. (uint256 reserveX, uint256 reserveY) = (self.virtualX, self.virtualY); - uint256 feeBps = swapper == self.controller - ? self.priorityFeeBasisPoints - : self.feeBasisPoints; + uint256 feeBps = self.feeBasisPoints; // Compute the adjusted reserves. (,, reserveX, reserveY) = @@ -785,11 +782,10 @@ library NormalStrategyLib { PortfolioConfig memory config, Order memory order, uint256 timestamp, - uint256 protocolFee, - address swapper + uint256 protocolFee ) internal view returns (uint256 amountOutWad) { (uint256 independentReserve, int256 prevInv, int256 postInv) = - getSwapInvariants(self, config, order, timestamp, protocolFee, swapper); + getSwapInvariants(self, config, order, timestamp, protocolFee); NormalCurve memory curve = transform(config); curve.invariant = prevInv; diff --git a/test/Configuration.sol b/test/Configuration.sol index 0fe6d870..271ab5da 100644 --- a/test/Configuration.sol +++ b/test/Configuration.sol @@ -52,7 +52,7 @@ error Configuration_FuzzInvalidKey(bytes32 what); address constant Configuration_DEFAULT_CONTROLLER = address(0); address constant Configuration_DEFAULT_STRATEGY = address(0); -uint16 constant Configuration_DEFAULT_FEE = 30; +uint16 constant Configuration_DEFAULT_FEE = 1; uint16 constant Configuration_DEFAULT_PRIORITY_FEE = 0; /// @dev Instantiates a configuration with default values. diff --git a/test/TestPortfolioAllocate.t.sol b/test/TestPortfolioAllocate.t.sol index 5782d893..2571576f 100644 --- a/test/TestPortfolioAllocate.t.sol +++ b/test/TestPortfolioAllocate.t.sol @@ -59,7 +59,7 @@ contract TestPortfolioAllocate is Setup { subject().multicall(data); - (,, uint128 liquidity,,,,,) = subject().pools(poolId); + (,, uint128 liquidity,,,,,,,,) = subject().pools(poolId); assertEq(liquidity, 1 ether, "liquidity"); } diff --git a/test/TestPortfolioSwap.t.sol b/test/TestPortfolioSwap.t.sol index 41fd4e82..d61c5735 100644 --- a/test/TestPortfolioSwap.t.sol +++ b/test/TestPortfolioSwap.t.sol @@ -112,6 +112,56 @@ contract TestPortfolioSwap is Setup { assertTrue(post > prev, "physical-balance-did-not-increase"); } + function test_swap_invariant_overtime() + public + defaultConfig + useActor + usePairTokens(10 ether) + allocateSome(1 ether) + { + bool sellAsset = true; + uint128 amtIn = 0.5 ether; + + uint256 i = 0; + while (i < 365) { + vm.warp(block.timestamp + 1 days); + (uint256 reserveAsset, uint256 reserveQuote) = + subject().getPoolReserves(ghost().poolId); + int256 invariant = subject().getInvariant(ghost().poolId); + console2.log("invariant before swap execution", invariant); + sellAsset = reserveQuote > 1 ether ? true : sellAsset; + sellAsset = reserveAsset > 1 ether ? false : sellAsset; + amtIn = uint128(sellAsset ? reserveQuote - 0.4 ether : reserveAsset - 0.4 ether); + // amtIn = 1000; + console.log("amtIn", amtIn); + uint128 amtOut = uint128( + subject().getAmountOut(ghost().poolId, sellAsset, amtIn, actor()) + ); + console.log("sellAsset", sellAsset); + console.log("reserveAsset", reserveAsset); + console.log("reserveQuote", reserveQuote); + console2.log("iter", i); + + + Order memory order = Order({ + useMax: false, + poolId: ghost().poolId, + input: amtIn, + output: amtOut, + sellAsset: sellAsset + }); + + subject().swap(order); + + invariant = subject().getInvariant(ghost().poolId); + console2.log("invariant after swap execution", invariant); + + sellAsset = !sellAsset; + i++; + } + } + + function test_swap_protocol_fee() public defaultConfig diff --git a/test/strategies/NormalConfiguration.sol b/test/strategies/NormalConfiguration.sol index 5431e501..67dd32fb 100644 --- a/test/strategies/NormalConfiguration.sol +++ b/test/strategies/NormalConfiguration.sol @@ -20,8 +20,8 @@ import "contracts/strategies/NormalStrategyLib.sol"; uint256 constant NormalConfiguration_DEFAULT_PRICE = 1 ether; uint256 constant NormalConfiguration_DEFAULT_STRIKE_WAD = 1 ether; -uint256 constant NormalConfiguration_DEFAULT_VOLATILITY_BPS = 1000 wei; // in bps -uint256 constant NormalConfiguration_DEFAULT_DURATION_SEC = 1 days; // in seconds +uint256 constant NormalConfiguration_DEFAULT_VOLATILITY_BPS = 500 wei; // in bps +uint256 constant NormalConfiguration_DEFAULT_DURATION_SEC = 365 days + 100; // in seconds uint256 constant NormalConfiguration_DEFAULT_CREATION_TIMESTAMP = 0; bool constant NormalConfiguration_DEFAULT_IS_PERPETUAL = false;