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
118 changes: 104 additions & 14 deletions contracts/HubPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ pragma solidity ^0.8.0;
import "@uma/core/contracts/common/implementation/Testable.sol";
import "@uma/core/contracts/common/implementation/Lockable.sol";
import "@uma/core/contracts/common/implementation/MultiCaller.sol";
import "@uma/core/contracts/common/implementation/ExpandedERC20.sol";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: something we might consider for a follow-up. We could swap this out for an ERC20 contract that's optimized for this use case (immutable and singular minter/burner).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point. will add a comment.


import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";

interface WETH9Like {
function withdraw(uint256 wad) external;
Expand All @@ -15,29 +19,50 @@ interface WETH9Like {
}

contract HubPool is Testable, Lockable, MultiCaller, Ownable {
using SafeERC20 for IERC20;
using Address for address;
struct LPToken {
address lpToken;
bool isWeth;
ExpandedERC20 lpToken;
bool isEnabled;
}

WETH9Like public l1Weth;

mapping(address => LPToken) public lpTokens; // Mapping of L1TokenAddress to the associated LPToken.

constructor(address timerAddress) Testable(timerAddress) {}
event LiquidityAdded(
address indexed l1Token,
uint256 amount,
uint256 lpTokensMinted,
address indexed liquidityProvider
);
event LiquidityRemoved(
address indexed l1Token,
uint256 amount,
uint256 lpTokensBurnt,
address indexed liquidityProvider
);

constructor(address _l1Weth, address _timerAddress) Testable(_timerAddress) {
l1Weth = WETH9Like(_l1Weth);
}

/*************************************************
* ADMIN FUNCTIONS *
*************************************************/

// TODO: the two functions below should be called by the Admin contract.
function enableL1TokenForLiquidityProvision(
address l1Token,
bool isWeth,
string memory lpTokenName,
string memory lpTokenSymbol
) public onlyOwner {
ERC20 lpToken = new ERC20(lpTokenName, lpTokenSymbol);
lpTokens[l1Token] = LPToken({ lpToken: address(lpToken), isWeth: isWeth, isEnabled: true });
function enableL1TokenForLiquidityProvision(address l1Token) public onlyOwner {
// NOTE: if we run out of bytecode this logic could be refactored into a custom token factory that does the
// appends and permission setting.
ExpandedERC20 lpToken = new ExpandedERC20(
append("Across ", IERC20Metadata(l1Token).name(), " LP Token"), // LP Token Name
append("Av2-", IERC20Metadata(l1Token).symbol(), "-LP"), // LP Token Symbol
IERC20Metadata(l1Token).decimals() // LP Token Decimals
);
Comment on lines +58 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor note: we could use a contract like the TokenFactory here down the line so the ERC20 bytecode doesn't need to live in this contract (and thus, we give ourselves more room in the bytecode limit).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a good point. we can make this change if we run out of space. I'll add a comment.

lpToken.addMember(1, address(this)); // Set this contract as the LP Token's minter.
lpToken.addMember(2, address(this)); // Set this contract as the LP Token's burner.
lpTokens[l1Token] = LPToken({ lpToken: lpToken, isEnabled: true });
}

function disableL1TokenForLiquidityProvision(address l1Token) public onlyOwner {
Expand All @@ -51,9 +76,49 @@ contract HubPool is Testable, Lockable, MultiCaller, Ownable {
* LIQUIDITY PROVIDER FUNCTIONS *
*************************************************/

function addLiquidity(address token, uint256 amount) public {}
function addLiquidity(address l1Token, uint256 l1TokenAmount) public payable {
Copy link
Contributor

@mrice32 mrice32 Jan 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this all new code? Or is it all copied? Or is it a mix? If a mix, can you comment on the parts that you have changed (throughout the file)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's copied but changed to include the new mapping structure. the v1 version of this did not specify the l1Token (one token per pool). the changes therefore are simply to define the l1Token within the mapping. the other small change is the mint logic is now called on the external contract, rather than on this as the LPtoken is external.

require(lpTokens[l1Token].isEnabled);
// 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.
require((address(l1Token) == address(l1Weth) && msg.value == l1TokenAmount) || msg.value == 0, "Bad msg.value");

// Since `exchangeRateCurrent()` reads this contract's balance and updates contract state using it,
// we must call it first before transferring any tokens to this contract.
uint256 lpTokensToMint = (l1TokenAmount * 1e18) / _exchangeRateCurrent();
ExpandedERC20(lpTokens[l1Token].lpToken).mint(msg.sender, lpTokensToMint);
// liquidReserves += l1TokenAmount; //TODO: Add this when we have the liquidReserves variable implemented.

if (address(l1Token) == address(l1Weth) && msg.value > 0)
WETH9Like(address(l1Token)).deposit{ value: msg.value }();
else IERC20(l1Token).safeTransferFrom(msg.sender, address(this), l1TokenAmount);

emit LiquidityAdded(l1Token, l1TokenAmount, lpTokensToMint, msg.sender);
}

function removeLiquidity(
address l1Token,
uint256 lpTokenAmount,
bool sendEth
) public nonReentrant {
// Can only send eth on withdrawing liquidity iff this is the WETH pool.
require(l1Token == address(l1Weth) || !sendEth, "Cant send eth");
uint256 l1TokensToReturn = (lpTokenAmount * _exchangeRateCurrent()) / 1e18;

// Check that there is enough liquid reserves to withdraw the requested amount.
// require(liquidReserves >= (pendingReserves + l1TokensToReturn), "Utilization too high to remove"); // TODO: add this when we have liquid reserves variable implemented.

ExpandedERC20(lpTokens[l1Token].lpToken).burnFrom(msg.sender, lpTokenAmount);
// liquidReserves -= l1TokensToReturn; // TODO: add this when we have liquid reserves variable implemented.

function exchangeRateCurrent(address token) public returns (uint256) {}
if (sendEth) _unwrapWETHTo(payable(msg.sender), l1TokensToReturn);
else IERC20(l1Token).safeTransfer(msg.sender, l1TokensToReturn);

emit LiquidityRemoved(l1Token, l1TokensToReturn, lpTokenAmount, msg.sender);
}

function exchangeRateCurrent() public nonReentrant returns (uint256) {
return _exchangeRateCurrent();
}

function liquidityUtilizationPostRelay(address token, uint256 relayedAmount) public returns (uint256) {}

Expand All @@ -72,4 +137,29 @@ contract HubPool is Testable, Lockable, MultiCaller, Ownable {
uint256[] memory netSendAmounts,
bytes32[] memory inclusionProof
) public {}

function _exchangeRateCurrent() internal pure returns (uint256) {
return 1e18;
}

// Unwraps ETH and does a transfer to a recipient address. If the recipient is a smart contract then sends WETH.
function _unwrapWETHTo(address payable to, uint256 amount) internal {
if (address(to).isContract()) {
IERC20(address(l1Weth)).safeTransfer(to, amount);
} else {
l1Weth.withdraw(amount);
to.transfer(amount);
}
}

function append(
string memory a,
string memory b,
string memory c
) internal pure returns (string memory) {
return string(abi.encodePacked(a, b, c));
}

// Added to enable the BridgePool to receive ETH. used when unwrapping Weth.
receive() external payable {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we revert if this comes from an address that is not the WETH contract?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's an interesting idea but its somewhat inconsistant. we dont prevent users from sending tokens so why should we prevent them from sending eth?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you're saying. ETH transfers work differently, so I would argue it's more likely to send ETH accidentally. However, this would incur a slight gas cost, so it probably isn't worth the cost of the check.

}
5 changes: 2 additions & 3 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,11 @@ task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
// Go to https://hardhat.org/config/ to learn more

const config: HardhatUserConfig = {
solidity: "0.8.11",
solidity: { compilers: [{ version: "0.8.11", settings: { optimizer: { enabled: true, runs: 200 } } }] },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set runs to 1_000_000?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

networks: {
ropsten: {
url: process.env.ROPSTEN_URL || "",
accounts:
process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
accounts: process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use a mnemonic? Isn't that the preferred way of generating accounts?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not change this, this was just a listing change. I think we should ignore this until we get there but ye I agree a mnemonic would be better

},
},
gasReporter: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
},
"dependencies": {
"@openzeppelin/contracts": "^4.4.2",
"@uma/common": "^2.17.0",
"@uma/contracts-node": "^0.2.0",
"@uma/core": "^2.24.0"
},
Expand Down
44 changes: 44 additions & 0 deletions test/HubPool.Fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { TokenRolesEnum, ZERO_ADDRESS } from "@uma/common";
import { getContractFactory, toWei } from "./utils";
import { Contract, BigNumber } from "ethers";

export async function deployHubPoolTestHelperContracts(deployerWallet: any) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

// Useful contracts.
const timer = await (await getContractFactory("Timer", deployerWallet)).deploy();

// Create 3 tokens: WETH for wrapping unwrapping and 2 ERC20s with different decimals.
const weth = await (await getContractFactory("WETH9", deployerWallet)).deploy();
const usdc = await (await getContractFactory("ExpandedERC20", deployerWallet)).deploy("USD Coin", "USDC", 6);
await usdc.addMember(TokenRolesEnum.MINTER, deployerWallet.address);
const dai = await (await getContractFactory("ExpandedERC20", deployerWallet)).deploy("DAI Stablecoin", "DAI", 18);
await dai.addMember(TokenRolesEnum.MINTER, deployerWallet.address);

// Deploy the hubPool
const hubPool = await (await getContractFactory("HubPool", deployerWallet)).deploy(weth.address, timer.address);

return { timer, weth, usdc, dai, hubPool };
}

export async function seedWallet(
walletToFund: any,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

tokens: Contract[],
weth: Contract | undefined,
amountToSeedWith: number | BigNumber
) {
for (const token of tokens) await token.mint(walletToFund.address, amountToSeedWith);

if (weth) await weth.connect(walletToFund).deposit({ value: amountToSeedWith });
}

export async function enableTokensForLiquidityProvision(owner: any, hubPool: Contract, tokens: Contract[]) {
let lpTokens = [];
for (const token of tokens) {
await hubPool.enableL1TokenForLiquidityProvision(token.address);
lpTokens.push(
await (
await getContractFactory("ExpandedERC20", owner)
).attach((await hubPool.callStatic.lpTokens(token.address)).lpToken)
);
}
return lpTokens;
}
126 changes: 115 additions & 11 deletions test/HubPool.LiquidityProvision.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,124 @@
import { expect } from "chai";
import { Contract } from "ethers";
import { deployContract, MockProvider } from "ethereum-waffle";
import { getContract } from "./utils";
import { ethers } from "hardhat";
import { getContractFactory, fromWei, toBN, SignerWithAddress } from "./utils";
import { deployHubPoolTestHelperContracts, enableTokensForLiquidityProvision, seedWallet } from "./HubPool.Fixture";
import { amountToSeedWallets, amountToLp } from "./HubPool.constants";

let hubPool: Contract;
let timer: Contract;
let hubPool: Contract, weth: Contract, usdc: Contract, dai: Contract;
let wethLpToken: Contract, usdcLpToken: Contract, daiLpToken: Contract;
let owner: SignerWithAddress, liquidityProvider: SignerWithAddress, other: SignerWithAddress;

describe("HubPool LiquidityProvision", async function () {
const [owner] = new MockProvider().getWallets();
describe("HubPool Liquidity Provision", function () {
beforeEach(async function () {
[owner, liquidityProvider, other] = await ethers.getSigners();
({ weth, usdc, dai, hubPool } = await deployHubPoolTestHelperContracts(owner));
[wethLpToken, usdcLpToken, daiLpToken] = await enableTokensForLiquidityProvision(owner, hubPool, [weth, usdc, dai]);

before(async function () {
timer = await deployContract(owner, await getContract("Timer"));
// mint some fresh tokens and deposit ETH for weth for the liquidity provider.
await seedWallet(liquidityProvider, [usdc, dai], weth, amountToSeedWallets);
});

it("Adding ER20 liquidity correctly pulls tokens and mints LP tokens", async function () {
const daiLpToken = await (
await getContractFactory("ExpandedERC20", owner)
).attach((await hubPool.callStatic.lpTokens(dai.address)).lpToken);

// Balances of collateral before should equal the seed amount and there should be 0 outstanding LP tokens.
expect(await dai.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets);
expect(await daiLpToken.balanceOf(liquidityProvider.address)).to.equal(0);

await dai.connect(liquidityProvider).approve(hubPool.address, amountToLp);
await hubPool.connect(liquidityProvider).addLiquidity(dai.address, amountToLp);

//The balance of the collateral should be equal to the original amount minus the LPed amount. The balance of LP
// tokens should be equal to the amount of LP tokens divided by the exchange rate current. This rate starts at 1e18,
// so this should equal the amount minted.
expect(await dai.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(amountToLp));
expect(await daiLpToken.balanceOf(liquidityProvider.address)).to.equal(amountToLp);
expect(await daiLpToken.totalSupply()).to.equal(amountToLp);
});
it("Removing ER20 liquidity burns LP tokens and returns collateral", async function () {
await dai.connect(liquidityProvider).approve(hubPool.address, amountToLp);
await hubPool.connect(liquidityProvider).addLiquidity(dai.address, amountToLp);

// Next, try remove half the liquidity. This should modify the balances, as expected.
await hubPool.connect(liquidityProvider).removeLiquidity(dai.address, amountToLp.div(2), false);

expect(await dai.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(amountToLp.div(2)));
expect(await daiLpToken.balanceOf(liquidityProvider.address)).to.equal(amountToLp.div(2));
expect(await daiLpToken.totalSupply()).to.equal(amountToLp.div(2));

// Removing more than the total balance of LP tokens should throw.
await expect(hubPool.connect(other).removeLiquidity(dai.address, amountToLp, false)).to.be.reverted;

// Cant try receive ETH if the token is pool token is not WETH. Try redeem 1/3 of the original amount added. This is
// less than the total amount the wallet has left (since we removed half the amount before).
await expect(hubPool.connect(other).removeLiquidity(dai.address, amountToLp.div(3), true)).to.be.reverted;

hubPool = await deployContract(owner, await getContract("HubPool"), [timer.address]);
//Can remove the remaining LP tokens for a balance of 0.
await hubPool.connect(liquidityProvider).removeLiquidity(dai.address, amountToLp.div(2), false);
expect(await dai.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets); // back to starting balance.
expect(await daiLpToken.balanceOf(liquidityProvider.address)).to.equal(0); // All LP tokens burnt.
expect(await daiLpToken.totalSupply()).to.equal(0);
});
it("Only owner can enable L1 Tokens for liquidity provision", async function () {
expect(await hubPool.callStatic.timerAddress()).to.equal(timer.address);
it("Adding ETH liquidity correctly wraps to WETH and mints LP tokens", async function () {
// Depositor can send WETH, if they have. Explicitly set the value to 0 to ensure we dont send any eth with the tx.
await weth.connect(liquidityProvider).approve(hubPool.address, amountToLp);
await hubPool.connect(liquidityProvider).addLiquidity(weth.address, amountToLp, { value: 0 });
expect(await weth.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(amountToLp));
expect(await wethLpToken.balanceOf(liquidityProvider.address)).to.equal(amountToLp);

// Next, try depositing ETH with the transaction. No WETH should be sent. The ETH send with the TX should be
// wrapped for the user and LP tokens minted. Send the deposit and check the ether balance changes as expected.
await expect(() =>
hubPool.connect(liquidityProvider).addLiquidity(weth.address, amountToLp, { value: amountToLp })
).to.changeEtherBalance(weth, amountToLp); // WETH's Ether balance should increase by the amount LPed.
// The weth Token balance should have stayed the same as no weth was spent.
expect(await weth.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(amountToLp));
// However, the WETH LP token should have increase by the amount of LP tokens minted, as 2 x amountToLp.
expect(await wethLpToken.balanceOf(liquidityProvider.address)).to.equal(amountToLp.mul(2));
// Equally, the total WETH supply should have increased by the amount of LP tokens minted as they were deposited.
expect(await wethLpToken.totalSupply()).to.equal(amountToLp.mul(2));
expect(await weth.totalSupply()).to.equal(amountToLp.add(amountToSeedWallets));
});

it("Removing ETH liquidity can send back WETH or ETH depending on the users choice", async function () {
await weth.connect(liquidityProvider).approve(hubPool.address, amountToLp);
await hubPool.connect(liquidityProvider).addLiquidity(weth.address, amountToLp);

// Remove half the liquidity as WETH (set sendETH = false). This should modify the weth bal and not the eth bal.
await expect(() =>
hubPool.connect(liquidityProvider).removeLiquidity(weth.address, amountToLp.div(2), false)
).to.changeEtherBalance(liquidityProvider, 0);

expect(await weth.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(amountToLp.div(2))); // WETH balance should increase by the amount removed.

// Next, remove half the liquidity as ETH (set sendETH = true). This should modify the eth bal but not the weth bal.
await expect(() =>
hubPool.connect(liquidityProvider).removeLiquidity(weth.address, amountToLp.div(2), true)
).to.changeEtherBalance(liquidityProvider, amountToLp.div(2)); // There should be ETH transferred, not WETH.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this syntax is great! unfortunately it cant be chained along with a .to.changeTokenBalance so I did this the old fashion way.

expect(await weth.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(amountToLp.div(2))); // weth balance stayed the same.

// There should be no LP tokens left outstanding:
expect(await wethLpToken.balanceOf(liquidityProvider.address)).to.equal(0);
});
it("Adding and removing non-18 decimal collateral mints the commensurate amount of LP tokens", async function () {
// USDC is 6 decimal places. Scale the amountToLp back to a normal number then up by 6 decimal places to get a 1e6
// scaled number. i.e amountToLp is 1000 so this number will be 1e6.
const scaledAmountToLp = toBN(fromWei(amountToLp)).mul(1e6); // USDC is 6 decimal places.
await usdc.connect(liquidityProvider).approve(hubPool.address, amountToLp);
await hubPool.connect(liquidityProvider).addLiquidity(usdc.address, scaledAmountToLp);

// Check the balances are correct.
expect(await usdcLpToken.balanceOf(liquidityProvider.address)).to.equal(scaledAmountToLp);
expect(await usdc.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(scaledAmountToLp));
expect(await usdc.balanceOf(hubPool.address)).to.equal(scaledAmountToLp);

// Redemption should work as normal, just scaled.
await hubPool.connect(liquidityProvider).removeLiquidity(usdc.address, scaledAmountToLp.div(2), false);
expect(await usdcLpToken.balanceOf(liquidityProvider.address)).to.equal(scaledAmountToLp.div(2));
expect(await usdc.balanceOf(liquidityProvider.address)).to.equal(amountToSeedWallets.sub(scaledAmountToLp.div(2)));
expect(await usdc.balanceOf(hubPool.address)).to.equal(scaledAmountToLp.div(2));
});
});
Loading