diff --git a/core/contracts/shared/IReceiveApproval.sol b/core/contracts/shared/IReceiveApproval.sol new file mode 100644 index 000000000..175e32eb8 --- /dev/null +++ b/core/contracts/shared/IReceiveApproval.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.20; + +/// @notice An interface that should be implemented by contracts supporting +/// `approveAndCall`/`receiveApproval` pattern. +interface IReceiveApproval { + /// @notice Receives approval to spend tokens. Called as a result of + /// `approveAndCall` call on the token. + function receiveApproval( + address from, + uint256 amount, + address token, + bytes calldata extraData + ) external; +} diff --git a/core/contracts/staking/TokenStaking.sol b/core/contracts/staking/TokenStaking.sol new file mode 100644 index 000000000..9866ce02b --- /dev/null +++ b/core/contracts/staking/TokenStaking.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../shared/IReceiveApproval.sol"; + +/// @title TokenStaking +/// @notice A token staking contract for a specified standard ERC20 token. A +/// holder of the specified token can stake its tokens to this contract +/// and recover the stake after undelegation period is over. +contract TokenStaking is IReceiveApproval { + using SafeERC20 for IERC20; + + IERC20 internal immutable token; + + mapping(address => uint256) public balanceOf; + + event Staked(address indexed staker, uint256 amount); + + constructor(IERC20 _token) { + require( + address(_token) != address(0), + "Token can not be the zero address" + ); + + token = _token; + } + + /// @notice Receives approval of token transfer and stakes the approved + /// amount or adds the approved amount to an existing stake. + /// @dev Requires that the provided token contract be the same one linked to + /// this contract. + /// @param from The owner of the tokens who approved them to transfer. + /// @param amount Approved amount for the transfer and stake. + /// @param _token Token contract address. + function receiveApproval( + address from, + uint256 amount, + address _token, + bytes calldata + ) external override { + require(_token == address(token), "Unrecognized token"); + _stake(from, amount); + } + + /// @notice Stakes the owner's tokens in the staking contract. + /// @param amount Approved amount for the transfer and stake. + function stake(uint256 amount) external { + _stake(msg.sender, amount); + } + + /// @notice Returns minimum amount of staking tokens to participate in + /// protocol. + function minimumStake() public pure returns (uint256) { + // TODO: Fetch this param from "parameters" contract that stores + // governable params. + return 1; + } + + /// @notice Returns maximum amount of staking tokens. + function maximumStake() public pure returns (uint256) { + // TODO: Fetch this param from "parameters" contract that stores + // governable params. + return 100 ether; + } + + function _stake(address staker, uint256 amount) private { + require(amount >= minimumStake(), "Amount is less than minimum"); + require(amount <= maximumStake(), "Amount is greater than maxium"); + require(staker != address(0), "Can not be the zero address"); + + balanceOf[staker] += amount; + + // TODO: Mint stBTC token. + emit Staked(staker, amount); + token.safeTransferFrom(staker, address(this), amount); + } +} diff --git a/core/contracts/test/TestToken.sol b/core/contracts/test/TestToken.sol new file mode 100644 index 000000000..943d20bd6 --- /dev/null +++ b/core/contracts/test/TestToken.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity 0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "../shared/IReceiveApproval.sol"; + +contract Token is ERC20 { + constructor() ERC20("Test Token", "TEST") {} + + function mint(address account, uint256 value) external { + _mint(account, value); + } + + function approveAndCall( + address spender, + uint256 amount, + bytes memory extraData + ) external returns (bool) { + if (approve(spender, amount)) { + IReceiveApproval(spender).receiveApproval( + msg.sender, + amount, + address(this), + extraData + ); + return true; + } + return false; + } +} diff --git a/core/deploy/01_deploy_acre.ts b/core/deploy/01_deploy_acre.ts index 3266a1986..6b3121222 100644 --- a/core/deploy/01_deploy_acre.ts +++ b/core/deploy/01_deploy_acre.ts @@ -1,7 +1,7 @@ import type { HardhatRuntimeEnvironment } from "hardhat/types" import type { DeployFunction } from "hardhat-deploy/types" -const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { const { deployments } = hre const { log } = deployments diff --git a/core/package.json b/core/package.json index 2b5649c0b..6b08387c1 100644 --- a/core/package.json +++ b/core/package.json @@ -56,5 +56,8 @@ "ts-node": ">=8.0.0", "typechain": "^8.1.0", "typescript": ">=4.5.0" + }, + "dependencies": { + "@openzeppelin/contracts": "^5.0.0" } } diff --git a/core/test/staking/TokenStaking.test.ts b/core/test/staking/TokenStaking.test.ts new file mode 100644 index 000000000..c6b458bf9 --- /dev/null +++ b/core/test/staking/TokenStaking.test.ts @@ -0,0 +1,98 @@ +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers" +import { ethers } from "hardhat" +import { expect } from "chai" +import { WeiPerEther } from "ethers" +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" +import type { Token, TokenStaking } from "../../typechain" + +async function tokenStakingFixture() { + const [_, tokenHolder] = await ethers.getSigners() + const StakingToken = await ethers.getContractFactory("Token") + const token = await StakingToken.deploy() + + const amountToMint = WeiPerEther * 10000n + + token.mint(tokenHolder, amountToMint) + + const TokenStaking = await ethers.getContractFactory("TokenStaking") + const tokenStaking = await TokenStaking.deploy(await token.getAddress()) + + return { tokenStaking, token, tokenHolder } +} + +describe("TokenStaking", () => { + let tokenStaking: TokenStaking + let token: Token + let tokenHolder: HardhatEthersSigner + + beforeEach(async () => { + ;({ tokenStaking, token, tokenHolder } = + await loadFixture(tokenStakingFixture)) + }) + + describe("staking", () => { + const amountToStake = WeiPerEther * 10n + + describe("when staking via staking contract directly", () => { + beforeEach(async () => { + // Infinite approval for staking contract. + await token + .connect(tokenHolder) + .approve(await tokenStaking.getAddress(), ethers.MaxUint256) + }) + + it("should stake tokens", async () => { + const tokenHolderAddress = await tokenHolder.getAddress() + const tokenBalanceBeforeStake = + await token.balanceOf(tokenHolderAddress) + + await expect(tokenStaking.connect(tokenHolder).stake(amountToStake)) + .to.emit(tokenStaking, "Staked") + .withArgs(tokenHolderAddress, amountToStake) + expect(await tokenStaking.balanceOf(tokenHolderAddress)).to.be.eq( + amountToStake, + ) + expect(await token.balanceOf(tokenHolderAddress)).to.be.eq( + tokenBalanceBeforeStake - amountToStake, + ) + }) + + it("should revert if the staked amount is less than required minimum", async () => { + await expect( + tokenStaking.connect(tokenHolder).stake(0), + ).to.be.revertedWith("Amount is less than minimum") + }) + + it("should revert if the staked amount is grater than maxium stake amount", async () => { + const maxAmount = await tokenStaking.maximumStake() + + await expect( + tokenStaking.connect(tokenHolder).stake(maxAmount + 1n), + ).to.be.revertedWith("Amount is greater than maxium") + }) + }) + + describe("when staking via staking token using approve and call pattern", () => { + it("should stake tokens", async () => { + const tokenHolderAddress = await tokenHolder.getAddress() + const tokenBalanceBeforeStake = + await token.balanceOf(tokenHolderAddress) + const tokenStakingAddress = await tokenStaking.getAddress() + + await expect( + token + .connect(tokenHolder) + .approveAndCall(tokenStakingAddress, amountToStake, "0x"), + ) + .to.emit(tokenStaking, "Staked") + .withArgs(tokenHolderAddress, amountToStake) + expect(await tokenStaking.balanceOf(tokenHolderAddress)).to.be.eq( + amountToStake, + ) + expect(await token.balanceOf(tokenHolderAddress)).to.be.eq( + tokenBalanceBeforeStake - amountToStake, + ) + }) + }) + }) +}) diff --git a/core/yarn.lock b/core/yarn.lock index e2d8e3206..f61f4d6f0 100644 --- a/core/yarn.lock +++ b/core/yarn.lock @@ -879,6 +879,11 @@ resolved "https://registry.yarnpkg.com/@nomiclabs/hardhat-waffle/-/hardhat-waffle-2.0.6.tgz#d11cb063a5f61a77806053e54009c40ddee49a54" integrity sha512-+Wz0hwmJGSI17B+BhU/qFRZ1l6/xMW82QGXE/Gi+WTmwgJrQefuBs1lIf7hzQ1hLk6hpkvb/zwcNkpVKRYTQYg== +"@openzeppelin/contracts@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-5.0.0.tgz#ee0e4b4564f101a5c4ee398cd4d73c0bd92b289c" + integrity sha512-bv2sdS6LKqVVMLI5+zqnNrNU/CA+6z6CmwFXm/MzmOPBRSO5reEJN7z0Gbzvs0/bv/MZZXNklubpwy3v2+azsw== + "@openzeppelin/defender-admin-client@^1.48.0": version "1.49.0" resolved "https://registry.yarnpkg.com/@openzeppelin/defender-admin-client/-/defender-admin-client-1.49.0.tgz#ed07318ccba10ac8a8a33cf594fc18b7ab5889f9"