diff --git a/contracts/HubPool.sol b/contracts/HubPool.sol index 5bf23ff2a..b6b4fa3cb 100644 --- a/contracts/HubPool.sol +++ b/contracts/HubPool.sol @@ -64,7 +64,7 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { mapping(uint256 => CrossChainContract) public crossChainContracts; // Mapping of chainId to the associated adapter and spokePool contracts. - LpTokenFactoryInterface lpTokenFactory; + LpTokenFactoryInterface public lpTokenFactory; FinderInterface public finder; @@ -73,7 +73,15 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { // Interest rate payment that scales the amount of pending fees per second paid to LPs. 0.0000015e18 will pay out // the full amount of fees entitled to LPs in ~ 7.72 days, just over the standard L2 7 day liveness. - uint256 lpFeeRatePerSecond = 1500000000000; + uint256 public lpFeeRatePerSecond = 1500000000000; + + mapping(address => uint256) public unclaimedAccumulatedProtocolFees; + + // Address that captures protocol fees. Accumulated protocol fees can be claimed by this address. + address public protocolFeeCaptureAddress; + + // Percentage of lpFees that are captured by the protocol and claimable by the protocolFeeCaptureAddress. + uint256 public protocolFeeCapturePct; // Token used to bond the data worker for proposing relayer refund bundles. IERC20 public bondToken; @@ -85,6 +93,10 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { // be disputed only during this period of time. Defaults to 2 hours, like the rest of the UMA ecosystem. uint64 public refundProposalLiveness = 7200; + event ProtocolFeeCaptureSet(address indexed newProtocolFeeCaptureAddress, uint256 indexed newProtocolFeeCapturePct); + + event ProtocolFeesCapturedClaimed(address indexed l1Token, uint256 indexed accumulatedFees); + event BondSet(address indexed newBondToken, uint256 newBondAmount); event RefundProposalLivenessSet(uint256 newRefundProposalLiveness); @@ -144,12 +156,23 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { ) Testable(_timer) { lpTokenFactory = _lpTokenFactory; finder = _finder; + protocolFeeCaptureAddress = owner(); } /************************************************* * ADMIN FUNCTIONS * *************************************************/ + function setProtocolFeeCapture(address newProtocolFeeCaptureAddress, uint256 newProtocolFeeCapturePct) + public + onlyOwner + { + require(newProtocolFeeCapturePct <= 1e18, "Bad protocolFeeCapturePct"); + protocolFeeCaptureAddress = newProtocolFeeCaptureAddress; + protocolFeeCapturePct = newProtocolFeeCapturePct; + emit ProtocolFeeCaptureSet(newProtocolFeeCaptureAddress, newProtocolFeeCapturePct); + } + function setBond(IERC20 newBondToken, uint256 newBondAmount) public onlyOwner noActiveRequests { bondToken = newBondToken; bondAmount = newBondAmount; @@ -281,7 +304,7 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { bytes32 poolRebalanceRoot, bytes32 destinationDistributionRoot, bytes32 slowRelayFulfillmentRoot - ) public noActiveRequests { + ) public nonReentrant noActiveRequests { require(poolRebalanceLeafCount > 0, "Bundle must have at least 1 leaf"); uint64 requestExpirationTimestamp = uint64(getCurrentTime() + refundProposalLiveness); @@ -309,7 +332,10 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { ); } - function executeRelayerRefund(PoolRebalanceLeaf memory poolRebalanceLeaf, bytes32[] memory proof) public { + function executeRelayerRefund(PoolRebalanceLeaf memory poolRebalanceLeaf, bytes32[] memory proof) + public + nonReentrant + { require(getCurrentTime() >= refundRequest.requestExpirationTimestamp, "Not passed liveness"); // Verify the leafId in the poolRebalanceLeaf has not yet been claimed. @@ -349,7 +375,7 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { ); } - function disputeRelayerRefund() public { + function disputeRelayerRefund() public nonReentrant { require(getCurrentTime() <= refundRequest.requestExpirationTimestamp, "Request passed liveness"); // Request price from OO and dispute it. @@ -405,6 +431,12 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { delete refundRequest; } + function claimProtocolFeesCaptured(address l1Token) public nonReentrant { + IERC20(l1Token).safeTransfer(protocolFeeCaptureAddress, unclaimedAccumulatedProtocolFees[l1Token]); + emit ProtocolFeesCapturedClaimed(l1Token, unclaimedAccumulatedProtocolFees[l1Token]); + unclaimedAccumulatedProtocolFees[l1Token] = 0; + } + function _getRefundProposalAncillaryData() public view returns (bytes memory ancillaryData) { ancillaryData = AncillaryData.appendKeyValueUint( "", @@ -501,12 +533,8 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { pooledTokens[l1Tokens[i]].liquidReserves -= uint256(netSendAmounts[i]); } - // Assign any LP fees included into the bundle to the pooled token. These LP fees are tracked in the - // undistributedLpFees and within the utilizedReserves. undistributedLpFees is gradually decrease - // over the smear duration to give the LPs their rewards over a period of time. Adding to utilizedReserves - // acts to track these rewards after the smear duration. See _exchangeRateCurrent for more details. - pooledTokens[l1Tokens[i]].undistributedLpFees += bundleLpFees[i]; - pooledTokens[l1Tokens[i]].utilizedReserves += int256(bundleLpFees[i]); + // Allocate LP fees and protocol fees from the bundle to the associated pooled token trackers. + _allocateLpAndProtocolFees(l1Tokens[i], bundleLpFees[i]); } } @@ -592,6 +620,24 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { return (numerator * 1e18) / denominator; } + function _allocateLpAndProtocolFees(address l1Token, uint256 bundleLpFees) internal { + // Calculate the fraction of bundledLpFees that are allocated to the protocol and to the LPs. + uint256 protocolFeesCaptured = (bundleLpFees * protocolFeeCapturePct) / 1e18; + uint256 lpFeesCaptured = bundleLpFees - protocolFeesCaptured; + + // Assign any LP fees included into the bundle to the pooled token. These LP fees are tracked in the + // undistributedLpFees and within the utilizedReserves. undistributedLpFees is gradually decrease + // over the smear duration to give the LPs their rewards over a period of time. Adding to utilizedReserves + // acts to track these rewards after the smear duration. See _exchangeRateCurrent for more details. + if (lpFeesCaptured > 0) { + pooledTokens[l1Token].undistributedLpFees += lpFeesCaptured; + pooledTokens[l1Token].utilizedReserves += int256(lpFeesCaptured); + } + + // If there are any protocol fees, allocate them to the unclaimed protocol tracker amount. + if (protocolFeesCaptured > 0) unclaimedAccumulatedProtocolFees[l1Token] += protocolFeesCaptured; + } + // Added to enable the SpokePool to receive ETH. used when unwrapping WETH. receive() external payable {} } diff --git a/test/HubPool.Fees.ts b/test/HubPool.Fees.ts deleted file mode 100644 index e87edd920..000000000 --- a/test/HubPool.Fees.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { toWei, toBNWei, SignerWithAddress, seedWallet, expect, Contract, ethers } from "./utils"; -import * as consts from "./constants"; -import { hubPoolFixture, enableTokensForLP } from "./HubPool.Fixture"; -import { buildPoolRebalanceLeafTree, buildPoolRebalanceLeafs } from "./MerkleLib.utils"; - -let hubPool: Contract, weth: Contract, timer: Contract; -let owner: SignerWithAddress, dataWorker: SignerWithAddress, liquidityProvider: SignerWithAddress; - -async function constructSimpleTree() { - const wethSendToL2 = toBNWei(100); - const wethAttributeToLps = toBNWei(10); - const leafs = buildPoolRebalanceLeafs( - [consts.repaymentChainId], // repayment chain. In this test we only want to send one token to one chain. - [weth], // l1Token. We will only be sending WETH and DAI to the associated repayment chain. - [[wethAttributeToLps]], // bundleLpFees. Set to 1 ETH and 10 DAI respectively to attribute to the LPs. - [[wethSendToL2]], // netSendAmounts. Set to 100 ETH and 1000 DAI as the amount to send from L1->L2. - [[wethSendToL2]] // runningBalances. Set to 100 ETH and 1000 DAI. - ); - const tree = await buildPoolRebalanceLeafTree(leafs); - - return { wethSendToL2, wethAttributeToLps, leafs, tree }; -} - -describe("HubPool LP fees", function () { - beforeEach(async function () { - [owner, dataWorker, liquidityProvider] = await ethers.getSigners(); - ({ weth, hubPool, timer } = await hubPoolFixture()); - await seedWallet(dataWorker, [], weth, consts.bondAmount.add(consts.finalFee).mul(2)); - await seedWallet(liquidityProvider, [], weth, consts.amountToLp.mul(10)); - - await enableTokensForLP(owner, hubPool, weth, [weth]); - await weth.connect(liquidityProvider).approve(hubPool.address, consts.amountToLp); - await hubPool.connect(liquidityProvider).addLiquidity(weth.address, consts.amountToLp); - await weth.connect(dataWorker).approve(hubPool.address, consts.bondAmount.mul(10)); - }); - - it("Fee tracking variables are correctly updated at the execution of a refund", async function () { - // Before any execution happens liquidity trackers are set as expected. - const pooledTokenInfoPreExecution = await hubPool.pooledTokens(weth.address); - expect(pooledTokenInfoPreExecution.liquidReserves).to.eq(consts.amountToLp); - expect(pooledTokenInfoPreExecution.utilizedReserves).to.eq(0); - expect(pooledTokenInfoPreExecution.undistributedLpFees).to.eq(0); - expect(pooledTokenInfoPreExecution.lastLpFeeUpdate).to.eq(await timer.getCurrentTime()); - expect(pooledTokenInfoPreExecution.isWeth).to.eq(true); - - const { wethSendToL2, wethAttributeToLps, leafs, tree } = await constructSimpleTree(); - - await hubPool - .connect(dataWorker) - .initiateRelayerRefund([3117], 1, tree.getHexRoot(), consts.mockTreeRoot, consts.mockSlowRelayFulfillmentRoot); - await timer.setCurrentTime(Number(await timer.getCurrentTime()) + consts.refundProposalLiveness); - await hubPool.connect(dataWorker).executeRelayerRefund(leafs[0], tree.getHexProof(leafs[0])); - - // Validate the post execution values have updated as expected. Liquid reserves should be the original LPed amount - // minus the amount sent to L2. Utilized reserves should be the amount sent to L2 plus the attribute to LPs. - // Undistributed LP fees should be attribute to LPs. - const pooledTokenInfoPostExecution = await hubPool.pooledTokens(weth.address); - expect(pooledTokenInfoPostExecution.liquidReserves).to.eq(consts.amountToLp.sub(wethSendToL2)); - expect(pooledTokenInfoPostExecution.utilizedReserves).to.eq(wethSendToL2.add(wethAttributeToLps)); - expect(pooledTokenInfoPostExecution.undistributedLpFees).to.eq(wethAttributeToLps); - }); - - it("Exchange rate current correctly attributes fees over the smear period", async function () { - // Fees are designed to be attributed over a period of time so they dont all arrive on L1 as soon as the bundle is - // executed. We can validate that fees are correctly smeared by attributing some and then moving time forward and - // validating that key variable shift as a function of time. - const { leafs, tree } = await constructSimpleTree(); - - // Exchange rate current before any fees are attributed execution should be 1. - expect(await hubPool.callStatic.exchangeRateCurrent(weth.address)).to.eq(toWei(1)); - await hubPool.exchangeRateCurrent(weth.address); - - await hubPool - .connect(dataWorker) - .initiateRelayerRefund([3117], 1, tree.getHexRoot(), consts.mockTreeRoot, consts.mockSlowRelayFulfillmentRoot); - await timer.setCurrentTime(Number(await timer.getCurrentTime()) + consts.refundProposalLiveness); - await hubPool.connect(dataWorker).executeRelayerRefund(leafs[0], tree.getHexProof(leafs[0])); - - // Exchange rate current right after the refund execution should be the amount deposited, grown by the 100 second - // liveness period. Of the 10 ETH attributed to LPs, a total of 10*0.0000015*100=0.0015 was attributed to LPs. - // The exchange rate is therefore (1000+0.0015)/1000=1.0000015. - expect((await hubPool.callStatic.exchangeRateCurrent(weth.address)).toString()).to.eq(toWei(1.0000015)); - - // Validate the state variables are updated accordingly. In particular, undistributedLpFees should have decremented - // by the amount allocated in the previous computation. This should be 10-0.0015=9.9985. - await hubPool.exchangeRateCurrent(weth.address); // force state sync. - expect((await hubPool.pooledTokens(weth.address)).undistributedLpFees).to.eq(toWei(9.9985)); - - // Next, advance time 2 days. Compute the ETH attributed to LPs by multiplying the original amount allocated(10), - // minus the previous computation amount(0.0015) by the smear rate, by the duration to get the second periods - // allocation of(10 - 0.0015) * 0.0000015 * (172800)=2.5916112.The exchange rate should be The sum of the - // liquidity provided and the fees added in both periods as (1000+0.0015+2.5916112)/1000=1.0025931112. - await timer.setCurrentTime(Number(await timer.getCurrentTime()) + 2 * 24 * 60 * 60); - expect((await hubPool.callStatic.exchangeRateCurrent(weth.address)).toString()).to.eq(toWei(1.0025931112)); - - // Again, we can validate that the undistributedLpFees have been updated accordingly. This should be set to the - // original amount (10) minus the two sets of attributed LP fees as 10-0.0015-2.5916112=7.4068888. - await hubPool.exchangeRateCurrent(weth.address); // force state sync. - expect((await hubPool.pooledTokens(weth.address)).undistributedLpFees).to.eq(toWei(7.4068888)); - - // Finally, advance time past the end of the smear period by moving forward 10 days. At this point all LP fees - // should be attributed such that undistributedLpFees=0 and the exchange rate should simply be (1000+10)/1000=1.01. - await timer.setCurrentTime(Number(await timer.getCurrentTime()) + 10 * 24 * 60 * 60); - expect((await hubPool.callStatic.exchangeRateCurrent(weth.address)).toString()).to.eq(toWei(1.01)); - await hubPool.exchangeRateCurrent(weth.address); // force state sync. - expect((await hubPool.pooledTokens(weth.address)).undistributedLpFees).to.eq(toWei(0)); - }); -}); diff --git a/test/HubPool.ProtocolFees.ts b/test/HubPool.ProtocolFees.ts new file mode 100644 index 000000000..0b87c4e65 --- /dev/null +++ b/test/HubPool.ProtocolFees.ts @@ -0,0 +1,81 @@ +import { toWei, toBNWei, SignerWithAddress, seedWallet, expect, Contract, ethers } from "./utils"; +import { mockTreeRoot, finalFee, bondAmount, amountToLp, refundProposalLiveness } from "./constants"; +import { hubPoolFixture, enableTokensForLP } from "./HubPool.Fixture"; +import { constructSingleChainTree } from "./MerkleLib.utils"; + +let hubPool: Contract, weth: Contract, timer: Contract; +let owner: SignerWithAddress, dataWorker: SignerWithAddress, liquidityProvider: SignerWithAddress; + +const initialProtocolFeeCapturePct = toBNWei("0.1"); + +describe("HubPool Protocol fees", function () { + beforeEach(async function () { + [owner, dataWorker, liquidityProvider] = await ethers.getSigners(); + ({ weth, hubPool, timer } = await hubPoolFixture()); + await seedWallet(dataWorker, [], weth, bondAmount.add(finalFee).mul(2)); + await seedWallet(liquidityProvider, [], weth, amountToLp.mul(10)); + + await enableTokensForLP(owner, hubPool, weth, [weth]); + await weth.connect(liquidityProvider).approve(hubPool.address, amountToLp); + await hubPool.connect(liquidityProvider).addLiquidity(weth.address, amountToLp); + await weth.connect(dataWorker).approve(hubPool.address, bondAmount.mul(10)); + + await hubPool.setProtocolFeeCapture(owner.address, initialProtocolFeeCapturePct); + }); + + it("Only owner can set protocol fee capture", async function () { + await expect(hubPool.connect(liquidityProvider).setProtocolFeeCapture(liquidityProvider.address, toWei("0.1"))).to + .be.reverted; + }); + it("Can change protocol fee capture settings", async function () { + expect(await hubPool.callStatic.protocolFeeCaptureAddress()).to.equal(owner.address); + expect(await hubPool.callStatic.protocolFeeCapturePct()).to.equal(initialProtocolFeeCapturePct); + const newPct = toWei("0.1"); + await hubPool.connect(owner).setProtocolFeeCapture(liquidityProvider.address, newPct); + expect(await hubPool.callStatic.protocolFeeCaptureAddress()).to.equal(liquidityProvider.address); + expect(await hubPool.callStatic.protocolFeeCapturePct()).to.equal(newPct); + }); + it("When fee capture pct is not set to zero fees correctly attribute between LPs and the protocol", async function () { + const { leafs, tree, realizedLpFees } = await constructSingleChainTree(weth); + await hubPool.connect(dataWorker).initiateRelayerRefund([3117], 1, tree.getHexRoot(), mockTreeRoot, mockTreeRoot); + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + refundProposalLiveness); + await hubPool.connect(dataWorker).executeRelayerRefund(leafs[0], tree.getHexProof(leafs[0])); + + // 90% of the fees should be attributed to the LPs. + expect((await hubPool.pooledTokens(weth.address)).undistributedLpFees).to.equal( + realizedLpFees.mul(toBNWei("1").sub(initialProtocolFeeCapturePct)).div(toBNWei("1")) + ); + + // 10% of the fees should be attributed to the protocol. + const expectedProtocolFees = realizedLpFees.mul(initialProtocolFeeCapturePct).div(toBNWei("1")); + expect(await hubPool.unclaimedAccumulatedProtocolFees(weth.address)).to.equal(expectedProtocolFees); + + // Protocol should be able to claim their fees. + await expect(() => hubPool.claimProtocolFeesCaptured(weth.address)).to.changeTokenBalance( + weth, + owner, + expectedProtocolFees + ); + + // After claiming, the protocol fees should be zero. + expect(await hubPool.unclaimedAccumulatedProtocolFees(weth.address)).to.equal("0"); + + // Once all the fees have been attributed the correct amount should be claimable by the LPs. + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + 10 * 24 * 60 * 60); // Move time to accumulate all fees. + await hubPool.exchangeRateCurrent(weth.address); // force state sync. + expect((await hubPool.pooledTokens(weth.address)).undistributedLpFees).to.equal(0); + }); + it("When fee capture pct is set to zero all fees accumulate to the LPs", async function () { + await hubPool.setProtocolFeeCapture(owner.address, "0"); + const { leafs, tree, realizedLpFees } = await constructSingleChainTree(weth); + await hubPool.connect(dataWorker).initiateRelayerRefund([3117], 1, tree.getHexRoot(), mockTreeRoot, mockTreeRoot); + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + refundProposalLiveness); + await hubPool.connect(dataWorker).executeRelayerRefund(leafs[0], tree.getHexProof(leafs[0])); + expect((await hubPool.pooledTokens(weth.address)).undistributedLpFees).to.equal(realizedLpFees); + + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + 10 * 24 * 60 * 60); // Move time to accumulate all fees. + await hubPool.exchangeRateCurrent(weth.address); // force state sync. + expect((await hubPool.pooledTokens(weth.address)).undistributedLpFees).to.equal(0); + expect(await hubPool.callStatic.exchangeRateCurrent(weth.address)).to.equal(toWei(1.01)); + }); +}); diff --git a/test/chain-adapters/Arbitrum_Adapter.ts b/test/chain-adapters/Arbitrum_Adapter.ts index b587900e1..768160430 100644 --- a/test/chain-adapters/Arbitrum_Adapter.ts +++ b/test/chain-adapters/Arbitrum_Adapter.ts @@ -89,7 +89,6 @@ describe("Arbitrum Chain Adapter", function () { await hubPool.connect(dataWorker).executeRelayerRefund(leafs[0], tree.getHexProof(leafs[0])); // The correct functions should have been called on the arbitrum contracts. expect(l1ERC20Gateway.outboundTransfer).to.have.been.calledOnce; // One token transfer over the canonical bridge. - expect(l1ERC20Gateway.outboundTransfer).to.have.been.calledWith( dai.address, mockSpoke.address,