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'}