Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions contracts/HubPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable {
}

modifier unpaused() {
require(!paused, "Proposal process has been paused");
require(!paused, "Contract is paused");
_;
}

Expand Down Expand Up @@ -420,6 +420,21 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable {
emit L2TokenDisabledForLiquidityProvision(l1Token, pooledTokens[l1Token].lpToken);
}

/**
* @notice Enables the owner of the protocol to haircut reserves in the event of an irrecoverable loss of funds on
* one of the L2s. Consider funds are leant out onto a L2 that dies irrecoverably. This value will offset the
* exchangeRateCurrent such that all LPs receive a pro rata loss of the the reserves. Should be used in conjunction
* with pause logic to prevent LPs from adding/withdrawing liquidity during the haircut process.
* Callable only by owner.
* @param l1Token Token to execute the haircut on.
* @param haircutAmount The amount of reserves to haircut the LPs by.
*/
function haircutReserves(address l1Token, int256 haircutAmount) public onlyOwner nonReentrant {
// Note that we do not call sync first in this method. The Owner should call this manually before haircutting.
// This is done in the event sync is reverting due to too low balanced in the contract relative to bond amount.
pooledTokens[l1Token].utilizedReserves -= haircutAmount;
}

/*************************************************
* LIQUIDITY PROVIDER FUNCTIONS *
*************************************************/
Expand All @@ -436,7 +451,7 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable {
* @param l1Token Token to deposit into this contract.
* @param l1TokenAmount Amount of liquidity to provide.
*/
function addLiquidity(address l1Token, uint256 l1TokenAmount) public payable override nonReentrant {
function addLiquidity(address l1Token, uint256 l1TokenAmount) public payable override nonReentrant unpaused {
require(pooledTokens[l1Token].isEnabled, "Token not enabled");
// If this is the weth pool and the caller sends msg.value then the msg.value must match the l1TokenAmount.
// Else, msg.value must be set to 0.
Expand Down Expand Up @@ -467,7 +482,7 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable {
address l1Token,
uint256 lpTokenAmount,
bool sendEth
) public override nonReentrant {
) public override nonReentrant unpaused {
require(address(weth) == l1Token || !sendEth, "Cant send eth");
uint256 l1TokensToReturn = (lpTokenAmount * _exchangeRateCurrent(l1Token)) / 1e18;

Expand Down
10 changes: 10 additions & 0 deletions test/HubPool.LiquidityProvision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,14 @@ describe("HubPool Liquidity Provision", function () {
expect(await usdc.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(scaledAmountToLp.div(2)));
expect(await usdc.balanceOf(hubPool.address)).to.equal(scaledAmountToLp.div(2));
});
it("Pause disables any liquidity action", async function () {
await hubPool.connect(owner).setPaused(true);
await weth.connect(liquidityProvider).approve(hubPool.address, amountToLp);
await expect(hubPool.connect(liquidityProvider).addLiquidity(weth.address, amountToLp)).to.be.revertedWith(
"Contract is paused"
);
await expect(
hubPool.connect(liquidityProvider).removeLiquidity(weth.address, amountToLp, false)
).to.be.revertedWith("Contract is paused");
});
});
60 changes: 60 additions & 0 deletions test/HubPool.LiquidityProvisionHaircut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, ethers, Contract, SignerWithAddress, seedWallet, toWei } from "./utils";
import * as consts from "./constants";
import { hubPoolFixture, enableTokensForLP } from "./fixtures/HubPool.Fixture";
import { constructSingleChainTree } from "./MerkleLib.utils";

let hubPool: Contract, weth: Contract, timer: Contract;
let owner: SignerWithAddress, dataWorker: SignerWithAddress, liquidityProvider: SignerWithAddress;

describe("HubPool Liquidity Provision Haircut", 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("Haircut can correctly offset exchange rate current to encapsulate lossed tokens", async function () {
const { tokensSendToL2, leaves, tree } = await constructSingleChainTree(weth.address);

await hubPool
.connect(dataWorker)
.proposeRootBundle([3117], 1, tree.getHexRoot(), consts.mockTreeRoot, consts.mockSlowRelayRoot);
await timer.setCurrentTime(Number(await timer.getCurrentTime()) + consts.refundProposalLiveness + 1);
await hubPool.connect(dataWorker).executeRootBundle(...Object.values(leaves[0]), tree.getHexProof(leaves[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*7201=0.108015 was attributed to LPs.
// The exchange rate is therefore (1000+0.108015)/1000=1.000108015.
expect(await hubPool.callStatic.exchangeRateCurrent(weth.address)).to.equal(toWei(1.000108015));

// At this point if all LP tokens are attempted to be redeemed at the provided exchange rate the call should fail
// as the hub pool is currently waiting for funds to come back over the canonical bridge. they are lent out.
await expect(hubPool.connect(liquidityProvider).removeLiquidity(weth.address, consts.amountToLp, false)).to.be
.reverted;

// Now, consider that the funds sent over the bridge (tokensSendToL2) are actually lost due to the L2 breaking.
// We now need to haircut the LPs be modifying the exchange rate current such that they get a commensurate
// redemption rate against the lost funds.
await hubPool.haircutReserves(weth.address, tokensSendToL2);
await hubPool.sync(weth.address);

// The exchange rate current should now factor in the loss of funds and should now be less than 1. Taking the amount
// attributed to LPs in fees from the previous calculation and the 100 lost tokens, the exchangeRateCurrent should be:
// (1000+0.108015-100)/1000=0.900108015.
expect(await hubPool.callStatic.exchangeRateCurrent(weth.address)).to.equal(toWei(0.900108015));

// Now, advance time such that all accumulated rewards are accumulated.
await timer.setCurrentTime(Number(await timer.getCurrentTime()) + 10 * 24 * 60 * 60);
await hubPool.exchangeRateCurrent(weth.address); // force state sync.
expect((await hubPool.pooledTokens(weth.address)).undistributedLpFees).to.equal(0);

// Exchange rate should now be the (LPAmount + fees - lostTokens) / LPTokenSupply = (1000+10-100)/1000=0.91
expect(await hubPool.callStatic.exchangeRateCurrent(weth.address)).to.equal(toWei(0.91));
});
});
2 changes: 1 addition & 1 deletion test/HubPool.ProposeRootBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,6 @@ describe("HubPool Root Bundle Proposal", function () {
await hubPool.connect(owner).setPaused(true);
await expect(
hubPool.proposeRootBundle([1, 2, 3], 5, consts.mockTreeRoot, consts.mockTreeRoot, consts.mockSlowRelayRoot)
).to.be.revertedWith("Proposal process has been paused");
).to.be.revertedWith("Contract is paused");
});
});