diff --git a/core/.solhint.json b/core/.solhint.json index 7afe6405c..f54af0b9a 100644 --- a/core/.solhint.json +++ b/core/.solhint.json @@ -1,5 +1,4 @@ { "extends": "thesis", - "plugins": [], - "rules": {} + "plugins": [] } diff --git a/core/contracts/Acre.sol b/core/contracts/Acre.sol index cb6ed5f33..0e1cf640f 100644 --- a/core/contracts/Acre.sol +++ b/core/contracts/Acre.sol @@ -1,9 +1,46 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity ^0.8.21; -// Uncomment this line to use console.log -// import "hardhat/console.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; -contract Acre { - // TODO: add your implementation +/// @title Acre +/// @notice This contract implements the ERC-4626 tokenized vault standard. By +/// staking tBTC, users acquire a liquid staking token called stBTC, +/// commonly referred to as "shares". The staked tBTC is securely +/// deposited into Acre's vaults, where it generates yield over time. +/// Users have the flexibility to redeem stBTC, enabling them to +/// withdraw their staked tBTC along with the accrued yield. +/// @dev ERC-4626 is a standard to optimize and unify the technical parameters +/// of yield-bearing vaults. This contract facilitates the minting and +/// burning of shares (stBTC), which are represented as standard ERC20 +/// tokens, providing a seamless exchange with tBTC tokens. +contract Acre is ERC4626 { + event StakeReferral(bytes32 indexed referral, uint256 assets); + + constructor( + IERC20 tbtc + ) ERC4626(tbtc) ERC20("Acre Staked Bitcoin", "stBTC") {} + + /// @notice Stakes a given amount of tBTC token and mints shares to a + /// receiver. + /// @dev This function calls `deposit` function from `ERC4626` contract. The + /// amount of the assets has to be pre-approved in the tBTC contract. + /// @param assets Approved amount for the transfer and stake. + /// @param receiver The address to which the shares will be minted. + /// @param referral Data used for referral program. + /// @return shares Minted shares. + function stake( + uint256 assets, + address receiver, + bytes32 referral + ) public returns (uint256) { + // TODO: revisit the type of referral. + uint256 shares = deposit(assets, receiver); + + if (referral != bytes32(0)) { + emit StakeReferral(referral, assets); + } + + return shares; + } } diff --git a/core/contracts/test/TestERC20.sol b/core/contracts/test/TestERC20.sol new file mode 100644 index 000000000..44e5e14dc --- /dev/null +++ b/core/contracts/test/TestERC20.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.21; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract TestERC20 is ERC20 { + constructor() ERC20("Test Token", "TEST") {} + + function mint(address account, uint256 value) external { + _mint(account, value); + } +} diff --git a/core/package.json b/core/package.json index 49f094744..603cb8643 100644 --- a/core/package.json +++ b/core/package.json @@ -55,5 +55,8 @@ "ts-node": "^10.9.1", "typechain": "^8.3.2", "typescript": "^5.3.2" + }, + "dependencies": { + "@openzeppelin/contracts": "^5.0.0" } } diff --git a/core/test/Acre.test.ts b/core/test/Acre.test.ts index 97775ba4b..74e764ca0 100644 --- a/core/test/Acre.test.ts +++ b/core/test/Acre.test.ts @@ -1,5 +1,406 @@ +import { + SnapshotRestorer, + loadFixture, + takeSnapshot, +} from "@nomicfoundation/hardhat-toolbox/network-helpers" +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" +import { ethers } from "hardhat" +import { expect } from "chai" +import { ContractTransactionResponse, ZeroAddress } from "ethers" +import type { TestERC20, Acre } from "../typechain" +import { to1e18 } from "./utils" + +async function acreFixture() { + const [staker1, staker2] = await ethers.getSigners() + + const TestERC20 = await ethers.getContractFactory("TestERC20") + const tbtc = await TestERC20.deploy() + + const Acre = await ethers.getContractFactory("Acre") + const acre = await Acre.deploy(await tbtc.getAddress()) + + const amountToMint = to1e18(100000) + tbtc.mint(staker1, amountToMint) + tbtc.mint(staker2, amountToMint) + + return { acre, tbtc, staker1, staker2 } +} + describe("Acre", () => { - describe("Add your tests", () => { - it("Should validate something", async () => {}) + let acre: Acre + let tbtc: TestERC20 + let staker1: HardhatEthersSigner + let staker2: HardhatEthersSigner + + before(async () => { + ;({ acre, tbtc, staker1, staker2 } = await loadFixture(acreFixture)) + }) + + describe("stake", () => { + const referral = ethers.encodeBytes32String("referral") + let snapshot: SnapshotRestorer + + context("when staking as first staker", () => { + beforeEach(async () => { + snapshot = await takeSnapshot() + }) + + afterEach(async () => { + await snapshot.restore() + }) + + context("with a referral", () => { + const amountToStake = to1e18(1000) + + // In this test case there is only one staker and + // the token vault has not earned anythig yet so received shares are + // equal to staked tokens amount. + const expectedReceivedShares = amountToStake + + let tx: ContractTransactionResponse + let tbtcHolder: HardhatEthersSigner + let receiver: HardhatEthersSigner + + beforeEach(async () => { + tbtcHolder = staker1 + receiver = staker2 + + await tbtc + .connect(tbtcHolder) + .approve(await acre.getAddress(), amountToStake) + + tx = await acre + .connect(tbtcHolder) + .stake(amountToStake, receiver.address, referral) + }) + + it("should emit Deposit event", () => { + expect(tx).to.emit(acre, "Deposit").withArgs( + // Caller. + tbtcHolder.address, + // Receiver. + receiver.address, + // Staked tokens. + amountToStake, + // Received shares. + expectedReceivedShares, + ) + }) + + it("should emit StakeReferral event", () => { + expect(tx) + .to.emit(acre, "StakeReferral") + .withArgs(referral, amountToStake) + }) + + it("should mint stBTC tokens", async () => { + await expect(tx).to.changeTokenBalances( + acre, + [receiver.address], + [expectedReceivedShares], + ) + }) + + it("should transfer tBTC tokens", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [tbtcHolder.address, acre], + [-amountToStake, amountToStake], + ) + }) + }) + + context("without referral", () => { + const amountToStake = to1e18(10) + const emptyReferral = ethers.encodeBytes32String("") + let tx: ContractTransactionResponse + + beforeEach(async () => { + await tbtc + .connect(staker1) + .approve(await acre.getAddress(), amountToStake) + + tx = await acre + .connect(staker1) + .stake(amountToStake, staker1.address, emptyReferral) + }) + + it("should not emit the StakeReferral event", async () => { + await expect(tx).to.not.emit(acre, "StakeReferral") + }) + }) + + context( + "when amount to stake is greater than the approved amount", + () => { + const approvedAmount = to1e18(10) + const amountToStake = approvedAmount + 1n + + beforeEach(async () => { + await tbtc + .connect(staker1) + .approve(await acre.getAddress(), approvedAmount) + }) + + it("should revert", async () => { + await expect( + acre + .connect(staker1) + .stake(amountToStake, staker1.address, referral), + ).to.be.revertedWithCustomError(tbtc, "ERC20InsufficientAllowance") + }) + }, + ) + + context("when amount to stake is 1", () => { + const amountToStake = 1 + + beforeEach(async () => { + await tbtc + .connect(staker1) + .approve(await acre.getAddress(), amountToStake) + }) + + it("should not revert", async () => { + await expect( + acre + .connect(staker1) + .stake(amountToStake, staker1.address, referral), + ).to.not.be.reverted + }) + }) + + context("when the receiver is zero address", () => { + const amountToStake = to1e18(10) + + beforeEach(async () => { + await tbtc + .connect(staker1) + .approve(await acre.getAddress(), amountToStake) + }) + + it("should revert", async () => { + await expect( + acre.connect(staker1).stake(amountToStake, ZeroAddress, referral), + ).to.be.revertedWithCustomError(acre, "ERC20InvalidReceiver") + }) + }) + + context( + "when a staker approved and staked tokens and wants to stake more but w/o another apporval", + () => { + const amountToStake = to1e18(10) + + beforeEach(async () => { + await tbtc + .connect(staker1) + .approve(await acre.getAddress(), amountToStake) + + await acre + .connect(staker1) + .stake(amountToStake, staker1.address, referral) + }) + + it("should revert", async () => { + await expect( + acre + .connect(staker1) + .stake(amountToStake, staker1.address, referral), + ).to.be.revertedWithCustomError(acre, "ERC20InsufficientAllowance") + }) + }, + ) + }) + + context("when there are two stakers, A and B ", () => { + const staker1AmountToStake = to1e18(75) + const staker2AmountToStake = to1e18(25) + let afterStakesSnapshot: SnapshotRestorer + let afterSimulatingYieldSnapshot: SnapshotRestorer + + before(async () => { + await tbtc + .connect(staker1) + .approve(await acre.getAddress(), staker1AmountToStake) + await tbtc + .connect(staker2) + .approve(await acre.getAddress(), staker2AmountToStake) + + // Mint tokens. + await tbtc.connect(staker1).mint(staker1.address, staker1AmountToStake) + await tbtc.connect(staker2).mint(staker2.address, staker2AmountToStake) + }) + + context( + "when the vault is empty and has not yet earned yield from strategies", + () => { + after(async () => { + afterStakesSnapshot = await takeSnapshot() + }) + + context("when staker A stakes tokens", () => { + it("should stake tokens correctly", async () => { + await expect( + acre + .connect(staker1) + .stake(staker1AmountToStake, staker1.address, referral), + ).to.be.not.reverted + }) + + it("should receive shares equal to a staked amount", async () => { + const shares = await acre.balanceOf(staker1.address) + + expect(shares).to.eq(staker1AmountToStake) + }) + }) + + context("when staker B stakes tokens", () => { + it("should stake tokens correctly", async () => { + await expect( + acre + .connect(staker2) + .stake(staker2AmountToStake, staker2.address, referral), + ).to.be.not.reverted + }) + + it("should receive shares equal to a staked amount", async () => { + const shares = await acre.balanceOf(staker2.address) + + expect(shares).to.eq(staker2AmountToStake) + }) + }) + }, + ) + + context("when the vault has stakes", () => { + before(async () => { + await afterStakesSnapshot.restore() + }) + + it("the total assets amount should be equal to all staked tokens", async () => { + const totalAssets = await acre.totalAssets() + + expect(totalAssets).to.eq(staker1AmountToStake + staker2AmountToStake) + }) + }) + + context("when vault earns yield", () => { + let staker1SharesBefore: bigint + let staker2SharesBefore: bigint + let vaultYield: bigint + + before(async () => { + // Current state: + // Staker A shares = 75 + // Staker B shares = 25 + // Total assets = 75(staker A) + 25(staker B) + 50(yield) + await afterStakesSnapshot.restore() + + staker1SharesBefore = await acre.balanceOf(staker1.address) + staker2SharesBefore = await acre.balanceOf(staker2.address) + vaultYield = to1e18(50) + + // Simulating yield returned from strategies. The vault now contains + // more tokens than deposited which causes the exchange rate to + // change. + await tbtc.mint(await acre.getAddress(), vaultYield) + }) + + after(async () => { + afterSimulatingYieldSnapshot = await takeSnapshot() + }) + + it("the vault should hold more assets", async () => { + expect(await acre.totalAssets()).to.be.eq( + staker1AmountToStake + staker2AmountToStake + vaultYield, + ) + }) + + it("the staker's shares should be the same", async () => { + expect(await acre.balanceOf(staker1.address)).to.be.eq( + staker1SharesBefore, + ) + expect(await acre.balanceOf(staker2.address)).to.be.eq( + staker2SharesBefore, + ) + }) + + it("the staker A should be able to redeem more tokens than before", async () => { + const shares = await acre.balanceOf(staker1.address) + const availableAssetsToRedeem = await acre.previewRedeem(shares) + + // Expected amount w/o rounding: 75 * 150 / 100 = 112.5 + // Expected amount w/ support for rounding: 112499999999999999999 in + // tBTC token precision. + const expectedAssetsToRedeem = 112499999999999999999n + + expect(availableAssetsToRedeem).to.be.eq(expectedAssetsToRedeem) + }) + + it("the staker B should be able to redeem more tokens than before", async () => { + const shares = await acre.balanceOf(staker2.address) + const availableAssetsToRedeem = await acre.previewRedeem(shares) + + // Expected amount w/o rounding: 25 * 150 / 100 = 37.5 + // Expected amount w/ support for rounding: 37499999999999999999 in + // tBTC token precision. + const expectedAssetsToRedeem = 37499999999999999999n + + expect(availableAssetsToRedeem).to.be.eq(expectedAssetsToRedeem) + }) + }) + + context("when staker A stakes more tokens", () => { + const newAmountToStake = to1e18(20) + // Current state: + // Total assets = 75(staker A) + 25(staker B) + 50(yield) + // Total shares = 75 + 25 = 100 + // 20 * 100 / 150 = 13.(3) -> 13333333333333333333 in stBTC token + /// precision + const expectedSharesToMint = 13333333333333333333n + let sharesBefore: bigint + let availableToRedeemBefore: bigint + + before(async () => { + await afterSimulatingYieldSnapshot.restore() + + sharesBefore = await acre.balanceOf(staker1.address) + availableToRedeemBefore = await acre.previewRedeem(sharesBefore) + + tbtc.mint(staker1.address, newAmountToStake) + + await tbtc + .connect(staker1) + .approve(await acre.getAddress(), newAmountToStake) + + // State after stake: + // Total assets = 75(staker A) + 25(staker B) + 50(yield) + 20(staker + // A) = 170 + // Total shares = 75 + 25 + 13.(3) = 113.(3) + await acre.stake(newAmountToStake, staker1.address, referral) + }) + + it("should receive more shares", async () => { + const shares = await acre.balanceOf(staker1.address) + + expect(shares).to.be.eq(sharesBefore + expectedSharesToMint) + }) + + it("should be able to redeem more tokens than before", async () => { + const shares = await acre.balanceOf(staker1.address) + const availableToRedeem = await acre.previewRedeem(shares) + + // Expected amount w/o rounding: 88.(3) * 170 / 113.(3) = 132.5 + // Expected amount w/ support for rounding: 132499999999999999999 in + // tBTC token precision. + const expectedTotalAssetsAvailableToRedeem = 132499999999999999999n + + expect(availableToRedeem).to.be.greaterThan(availableToRedeemBefore) + expect(availableToRedeem).to.be.eq( + expectedTotalAssetsAvailableToRedeem, + ) + }) + }) + }) }) }) diff --git a/core/test/utils/index.ts b/core/test/utils/index.ts new file mode 100644 index 000000000..6654cca0e --- /dev/null +++ b/core/test/utils/index.ts @@ -0,0 +1 @@ +export * from "./number" diff --git a/core/test/utils/number.ts b/core/test/utils/number.ts new file mode 100644 index 000000000..9784b499c --- /dev/null +++ b/core/test/utils/number.ts @@ -0,0 +1,10 @@ +export function to1ePrecision( + n: string | number | bigint, + precision: number, +): bigint { + return BigInt(n) * 10n ** BigInt(precision) +} + +export function to1e18(n: string | number | bigint): bigint { + return to1ePrecision(n, 18) +} diff --git a/dapp/src/DApp.tsx b/dapp/src/DApp.tsx index 085b6fbe8..f38c6379e 100644 --- a/dapp/src/DApp.tsx +++ b/dapp/src/DApp.tsx @@ -1,5 +1,5 @@ import React from "react" -import { ChakraProvider } from "@chakra-ui/react" +import { Box, ChakraProvider } from "@chakra-ui/react" import { useDetectThemeMode } from "./hooks" import theme from "./theme" import { LedgerWalletAPIProvider, WalletContextProvider } from "./contexts" @@ -12,9 +12,9 @@ function DApp() { return ( <>
-
+ -
+ ) } diff --git a/dapp/src/components/Overview/PositionDetails.tsx b/dapp/src/components/Overview/PositionDetails.tsx index 972db661f..8fb074fe3 100644 --- a/dapp/src/components/Overview/PositionDetails.tsx +++ b/dapp/src/components/Overview/PositionDetails.tsx @@ -2,42 +2,40 @@ import React from "react" import { Text, Button, - HStack, Tooltip, Icon, useColorModeValue, - Flex, + CardBody, + Card, + CardFooter, + HStack, } from "@chakra-ui/react" import { BITCOIN, USD } from "../../constants" import { Info } from "../../static/icons" export default function PositionDetails() { return ( - - - Your positions - - - 34.75 - {BITCOIN.symbol} - - - - 1.245.148,1 - {USD.symbol} - - {/* TODO: Add correct text for tooltip */} - - - - - - - + + + + Your positions + {/* TODO: Add correct text for tooltip */} + + + + + + 34.75 {BITCOIN.symbol} + + + 1.245.148,1 {USD.symbol} + + + {/* TODO: Handle click actions */} - - + + ) } diff --git a/dapp/src/components/Overview/Statistics.tsx b/dapp/src/components/Overview/Statistics.tsx index b0c2dc0d6..bad05e4da 100644 --- a/dapp/src/components/Overview/Statistics.tsx +++ b/dapp/src/components/Overview/Statistics.tsx @@ -1,10 +1,13 @@ import React from "react" -import { Text, Box } from "@chakra-ui/react" +import { CardBody, Card } from "@chakra-ui/react" +import { TextMd } from "../Typography" export default function Statistics() { return ( - - Pool stats - + + + Pool stats + + ) } diff --git a/dapp/src/components/Overview/TransactionHistory.tsx b/dapp/src/components/Overview/TransactionHistory.tsx index 312233938..b8c0da7fd 100644 --- a/dapp/src/components/Overview/TransactionHistory.tsx +++ b/dapp/src/components/Overview/TransactionHistory.tsx @@ -1,10 +1,13 @@ import React from "react" -import { Text, Box } from "@chakra-ui/react" +import { CardBody, Card } from "@chakra-ui/react" +import { TextMd } from "../Typography" export default function TransactionHistory() { return ( - - Transaction history - + + + Transaction history + + ) } diff --git a/dapp/src/components/Overview/index.tsx b/dapp/src/components/Overview/index.tsx index 38f7d0068..6a4810a84 100644 --- a/dapp/src/components/Overview/index.tsx +++ b/dapp/src/components/Overview/index.tsx @@ -26,18 +26,20 @@ export default function Overview() { - + - + - + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 371dfb09f..257a3a017 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,10 @@ importers: version: 11.2.1 core: + dependencies: + '@openzeppelin/contracts': + specifier: ^5.0.0 + version: 5.0.0 devDependencies: '@nomicfoundation/hardhat-chai-matchers': specifier: ^2.0.2 @@ -88,7 +92,7 @@ importers: version: 4.0.0 solhint-config-thesis: specifier: github:thesis/solhint-config - version: github.com/thesis/solhint-config/159587cd6103f21aaa7fbadc17a06717a51b5b42(solhint@4.0.0) + version: github.com/thesis/solhint-config/266de12d96d58f01171e20858b855ec80520de8d(solhint@4.0.0) solidity-coverage: specifier: ^0.8.5 version: 0.8.5(hardhat@2.19.1) @@ -4421,6 +4425,10 @@ packages: - supports-color dev: true + /@openzeppelin/contracts@5.0.0: + resolution: {integrity: sha512-bv2sdS6LKqVVMLI5+zqnNrNU/CA+6z6CmwFXm/MzmOPBRSO5reEJN7z0Gbzvs0/bv/MZZXNklubpwy3v2+azsw==} + dev: false + /@openzeppelin/defender-admin-client@1.52.0(debug@4.3.4): resolution: {integrity: sha512-CKs5mMLL7+nXyugsHaAw0aPfLwFNA+vq7ftuJ3sWUKdbQRZsJ+/189HAwp2/BJC64yUbarEeWqOh3jNpaKRJLw==} dependencies: @@ -15402,9 +15410,9 @@ packages: prettier: 3.1.0 dev: true - github.com/thesis/solhint-config/159587cd6103f21aaa7fbadc17a06717a51b5b42(solhint@4.0.0): - resolution: {tarball: https://codeload.github.com/thesis/solhint-config/tar.gz/159587cd6103f21aaa7fbadc17a06717a51b5b42} - id: github.com/thesis/solhint-config/159587cd6103f21aaa7fbadc17a06717a51b5b42 + github.com/thesis/solhint-config/266de12d96d58f01171e20858b855ec80520de8d(solhint@4.0.0): + resolution: {tarball: https://codeload.github.com/thesis/solhint-config/tar.gz/266de12d96d58f01171e20858b855ec80520de8d} + id: github.com/thesis/solhint-config/266de12d96d58f01171e20858b855ec80520de8d name: solhint-config-thesis version: 0.1.0 engines: {node: '>=0.10.0'}