diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63858a6 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Contracts admin. +ADMIN= +# USDC token address. Leave empty to deploy a test one instead. +USDC= +BASETEST_PRIVATE_KEY= +VERIFY=false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 39baef7..847d475 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,13 +7,15 @@ jobs: name: Lint Solidity runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.4.0 - - uses: actions/setup-node@v2.5.1 + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Node.js 22 + uses: actions/setup-node@v4 with: - node-version: [22.x] - - - run: npm ci - + node-version: 22 + cache: 'npm' + - name: Install dependencies + run: npm ci - name: Run linter run: npm run lint:solidity @@ -21,11 +23,14 @@ jobs: name: Lint TS runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.4.0 - - uses: actions/setup-node@v2.5.1 + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Node.js 22 + uses: actions/setup-node@v4 with: - node-version: [22.x] - - - run: npm ci + node-version: 22 + cache: 'npm' + - name: Install dependencies + run: npm ci - name: Run linter run: npm run lint:ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8437fec..d0780a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,25 +8,19 @@ jobs: test: name: Test runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22.x] steps: - name: Checkout code - uses: actions/checkout@v2.4.0 - - uses: actions/cache@v2.1.7 + uses: actions/checkout@v4 + - name: Install Node.js 22 + uses: actions/setup-node@v4 with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - name: Install Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2.5.1 - with: - node-version: ${{ matrix.node-version }} + node-version: 22 + cache: 'npm' - name: Install dependencies run: npm ci - name: Compile contracts run: npm run compile - name: Hardhat Tests run: npm run test + - name: Deploy Tests + run: npm run test:deploy diff --git a/.solhint.json b/.solhint.json index b088e5a..a768a24 100644 --- a/.solhint.json +++ b/.solhint.json @@ -2,6 +2,7 @@ "extends": "solhint:recommended", "rules": { "max-line-length": ["off", 120], - "func-visibility": ["warn", {"ignoreConstructors": true}] + "func-visibility": ["warn", {"ignoreConstructors": true}], + "func-name-mixedcase": ["off"] } } diff --git a/README.md b/README.md index 1929e9f..4c64cfc 100644 --- a/README.md +++ b/README.md @@ -1 +1,33 @@ -# sprinter-liquidity-contracts \ No newline at end of file +# sprinter-liquidity-contracts + +Solidity contracts that facilitate Sprinter Liquidity logic + +### Install + + node 22.x is required + nvm use + npm install + npm run compile + +### Test + + npm run test + +### Deployment + +For local development you need to run a local hardhat node and deploy to it: + + npm run node + npm run deploy-local + +Local deployment wallet private key is: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +To deploy to live networks, create a `.env` file using the `.env.example` and fill in the relevant variables (only the ones needed for your deployment). +You need to have a private key specified. +To deploy to Base Testnet do: + + npm run deploy-basetest + +Make sure to save the output of the deployment. You can use those later in the `.env` file to run other scripts on the already deployed system. + +You could optionally set VERIFY to `true` in order to publish the source code after deployemnt to sourcify.dev. diff --git a/contracts/Deps.sol b/contracts/Deps.sol new file mode 100644 index 0000000..439edc4 --- /dev/null +++ b/contracts/Deps.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import { TransparentUpgradeableProxy } from '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol'; diff --git a/contracts/LiquidityHub.sol b/contracts/LiquidityHub.sol new file mode 100644 index 0000000..8fdf86f --- /dev/null +++ b/contracts/LiquidityHub.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import { + IERC20, + IERC20Metadata, + ERC20Upgradeable, + ERC4626Upgradeable, + Math +} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import {AccessControlUpgradeable} from '@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol'; +import {ERC7201Helper} from './utils/ERC7201Helper.sol'; +import {IManagedToken} from './interfaces/IManagedToken.sol'; + +contract LiquidityHub is ERC4626Upgradeable, AccessControlUpgradeable { + using Math for uint256; + + IManagedToken immutable public SHARES; + bytes32 public constant ASSETS_UPDATE_ROLE = "ASSETS_UPDATE_ROLE"; + + error ZeroAddress(); + error NotImplemented(); + error IncompatibleAssetsAndShares(); + + /// @custom:storage-location erc7201:sprinter.storage.LiquidityHub + struct LiquidityHubStorage { + uint256 totalAssets; + } + + bytes32 private constant StorageLocation = 0xb877bfaae1674461dd1960c90f24075e3de3265a91f6906fe128ab8da6ba1700; + + constructor(address shares) { + ERC7201Helper.validateStorageLocation( + StorageLocation, + 'sprinter.storage.LiquidityHub' + ); + if (shares == address(0)) revert ZeroAddress(); + SHARES = IManagedToken(shares); + _disableInitializers(); + } + + function initialize(IERC20 asset_, address admin) external initializer() { + ERC4626Upgradeable.__ERC4626_init(asset_); + require( + IERC20Metadata(address(asset_)).decimals() <= IERC20Metadata(address(SHARES)).decimals(), + IncompatibleAssetsAndShares() + ); + // Deliberately not initializing ERC20Upgradable because its + // functionality is delegated to SHARES. + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + function name() public pure override(IERC20Metadata, ERC20Upgradeable) returns (string memory) { + revert NotImplemented(); + } + + function symbol() public pure override(IERC20Metadata, ERC20Upgradeable) returns (string memory) { + revert NotImplemented(); + } + + function decimals() public pure override returns (uint8) { + revert NotImplemented(); + } + + function totalSupply() public view virtual override(IERC20, ERC20Upgradeable) returns (uint256) { + return IERC20(address(SHARES)).totalSupply(); + } + + function balanceOf(address owner) public view virtual override(IERC20, ERC20Upgradeable) returns (uint256) { + return IERC20(address(SHARES)).balanceOf(owner); + } + + function transfer(address, uint256) public pure override(IERC20, ERC20Upgradeable) returns (bool) { + revert NotImplemented(); + } + + function allowance(address, address) public pure override(IERC20, ERC20Upgradeable) returns (uint256) { + // Silences the unreachable code warning from ERC20Upgradeable._spendAllowance(). + return 0; + } + + function approve(address, uint256) public pure override(IERC20, ERC20Upgradeable) returns (bool) { + revert NotImplemented(); + } + + function transferFrom(address, address, uint256) public pure override(IERC20, ERC20Upgradeable) returns (bool) { + revert NotImplemented(); + } + + function totalAssets() public view virtual override returns (uint256) { + return _getStorage().totalAssets; + } + + function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual override returns (uint256) { + (uint256 supplyShares, uint256 supplyAssets) = _getTotals(); + return assets.mulDiv(supplyShares, supplyAssets, rounding); + } + + function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual override returns (uint256) { + (uint256 supplyShares, uint256 supplyAssets) = _getTotals(); + return shares.mulDiv(supplyAssets, supplyShares, rounding); + } + + function _getTotals() internal view returns (uint256 supply, uint256 assets) { + supply = totalSupply(); + if (supply == 0) { + supply = 10 ** _decimalsOffset(); + } + assets = totalAssets(); + if (assets == 0) { + assets = 1; + } + return (supply, assets); + } + + function _update(address from, address to, uint256 value) internal virtual override { + if (from == address(0)) { + SHARES.mint(to, value); + } else if (to == address(0)) { + SHARES.burn(from, value); + } else { + revert NotImplemented(); + } + } + + function _spendAllowance(address owner, address spender, uint256 value) internal virtual override { + 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 _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override { + _getStorage().totalAssets -= assets; + super._withdraw(caller, receiver, owner, assets, shares); + } + + function _decimalsOffset() internal view virtual override returns (uint8) { + return IERC20Metadata(address(SHARES)).decimals() - IERC20Metadata(asset()).decimals(); + } + + function _getStorage() private pure returns (LiquidityHubStorage storage $) { + assembly { + $.slot := StorageLocation + } + } +} diff --git a/contracts/ManagedToken.sol b/contracts/ManagedToken.sol index 4d3b6a1..18367da 100644 --- a/contracts/ManagedToken.sol +++ b/contracts/ManagedToken.sol @@ -2,8 +2,9 @@ pragma solidity 0.8.28; import {ERC20, ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import {IManagedToken} from './interfaces/IManagedToken.sol'; -contract ManagedToken is ERC20Permit { +contract ManagedToken is IManagedToken, ERC20Permit { address immutable public MANAGER; error AccessDenied(); @@ -27,4 +28,8 @@ contract ManagedToken is ERC20Permit { function burn(address from, uint256 amount) external onlyManager() { _burn(from, amount); } + + function spendAllowance(address owner, address spender, uint256 value) external onlyManager() { + _spendAllowance(owner, spender, value); + } } diff --git a/contracts/interfaces/IManagedToken.sol b/contracts/interfaces/IManagedToken.sol new file mode 100644 index 0000000..ade8ee2 --- /dev/null +++ b/contracts/interfaces/IManagedToken.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +interface IManagedToken { + function MANAGER() external view returns (address); + + function mint(address to, uint256 amount) external; + + function burn(address from, uint256 amount) external; + + function spendAllowance(address owner, address spender, uint256 value) external; +} diff --git a/contracts/testing/TestUSDC.sol b/contracts/testing/TestUSDC.sol new file mode 100644 index 0000000..0e68aef --- /dev/null +++ b/contracts/testing/TestUSDC.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract TestUSDC is ERC20 { + constructor() ERC20("Circle USD", "USDC") { + _mint(msg.sender, 1000 * 10 ** decimals()); + } + + function decimals() public pure override returns (uint8) { + return 6; + } +} diff --git a/contracts/utils/ERC7201Helper.sol b/contracts/utils/ERC7201Helper.sol new file mode 100644 index 0000000..d2ee9cd --- /dev/null +++ b/contracts/utils/ERC7201Helper.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +library ERC7201Helper { + error InvalidStorageSlot(string namespace); + + function validateStorageLocation(bytes32 actual, string memory namespace) internal pure { + bytes32 expected = getStorageLocation(bytes(namespace)); + require(actual == expected, InvalidStorageSlot(namespace)); + } + + function getStorageLocation(bytes memory namespace) internal pure returns(bytes32) { + return keccak256(abi.encode(uint256(keccak256(namespace)) - 1)) & ~bytes32(uint256(0xff)); + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 5d9d1b7..2f42358 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,7 @@ export default tseslint.config( "no-var": "error", "object-curly-spacing": ["error", "never"], "prefer-const": "error", + "@typescript-eslint/no-explicit-any": "off", quotes: ["error", "double"], semi: "off", } diff --git a/hardhat.config.ts b/hardhat.config.ts index 4b9ce28..51d4459 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -11,6 +11,14 @@ const config: HardhatUserConfig = { }, }, }, + networks: { + localhost: { + url: "http://127.0.0.1:8545/", + }, + }, + sourcify: { + enabled: true, + }, }; export default config; diff --git a/package-lock.json b/package-lock.json index bddfb88..910bbb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@eslint/js": "^9.17.0", "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "dotenv": "^16.4.7", "eslint": "^9.17.0", "hardhat": "^2.22.17", "solhint": "^5.0.4", @@ -4170,6 +4171,19 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 60bd20b..1fe7b70 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,16 @@ "main": "index.js", "scripts": { "compile": "hardhat compile", + "deploy": "hardhat run ./scripts/deploy.ts", + "deploy-local": "hardhat run ./scripts/deploy.ts --network localhost", + "deploy-basetest": "hardhat run ./scripts/deploy.ts --network basetest", + "node": "hardhat node", "hardhat": "hardhat", "lint": "npm run lint:solidity && npm run lint:ts", "lint:solidity": "solhint contracts/**/*.sol", "lint:ts": "eslint", - "test": "hardhat test" + "test": "hardhat test --typecheck", + "test:deploy": "ts-node --files ./scripts/deploy.ts" }, "repository": { "type": "git", @@ -29,6 +34,7 @@ "devDependencies": { "@eslint/js": "^9.17.0", "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "dotenv": "^16.4.7", "eslint": "^9.17.0", "hardhat": "^2.22.17", "solhint": "^5.0.4", diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100644 index 0000000..f10e8a3 --- /dev/null +++ b/scripts/deploy.ts @@ -0,0 +1,58 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import hre from "hardhat"; +import {isAddress} from "ethers"; +import {getContractAt, getCreateAddress, deploy} from "../test/helpers"; +import {assert, getVerifier} from "./helpers"; +import { + TestUSDC, SprinterUSDCLPShare, LiquidityHub, TransparentUpgradeableProxy, ProxyAdmin +} from "../typechain-types"; + +async function main() { + const [deployer] = await hre.ethers.getSigners(); + const admin: string = isAddress(process.env.ADMIN) ? process.env.ADMIN : deployer.address; + let usdc: string; + if (isAddress(process.env.USDC)) { + usdc = process.env.USDC; + } else { + const testUSDC = (await deploy("TestUSDC", deployer, {})) as TestUSDC; + usdc = await testUSDC.getAddress(); + } + + const startingNonce = await deployer.getNonce(); + + const verifier = getVerifier(); + + const liquidityHubAddress = await getCreateAddress(deployer, startingNonce + 2); + const lpToken = ( + await verifier.deploy("SprinterUSDCLPShare", deployer, {nonce: startingNonce + 0}, liquidityHubAddress) + ) as SprinterUSDCLPShare; + + const liquidityHubImpl = ( + await verifier.deploy("LiquidityHub", deployer, {nonce: startingNonce + 1}, lpToken.target) + ) as LiquidityHub; + const liquidityHubInit = (await liquidityHubImpl.initialize.populateTransaction(usdc, admin)).data; + const liquidityHubProxy = (await verifier.deploy( + "TransparentUpgradeableProxy", deployer, {nonce: startingNonce + 2}, + liquidityHubImpl.target, admin, liquidityHubInit + )) as TransparentUpgradeableProxy; + const liquidityHub = (await getContractAt("LiquidityHub", liquidityHubAddress, deployer)) as LiquidityHub; + const liquidityHubProxyAdminAddress = await getCreateAddress(liquidityHubProxy, 1); + const liquidityHubAdmin = (await getContractAt("ProxyAdmin", liquidityHubProxyAdminAddress)) as ProxyAdmin; + + assert(liquidityHubAddress == liquidityHubProxy.target, "LiquidityHub address mismatch"); + + console.log(); + console.log(`Admin: ${admin}`); + console.log(`SprinterUSDCLPShare: ${lpToken.target}`); + console.log(`LiquidityHub: ${liquidityHub.target}`); + console.log(`LiquidityHubProxyAdmin: ${liquidityHubAdmin.target}`); + console.log(`USDC: ${usdc}`); + + if (process.env.VERIFY === "true") { + await verifier.verify(); + } +} + +main(); diff --git a/scripts/helpers.ts b/scripts/helpers.ts new file mode 100644 index 0000000..2ce91c6 --- /dev/null +++ b/scripts/helpers.ts @@ -0,0 +1,40 @@ +import hre from "hardhat"; +import {Signer} from "ethers"; +import {deploy} from "../test/helpers"; + +export function assert(condition: boolean, message: string): void { + if (condition) return; + throw new Error(message); +}; + +function sleep(msec: number): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(true), msec); + }); +}; + +export function getVerifier() { + interface VerificationInput { + address: string; + constructorArguments: any[]; + } + + const contracts: VerificationInput[] = []; + return { + deploy: async (contractName: string, signer: Signer, txParams: object, ...params: any[]) => { + const contract = await deploy(contractName, signer, txParams, ...params); + contracts.push({ + address: await contract.getAddress(), + constructorArguments: params, + }); + return contract; + }, + verify: async () => { + console.log("Waiting half a minute to start verification"); + await sleep(30000); + for (const contract of contracts) { + await hre.run("verify:verify", contract); + } + }, + }; +}; diff --git a/test/LiquidityHub.ts b/test/LiquidityHub.ts new file mode 100644 index 0000000..c339bb4 --- /dev/null +++ b/test/LiquidityHub.ts @@ -0,0 +1,261 @@ +import { + loadFixture, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import {expect} from "chai"; +import hre from "hardhat"; +import { + getCreateAddress, getContractAt, deploy, + ZERO_ADDRESS +} from "./helpers"; +import { + TestUSDC, SprinterUSDCLPShare, LiquidityHub, TransparentUpgradeableProxy, ProxyAdmin +} from "../typechain-types"; + +describe("LiquidityHub", function () { + const deployAll = async () => { + const [deployer, admin, user, user2, user3] = await hre.ethers.getSigners(); + + const usdc = (await deploy("TestUSDC", deployer, {})) as TestUSDC; + + const USDC = 10n ** (await usdc.decimals()); + + const startingNonce = await deployer.getNonce(); + + const liquidityHubAddress = await getCreateAddress(deployer, startingNonce + 2); + const lpToken = ( + await deploy("SprinterUSDCLPShare", deployer, {nonce: startingNonce + 0}, liquidityHubAddress) + ) as SprinterUSDCLPShare; + const LP = 10n ** (await lpToken.decimals()); + + const liquidityHubImpl = ( + await deploy("LiquidityHub", deployer, {nonce: startingNonce + 1}, lpToken.target) + ) as LiquidityHub; + const liquidityHubInit = (await liquidityHubImpl.initialize.populateTransaction(usdc.target, admin.address)).data; + const liquidityHubProxy = (await deploy( + "TransparentUpgradeableProxy", deployer, {nonce: startingNonce + 2}, + liquidityHubImpl.target, admin, liquidityHubInit + )) as TransparentUpgradeableProxy; + const liquidityHub = (await getContractAt("LiquidityHub", liquidityHubAddress, deployer)) as LiquidityHub; + const liquidityHubProxyAdminAddress = await getCreateAddress(liquidityHubProxy, 1); + const liquidityHubAdmin = (await getContractAt("ProxyAdmin", liquidityHubProxyAdminAddress, admin)) as ProxyAdmin; + + return {deployer, admin, user, user2, user3, usdc, lpToken, + liquidityHub, liquidityHubProxy, liquidityHubAdmin, USDC, LP}; + }; + + it("Should have default values", async function () { + const {lpToken, liquidityHub, usdc, user, user2} = await loadFixture(deployAll); + + expect(await liquidityHub.SHARES()).to.equal(lpToken.target); + expect(await liquidityHub.asset()).to.equal(usdc.target); + expect(await liquidityHub.balanceOf(user.address)).to.equal(0n); + expect(await liquidityHub.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalAssets()).to.equal(0n); + expect(await liquidityHub.allowance(user.address, user2.address)).to.equal(0n); + + await expect(liquidityHub.name()) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + await expect(liquidityHub.symbol()) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + await expect(liquidityHub.decimals()) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + await expect(liquidityHub.transfer(user.address, 1n)) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + await expect(liquidityHub.approve(user.address, 1n)) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + await expect(liquidityHub.transferFrom(user.address, user2.address, 1n)) + .to.be.revertedWithCustomError(liquidityHub, "NotImplemented()"); + }); + + it("Should allow to deposit", async function () { + const {lpToken, liquidityHub, usdc, deployer, user, USDC, LP} = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + const tx = liquidityHub.connect(user).deposit(10n * USDC, user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 10n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(user.address, liquidityHub.target, 10n * USDC); + expect(await lpToken.balanceOf(user.address)).to.equal(10n * LP); + expect(await lpToken.totalSupply()).to.equal(10n * LP); + expect(await liquidityHub.totalSupply()).to.equal(10n * LP); + expect(await liquidityHub.totalAssets()).to.equal(10n * USDC); + expect(await liquidityHub.balanceOf(user.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(user.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityHub.target)).to.equal(10n * USDC); + }); + + it("Should allow to deposit twice", async function () { + const {lpToken, liquidityHub, usdc, deployer, user, USDC, LP} = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + const tx = liquidityHub.connect(user).deposit(3n * USDC, user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 3n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(user.address, liquidityHub.target, 3n * USDC); + const tx2 = liquidityHub.connect(user).deposit(7n * USDC, user.address); + await expect(tx2) + .to.emit(lpToken, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 7n * LP); + await expect(tx2) + .to.emit(usdc, "Transfer") + .withArgs(user.address, liquidityHub.target, 7n * USDC); + expect(await lpToken.balanceOf(user.address)).to.equal(10n * LP); + expect(await lpToken.totalSupply()).to.equal(10n * LP); + expect(await liquidityHub.totalSupply()).to.equal(10n * LP); + expect(await liquidityHub.totalAssets()).to.equal(10n * USDC); + expect(await liquidityHub.balanceOf(user.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(user.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityHub.target)).to.equal(10n * USDC); + }); + + it("Should allow to mint", async function () { + const {lpToken, liquidityHub, usdc, deployer, user, USDC, LP} = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + const tx = liquidityHub.connect(user).mint(10n * LP, user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 10n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(user.address, liquidityHub.target, 10n * USDC); + expect(await lpToken.balanceOf(user.address)).to.equal(10n * LP); + expect(await lpToken.totalSupply()).to.equal(10n * LP); + expect(await liquidityHub.totalSupply()).to.equal(10n * LP); + expect(await liquidityHub.totalAssets()).to.equal(10n * USDC); + expect(await liquidityHub.balanceOf(user.address)).to.equal(10n * LP); + expect(await usdc.balanceOf(user.address)).to.equal(0n); + expect(await usdc.balanceOf(liquidityHub.target)).to.equal(10n * USDC); + }); + + it("Should allow to withdraw", async function () { + const {lpToken, liquidityHub, usdc, deployer, user, USDC, LP} = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + const tx = liquidityHub.connect(user).withdraw(10n * USDC, user.address, user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, ZERO_ADDRESS, 10n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(liquidityHub.target, user.address, 10n * USDC); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalAssets()).to.equal(0n); + expect(await liquidityHub.balanceOf(user.address)).to.equal(0n); + expect(await usdc.balanceOf(user.address)).to.equal(10n * USDC); + expect(await usdc.balanceOf(liquidityHub.target)).to.equal(0n); + }); + + it("Should allow to redeem", async function () { + const {lpToken, liquidityHub, usdc, deployer, user, USDC, LP} = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + const tx = liquidityHub.connect(user).redeem(10n * LP, user.address, user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, ZERO_ADDRESS, 10n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(liquidityHub.target, user.address, 10n * USDC); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalAssets()).to.equal(0n); + expect(await liquidityHub.balanceOf(user.address)).to.equal(0n); + expect(await usdc.balanceOf(user.address)).to.equal(10n * USDC); + expect(await usdc.balanceOf(liquidityHub.target)).to.equal(0n); + }); + + it("Should allow to withdraw from another user", async function () { + const {lpToken, liquidityHub, usdc, deployer, user, user2, user3, USDC, LP} = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(user3.address, 10n * LP); + const tx = liquidityHub.connect(user3).withdraw(10n * USDC, user2.address, user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, ZERO_ADDRESS, 10n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(liquidityHub.target, user2.address, 10n * USDC); + expect(await lpToken.allowance(user.address, user3.address)).to.equal(0n); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalAssets()).to.equal(0n); + expect(await liquidityHub.balanceOf(user.address)).to.equal(0n); + expect(await usdc.balanceOf(user2.address)).to.equal(10n * USDC); + expect(await usdc.balanceOf(liquidityHub.target)).to.equal(0n); + }); + + it("Should allow to redeem from another user", async function () { + const {lpToken, liquidityHub, usdc, deployer, user, user2, user3, USDC, LP} = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + await liquidityHub.connect(user).deposit(10n * USDC, user.address); + await lpToken.connect(user).approve(user3.address, 10n * LP); + const tx = liquidityHub.connect(user3).redeem(10n * LP, user2.address, user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(user.address, ZERO_ADDRESS, 10n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(liquidityHub.target, user2.address, 10n * USDC); + expect(await lpToken.allowance(user.address, user3.address)).to.equal(0n); + expect(await lpToken.balanceOf(user.address)).to.equal(0n); + expect(await lpToken.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalSupply()).to.equal(0n); + expect(await liquidityHub.totalAssets()).to.equal(0n); + expect(await liquidityHub.balanceOf(user.address)).to.equal(0n); + expect(await usdc.balanceOf(user2.address)).to.equal(10n * USDC); + expect(await usdc.balanceOf(liquidityHub.target)).to.equal(0n); + }); + + it("Should allow to deposit and withdraw multiple times", async function () { + const {lpToken, liquidityHub, usdc, deployer, user, USDC, LP} = await loadFixture(deployAll); + + await usdc.connect(deployer).transfer(user.address, 10n * USDC); + await usdc.connect(user).approve(liquidityHub.target, 10n * USDC); + const tx = liquidityHub.connect(user).deposit(3n * USDC, user.address); + await expect(tx) + .to.emit(lpToken, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 3n * LP); + await expect(tx) + .to.emit(usdc, "Transfer") + .withArgs(user.address, liquidityHub.target, 3n * USDC); + await liquidityHub.connect(user).withdraw(1n * USDC, user.address, user.address); + const tx2 = liquidityHub.connect(user).deposit(7n * USDC, user.address); + await expect(tx2) + .to.emit(lpToken, "Transfer") + .withArgs(ZERO_ADDRESS, user.address, 7n * LP); + await expect(tx2) + .to.emit(usdc, "Transfer") + .withArgs(user.address, liquidityHub.target, 7n * USDC); + await liquidityHub.connect(user).withdraw(4n * USDC, user.address, user.address); + expect(await lpToken.balanceOf(user.address)).to.equal(5n * LP); + expect(await lpToken.totalSupply()).to.equal(5n * LP); + expect(await liquidityHub.totalSupply()).to.equal(5n * LP); + expect(await liquidityHub.totalAssets()).to.equal(5n * USDC); + expect(await liquidityHub.balanceOf(user.address)).to.equal(5n * LP); + expect(await usdc.balanceOf(user.address)).to.equal(5n * USDC); + expect(await usdc.balanceOf(liquidityHub.target)).to.equal(5n * USDC); + }); +}); diff --git a/test/SprinterUSDCLPShare.ts b/test/SprinterUSDCLPShare.ts index 6f5d633..9645261 100644 --- a/test/SprinterUSDCLPShare.ts +++ b/test/SprinterUSDCLPShare.ts @@ -29,7 +29,7 @@ describe("SprinterUSDCLPShare", function () { it("Should allow manager to mint", async function () { const {lpToken, manager, user} = await loadFixture(deployLPToken); - expect(await lpToken.connect(manager).mint(user.address, 100n)) + await expect(lpToken.connect(manager).mint(user.address, 100n)) .to.emit(lpToken, "Transfer") .withArgs(ZERO_ADDRESS, user.address, 100n); expect(await lpToken.balanceOf(user.address)).to.equal(100n); @@ -47,7 +47,7 @@ describe("SprinterUSDCLPShare", function () { const {lpToken, manager, user} = await loadFixture(deployLPToken); await lpToken.connect(manager).mint(user.address, 100n); - expect(await lpToken.connect(manager).burn(user.address, 30n)) + await expect(lpToken.connect(manager).burn(user.address, 30n)) .to.emit(lpToken, "Transfer") .withArgs(user.address, ZERO_ADDRESS, 30n); expect(await lpToken.balanceOf(user.address)).to.equal(70n); @@ -66,7 +66,7 @@ describe("SprinterUSDCLPShare", function () { const {lpToken, manager, user, user2} = await loadFixture(deployLPToken); await lpToken.connect(manager).mint(user.address, 100n); - expect(await lpToken.connect(user).transfer(user2.address, 30n)) + await expect(lpToken.connect(user).transfer(user2.address, 30n)) .to.emit(lpToken, "Transfer") .withArgs(user.address, user2.address, 30n); expect(await lpToken.balanceOf(user.address)).to.equal(70n); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..7494390 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,29 @@ +import hre from "hardhat"; +import {AddressLike, resolveAddress, Signer, BaseContract, zeroPadBytes, toUtf8Bytes} from "ethers"; + +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +export const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; + +export async function getCreateAddress(from: AddressLike, nonce: number): Promise { + return hre.ethers.getCreateAddress({from: await resolveAddress(from), nonce}); +} + +export async function getContractAt(contractName: string, address: AddressLike, signer?: Signer): + Promise +{ + return hre.ethers.getContractAt(contractName, await resolveAddress(address), signer); +} + +export async function deploy(contractName: string, signer: Signer, txParams: object, ...params: any[]): + Promise +{ + const factory = await hre.ethers.getContractFactory(contractName, signer); + const instance = await factory.deploy(...params, txParams); + await instance.waitForDeployment(); + return instance; +} + +export function toBytes32(str: string) { + if (str.length > 32) throw new Error("String too long"); + return zeroPadBytes(toUtf8Bytes(str), 64); +}