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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@
ADMIN=
# USDC token address. Leave empty to deploy a test one instead.
USDC=
# Deposits to Liquidity Hub are only allowed till this limit is reached.
ASSETS_LIMIT=
BASETEST_PRIVATE_KEY=
VERIFY=false
139 changes: 120 additions & 19 deletions contracts/LiquidityHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,56 @@ import {
IERC20Metadata,
ERC20Upgradeable,
ERC4626Upgradeable,
SafeERC20,
Math
} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import {AccessControlUpgradeable} from '@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol';
import {ERC7201Helper} from './utils/ERC7201Helper.sol';
import {IManagedToken} from './interfaces/IManagedToken.sol';
import {ILiquidityPool} from './interfaces/ILiquidityPool.sol';

contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable {
using Math for uint256;

IManagedToken immutable public SHARES;
bytes32 public constant ASSETS_UPDATE_ROLE = "ASSETS_UPDATE_ROLE";
ILiquidityPool immutable public LIQUIDITY_POOL;
bytes32 public constant ASSETS_ADJUST_ROLE = "ASSETS_ADJUST_ROLE";

event TotalAssetsAdjustment(uint256 oldAssets, uint256 newAssets);
event AssetsLimitSet(uint256 oldLimit, uint256 newLimit);

error ZeroAddress();
error NotImplemented();
error IncompatibleAssetsAndShares();
error AssetsLimitIsTooBig();

/// @custom:storage-location erc7201:sprinter.storage.LiquidityHub
struct LiquidityHubStorage {
uint256 totalAssets;
uint256 assetsLimit;
}

bytes32 private constant StorageLocation = 0xb877bfaae1674461dd1960c90f24075e3de3265a91f6906fe128ab8da6ba1700;

constructor(address shares) {
constructor(address shares, address liquidityPool) {
ERC7201Helper.validateStorageLocation(
StorageLocation,
'sprinter.storage.LiquidityHub'
);
if (shares == address(0)) revert ZeroAddress();
if (liquidityPool == address(0)) revert ZeroAddress();
SHARES = IManagedToken(shares);
LIQUIDITY_POOL = ILiquidityPool(liquidityPool);
_disableInitializers();
}

function initialize(IERC20 asset_, address admin) external initializer() {
function initialize(
IERC20 asset_,
address admin,
address adjuster,
uint256 newAssetsLimit
) external initializer() {
ERC4626Upgradeable.__ERC4626_init(asset_);
require(
IERC20Metadata(address(asset_)).decimals() <= IERC20Metadata(address(SHARES)).decimals(),
Expand All @@ -48,6 +64,28 @@ contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable {
// Deliberately not initializing ERC20Upgradable because its
// functionality is delegated to SHARES.
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(ASSETS_ADJUST_ROLE, adjuster);
_setAssetsLimit(newAssetsLimit);
}

function adjustTotalAssets(uint256 amount, bool isIncrease) external onlyRole(ASSETS_ADJUST_ROLE) {
LiquidityHubStorage storage $ = _getStorage();
uint256 assets = $.totalAssets;
uint256 newAssets = isIncrease ? assets + amount : assets - amount;
$.totalAssets = newAssets;
emit TotalAssetsAdjustment(assets, newAssets);
}

function setAssetsLimit(uint256 newAssetsLimit) external onlyRole(DEFAULT_ADMIN_ROLE) {
_setAssetsLimit(newAssetsLimit);
}

function _setAssetsLimit(uint256 newAssetsLimit) internal {
require(newAssetsLimit <= type(uint256).max / 10 ** _decimalsOffset(), AssetsLimitIsTooBig());
LiquidityHubStorage storage $ = _getStorage();
uint256 oldLimit = $.assetsLimit;
$.assetsLimit = newAssetsLimit;
emit AssetsLimitSet(oldLimit, newAssetsLimit);
}

function name() public pure override(IERC20Metadata, ERC20Upgradeable) returns (string memory) {
Expand Down Expand Up @@ -91,26 +129,79 @@ contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable {
return _getStorage().totalAssets;
}

function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual override returns (uint256) {
(uint256 supplyShares, uint256 supplyAssets) = _getTotals();
function assetsLimit() public view returns (uint256) {
return _getStorage().assetsLimit;
}

function maxDeposit(address) public view virtual override returns (uint256) {
uint256 total = totalAssets();
uint256 limit = assetsLimit();
if (total >= limit) {
return 0;
}
return limit - total;
}

function maxMint(address) public view virtual override returns (uint256) {
return _convertToShares(maxDeposit(address(0)), Math.Rounding.Floor);
}

function depositWithPermit(
uint256 assets,
address receiver,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
IERC20Permit(asset()).permit(
_msgSender(),
address(this),
assets,
deadline,
v,
r,
s
);
deposit(assets, receiver);
}

function _toShares(
uint256 assets,
uint256 supplyShares,
uint256 supplyAssets,
Math.Rounding rounding
) internal view returns (uint256) {
(supplyShares, supplyAssets) = _getTotals(supplyShares, supplyAssets);
return assets.mulDiv(supplyShares, supplyAssets, rounding);
}

function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual override returns (uint256) {
(uint256 supplyShares, uint256 supplyAssets) = _getTotals();
function _toAssets(
uint256 shares,
uint256 supplyShares,
uint256 supplyAssets,
Math.Rounding rounding
) internal view returns (uint256) {
(supplyShares, supplyAssets) = _getTotals(supplyShares, supplyAssets);
return shares.mulDiv(supplyAssets, supplyShares, rounding);
}

function _getTotals() internal view returns (uint256 supply, uint256 assets) {
supply = totalSupply();
if (supply == 0) {
supply = 10 ** _decimalsOffset();
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual override returns (uint256) {
return _toShares(assets, totalSupply(), totalAssets(), rounding);
}

function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual override returns (uint256) {
return _toAssets(shares, totalSupply(), totalAssets(), rounding);
}

function _getTotals(uint256 supplyShares, uint256 supplyAssets) internal view returns (uint256, uint256) {
if (supplyShares == 0) {
supplyShares = 10 ** _decimalsOffset();
}
assets = totalAssets();
if (assets == 0) {
assets = 1;
if (supplyAssets == 0) {
supplyAssets = 1;
}
return (supply, assets);
return (supplyShares, supplyAssets);
}

function _update(address from, address to, uint256 value) internal virtual override {
Expand All @@ -128,8 +219,12 @@ contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable {
}

function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override {
super._deposit(caller, receiver, assets, shares);
_getStorage().totalAssets += assets;
LiquidityHubStorage storage $ = _getStorage();
SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(LIQUIDITY_POOL), assets);
_mint(receiver, shares);
$.totalAssets += assets;
LIQUIDITY_POOL.deposit();
emit Deposit(caller, receiver, assets, shares);
}

function _withdraw(
Expand All @@ -139,8 +234,14 @@ contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable {
uint256 assets,
uint256 shares
) internal virtual override {
_getStorage().totalAssets -= assets;
super._withdraw(caller, receiver, owner, assets, shares);
LiquidityHubStorage storage $ = _getStorage();
if (caller != owner) {
_spendAllowance(owner, caller, shares);
}
$.totalAssets -= assets;
_burn(owner, shares);
LIQUIDITY_POOL.withdraw(receiver, assets);
emit Withdraw(caller, receiver, owner, assets, shares);
}

function _decimalsOffset() internal view virtual override returns (uint8) {
Expand Down
8 changes: 8 additions & 0 deletions contracts/interfaces/ILiquidityPool.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity 0.8.28;

interface ILiquidityPool {
function deposit() external;

function withdraw(address to, uint256 amount) external;
}
24 changes: 24 additions & 0 deletions contracts/testing/TestLiquidityPool.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity 0.8.28;

import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ILiquidityPool} from "../interfaces/ILiquidityPool.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract TestLiquidityPool is ILiquidityPool, AccessControl {
IERC20 private immutable ASSET;

event Deposit();

constructor(IERC20 asset) {
ASSET = asset;
_grantRole(DEFAULT_ADMIN_ROLE, _msgSender());
}

function deposit() external override {
emit Deposit();
}
function withdraw(address to, uint256 amount) external override onlyRole(DEFAULT_ADMIN_ROLE) {
SafeERC20.safeTransfer(ASSET, to, amount);
}
}
5 changes: 3 additions & 2 deletions contracts/testing/TestUSDC.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
pragma solidity 0.8.28;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

contract TestUSDC is ERC20 {
constructor() ERC20("Circle USD", "USDC") {
contract TestUSDC is ERC20, ERC20Permit {
constructor() ERC20("Circle USD", "USDC") ERC20Permit("Circle USD") {
_mint(msg.sender, 1000 * 10 ** decimals());
}

Expand Down
10 changes: 10 additions & 0 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {HardhatUserConfig} from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

function isSet(param?: string) {
return param && param.length > 0;
}

const config: HardhatUserConfig = {
solidity: {
version: "0.8.28",
Expand All @@ -15,6 +19,12 @@ const config: HardhatUserConfig = {
localhost: {
url: "http://127.0.0.1:8545/",
},
basetest: {
chainId: 84532,
url: "https://sepolia.base.org",
accounts:
isSet(process.env.BASETEST_PRIVATE_KEY) ? [process.env.BASETEST_PRIVATE_KEY || ""] : [],
},
},
sourcify: {
enabled: true,
Expand Down
24 changes: 19 additions & 5 deletions scripts/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ import dotenv from "dotenv";
dotenv.config();

import hre from "hardhat";
import {isAddress} from "ethers";
import {getContractAt, getCreateAddress, deploy} from "../test/helpers";
import {isAddress, MaxUint256, getBigInt} from "ethers";
import {getContractAt, getCreateAddress, deploy, ZERO_BYTES32} from "../test/helpers";
import {assert, getVerifier} from "./helpers";
import {
TestUSDC, SprinterUSDCLPShare, LiquidityHub, TransparentUpgradeableProxy, ProxyAdmin
TestUSDC, SprinterUSDCLPShare, LiquidityHub, TransparentUpgradeableProxy, ProxyAdmin,
TestLiquidityPool,
} from "../typechain-types";

async function main() {
const [deployer] = await hre.ethers.getSigners();
const admin: string = isAddress(process.env.ADMIN) ? process.env.ADMIN : deployer.address;
const adjuster: string = isAddress(process.env.ADJUSTER) ? process.env.ADJUSTER : deployer.address;
const maxLimit: bigint = MaxUint256 / 10n ** 12n;
const assetsLimit: bigint = getBigInt(process.env.ASSETS_LIMIT || maxLimit);
let usdc: string;
if (isAddress(process.env.USDC)) {
usdc = process.env.USDC;
Expand All @@ -20,6 +24,9 @@ async function main() {
usdc = await testUSDC.getAddress();
}

console.log("TEST: Using TEST Liquidity Pool");
const liquidityPool = (await deploy("TestLiquidityPool", deployer, {}, usdc)) as TestLiquidityPool;

const startingNonce = await deployer.getNonce();

const verifier = getVerifier();
Expand All @@ -30,9 +37,11 @@ async function main() {
) as SprinterUSDCLPShare;

const liquidityHubImpl = (
await verifier.deploy("LiquidityHub", deployer, {nonce: startingNonce + 1}, lpToken.target)
await verifier.deploy("LiquidityHub", deployer, {nonce: startingNonce + 1}, lpToken.target, liquidityPool.target)
) as LiquidityHub;
const liquidityHubInit = (await liquidityHubImpl.initialize.populateTransaction(usdc, admin)).data;
const liquidityHubInit = (await liquidityHubImpl.initialize.populateTransaction(
usdc, admin, adjuster, assetsLimit
)).data;
const liquidityHubProxy = (await verifier.deploy(
"TransparentUpgradeableProxy", deployer, {nonce: startingNonce + 2},
liquidityHubImpl.target, admin, liquidityHubInit
Expand All @@ -43,6 +52,11 @@ async function main() {

assert(liquidityHubAddress == liquidityHubProxy.target, "LiquidityHub address mismatch");

const DEFAULT_ADMIN_ROLE = ZERO_BYTES32;

console.log("TEST: Using default admin role for Hub on Pool");
await liquidityPool.grantRole(DEFAULT_ADMIN_ROLE, liquidityHub.target);

console.log();
console.log(`Admin: ${admin}`);
console.log(`SprinterUSDCLPShare: ${lpToken.target}`);
Expand Down
9 changes: 8 additions & 1 deletion scripts/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ export function assert(condition: boolean, message: string): void {
throw new Error(message);
};

function sleep(msec: number): Promise<boolean> {
export function sleep(msec: number): Promise<boolean> {
return new Promise((resolve) => {
setTimeout(() => resolve(true), msec);
});
};

export function isSet(input?: string): boolean {
if (input) {
return input.length > 0;
}
return false;
};

export function getVerifier() {
interface VerificationInput {
address: string;
Expand Down
Loading
Loading