Skip to content
Closed
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
223 changes: 200 additions & 23 deletions contracts/LiquidityHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,73 @@ 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 DepositRequest(address caller, address receiver, uint256 assets);
event WithdrawRequest(address caller, address receiver, address owner, uint256 shares);

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

struct AdjustmentRecord {
uint256 totalAssets;
uint256 totalShares;
}

struct PendingDeposit {
uint256 assets;
uint256 adjustmentId;
}

struct PendingWithdraw {
uint256 shares;
uint256 adjustmentId;
}

/// @custom:storage-location erc7201:sprinter.storage.LiquidityHub
struct LiquidityHubStorage {
uint256 totalAssets;
uint256 totalShares;
uint256 depositedAssets;
uint256 burnedShares;
uint256 releasedAssets;
uint256 lastAdjustmentId;
mapping(uint256 adjustmentId => AdjustmentRecord) adjustmentRecords;
mapping(address receiver => PendingDeposit) pendingDeposits;
mapping(address receiver => PendingWithdraw) pendingWithdrawals;
}

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) external initializer() {
ERC4626Upgradeable.__ERC4626_init(asset_);
require(
IERC20Metadata(address(asset_)).decimals() <= IERC20Metadata(address(SHARES)).decimals(),
Expand All @@ -48,6 +81,26 @@ 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);
}

function adjustTotalAssets(uint256 amount, bool isIncrease) external onlyRole(ASSETS_ADJUST_ROLE) {
LiquidityHubStorage storage $ = _getStorage();
uint256 adjustmentId = ++$.lastAdjustmentId;
AdjustmentRecord storage adjustmentRecord = $.adjustmentRecords[adjustmentId];
uint256 assets = $.totalAssets;
uint256 newAssets = isIncrease ? assets + amount : assets - amount;
uint256 supplyShares = $.totalShares;
uint256 mintingShares = _toShares($.depositedAssets, supplyShares, newAssets, Math.Rounding.Floor);
uint256 releasingAssets = _toAssets($.burnedShares, supplyShares, newAssets, Math.Rounding.Floor);
$.totalAssets = newAssets + $.depositedAssets - releasingAssets;
$.totalShares = supplyShares + mintingShares - $.burnedShares;
$.depositedAssets = 0;
$.burnedShares = 0;
$.releasedAssets += releasingAssets;
adjustmentRecord.totalAssets = $.totalAssets;
adjustmentRecord.totalShares = $.totalShares;
emit TotalAssetsAdjustment(assets, newAssets);
}

function name() public pure override(IERC20Metadata, ERC20Upgradeable) returns (string memory) {
Expand All @@ -63,11 +116,11 @@ contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable {
}

function totalSupply() public view virtual override(IERC20, ERC20Upgradeable) returns (uint256) {
return IERC20(address(SHARES)).totalSupply();
return _getStorage().totalShares;
}

function balanceOf(address owner) public view virtual override(IERC20, ERC20Upgradeable) returns (uint256) {
return IERC20(address(SHARES)).balanceOf(owner);
return IERC20(address(SHARES)).balanceOf(owner) + _simulateSettleDeposit(owner);
}

function transfer(address, uint256) public pure override(IERC20, ERC20Upgradeable) returns (bool) {
Expand All @@ -91,26 +144,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 releasedAssets() external view returns (uint256) {
return _getStorage().releasedAssets;
}

function settle(address receiver) external {
_settleDeposit(receiver, receiver);
_settleWithdraw(receiver, receiver, receiver);
}

function settleDeposit(address receiver) external {
_settleDeposit(receiver, receiver);
}

function settleWithdraw(address receiver) external {
_settleWithdraw(receiver, receiver, receiver);
}

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 @@ -127,20 +233,91 @@ contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable {
SHARES.spendAllowance(owner, spender, value);
}

function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override {
super._deposit(caller, receiver, assets, shares);
_getStorage().totalAssets += assets;
function _deposit(address caller, address receiver, uint256 assets, uint256 /*shares*/) internal virtual override {
_settleWithdraw(caller, caller, caller);
_settleDeposit(caller, receiver);
LiquidityHubStorage storage $ = _getStorage();
SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(LIQUIDITY_POOL), assets);
PendingDeposit storage pendingDeposit = $.pendingDeposits[receiver];
pendingDeposit.assets += assets;
pendingDeposit.adjustmentId = $.lastAdjustmentId;
$.depositedAssets += assets;
LIQUIDITY_POOL.deposit();
emit DepositRequest(caller, receiver, assets);
}

function _simulateSettleDeposit(address receiver) internal view returns (uint256) {
LiquidityHubStorage storage $ = _getStorage();
PendingDeposit storage pendingDeposit = $.pendingDeposits[receiver];
uint256 assets = pendingDeposit.assets;
if (assets == 0) {
return 0;
}
uint256 settleAdjustmentId = pendingDeposit.adjustmentId + 1;
if (settleAdjustmentId > $.lastAdjustmentId) {
return 0;
}
AdjustmentRecord memory adjustmentRecord = $.adjustmentRecords[settleAdjustmentId];
uint256 shares = _toShares(
assets, adjustmentRecord.totalShares, adjustmentRecord.totalAssets, Math.Rounding.Floor
);
return shares;
}

function _settleDeposit(address caller, address receiver) internal {
uint256 shares = _simulateSettleDeposit(receiver);
if (shares == 0) {
return;
}
PendingDeposit storage pendingDeposit = _getStorage().pendingDeposits[receiver];
uint256 assets = pendingDeposit.assets;
pendingDeposit.assets = 0;
pendingDeposit.adjustmentId = 0;
_mint(receiver, shares);
emit Deposit(caller, receiver, assets, shares);
}

function _withdraw(
address caller,
address receiver,
address owner,
uint256 assets,
uint256 /*assets*/,
uint256 shares
) internal virtual override {
_getStorage().totalAssets -= assets;
super._withdraw(caller, receiver, owner, assets, shares);
_settleDeposit(caller, owner);
_settleWithdraw(caller, receiver, owner);
LiquidityHubStorage storage $ = _getStorage();
if (caller != owner) {
_spendAllowance(owner, caller, shares);
}
PendingWithdraw storage pendingWithdraw = $.pendingWithdrawals[receiver];
pendingWithdraw.shares += shares;
pendingWithdraw.adjustmentId = $.lastAdjustmentId;
$.burnedShares += shares;
_burn(owner, shares);
emit WithdrawRequest(caller, receiver, owner, shares);
}

function _settleWithdraw(address caller, address receiver, address owner) internal {
LiquidityHubStorage storage $ = _getStorage();
PendingWithdraw storage pendingWithdraw = $.pendingWithdrawals[receiver];
uint256 shares = pendingWithdraw.shares;
if (shares == 0) {
return;
}
uint256 settleAdjustmentId = pendingWithdraw.adjustmentId + 1;
if (settleAdjustmentId > $.lastAdjustmentId) {
return;
}
pendingWithdraw.shares = 0;
pendingWithdraw.adjustmentId = 0;
AdjustmentRecord memory adjustmentRecord = $.adjustmentRecords[settleAdjustmentId];
uint256 assets = _toAssets(
shares, adjustmentRecord.totalShares, adjustmentRecord.totalAssets, Math.Rounding.Floor
);
$.releasedAssets -= assets;
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
Loading
Loading