diff --git a/contracts/Staking.sol b/contracts/Staking.sol index af76ea0..aa37def 100644 --- a/contracts/Staking.sol +++ b/contracts/Staking.sol @@ -138,6 +138,7 @@ contract UnchainedStaking is Ownable, IERC721Receiver, ReentrancyGuard { event Extended(address user, uint256 unlock); event StakeIncreased(address user, uint256 amount, uint256[] nftIds); event BlsAddressChanged(address user, bytes32 from, bytes32 to); + event SlashThresholdChanged(uint256 from, uint256 to); /** * @dev Modifier to temporarily allow the contract to receive NFTs. @@ -612,6 +613,7 @@ contract UnchainedStaking is Ownable, IERC721Receiver, ReentrancyGuard { revert Forbidden(); } + emit SlashThresholdChanged(_slashThreshold, threshold); _slashThreshold = threshold; } diff --git a/docs/UnchainedStaking.md b/docs/UnchainedStaking.md index ea27a7e..4a6c593 100644 --- a/docs/UnchainedStaking.md +++ b/docs/UnchainedStaking.md @@ -4,7 +4,7 @@ > UnchainedStaking -TODO +This contract allows users to stake ERC20 tokens and ERC721 NFTs, offering functionalities to stake, unstake, extend stakes, and manage slashing in case of misbehavior. It implements an EIP-712 domain for secure off-chain signature verifications, enabling decentralized governance actions like voting or slashing without on-chain transactions for each vote. The contract includes a slashing mechanism where staked tokens can be slashed (removed from the stake) if the majority of voting power agrees on a misbehavior. Users can stake tokens and NFTs either as consumers or not, affecting their roles within the ecosystem, particularly in governance or voting processes. @@ -87,6 +87,23 @@ function getChainId() external view returns (uint256) |---|---|---| | _0 | uint256 | The current chain ID. | +### getSlashThreshold + +```solidity +function getSlashThreshold() external view returns (uint256) +``` + + + +*Returns the current threshold for slashing to occur. This represents the minimum percentage of total voting power that must agree on a slash for it to be executed.* + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | The slashing threshold as a percentage of total voting power. | + ### increaseStake ```solidity @@ -213,6 +230,22 @@ function setBlsAddress(bytes32 blsAddress) external nonpayable |---|---|---| | blsAddress | bytes32 | The new BLS address to be set for the user. | +### setSlashThreshold + +```solidity +function setSlashThreshold(uint256 threshold) external nonpayable +``` + + + +*Sets the minimum percentage of total voting power required to successfully execute a slash. Only callable by the contract owner. The threshold must be at least 51% to ensure a majority vote.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| threshold | uint256 | The new slashing threshold as a percentage. | + ### slash ```solidity @@ -685,6 +718,23 @@ error LengthMismatch() +### NonceUsed + +```solidity +error NonceUsed(uint256 index, uint256 nonce) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| index | uint256 | undefined | +| nonce | uint256 | undefined | + ### NotConsumer ```solidity @@ -744,6 +794,17 @@ error OwnableUnauthorizedAccount(address account) |---|---|---| | account | address | undefined | +### ReentrancyGuardReentrantCall + +```solidity +error ReentrancyGuardReentrantCall() +``` + + + +*Unauthorized reentrant call.* + + ### SafeERC20FailedOperation ```solidity @@ -781,6 +842,22 @@ error VotingPowerZero(uint256 index) +#### Parameters + +| Name | Type | Description | +|---|---|---| +| index | uint256 | undefined | + +### WrongAccused + +```solidity +error WrongAccused(uint256 index) +``` + + + + + #### Parameters | Name | Type | Description | diff --git a/docs/elin/contracts/utils/ReentrancyGuard.md b/docs/elin/contracts/utils/ReentrancyGuard.md new file mode 100644 index 0000000..20e9c12 --- /dev/null +++ b/docs/elin/contracts/utils/ReentrancyGuard.md @@ -0,0 +1,26 @@ +# ReentrancyGuard + + + + + + + +*Contract module that helps prevent reentrant calls to a function. Inheriting from `ReentrancyGuard` will make the {nonReentrant} modifier available, which can be applied to functions to make sure there are no nested (reentrant) calls to them. Note that because there is a single `nonReentrant` guard, functions marked as `nonReentrant` may not call one another. This can be worked around by making those functions `private`, and then adding `external` `nonReentrant` entry points to them. TIP: If you would like to learn more about reentrancy and alternative ways to protect against it, check out our blog post https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul].* + + + +## Errors + +### ReentrancyGuardReentrantCall + +```solidity +error ReentrancyGuardReentrantCall() +``` + + + +*Unauthorized reentrant call.* + + + diff --git a/test/Staking.js b/test/Staking.js index ec1687f..dece9c3 100644 --- a/test/Staking.js +++ b/test/Staking.js @@ -1,3 +1,166 @@ -const { time } = require("@nomicfoundation/hardhat-network-helpers"); +describe("UnchainedStaking", function () { + let staking, token, nft; + let owner, user1; -describe("Unchained Staking", function () {}); + // FIXME + beforeEach(async function () { + [owner, user1] = await ethers.getSigners(); + + // Deploy Mock ERC20 token + const Token = await ethers.getContractFactory("MockERC20"); + token = await Token.deploy("MockToken", "MTK"); + await token.deployed(); + + // Deploy Mock ERC721 NFT + const NFT = await ethers.getContractFactory("MockERC721"); + nft = await NFT.deploy("MockNFT", "MNFT"); + await nft.deployed(); + + // Deploy the Staking contract + const Staking = await ethers.getContractFactory("UnchainedStaking"); + staking = await Staking.deploy( + token.address, + nft.address, + 10, + owner.address, + "UnchainedStaking", + "1" + ); + await staking.deployed(); + + // Mint tokens and NFT to user1 + await token.mint(user1.address, ethers.utils.parseEther("1000")); + await nft.mint(user1.address, 1); + + // Approve Staking contract to spend tokens and NFT + await token + .connect(user1) + .approve(staking.address, ethers.utils.parseEther("1000")); + await nft.connect(user1).setApprovalForAll(staking.address, true); + }); + + it("allows users to stake tokens and NFTs", async function () { + await expect( + staking + .connect(user1) + .stake(60 * 60 * 24, ethers.utils.parseEther("500"), [1], true) + ) + .to.emit(staking, "Staked") + .withArgs( + user1.address, + ethers.constants.AnyNumber, + ethers.utils.parseEther("500"), + [1], + true + ); + + // Check balances and ownership + expect(await token.balanceOf(staking.address)).to.equal( + ethers.utils.parseEther("500") + ); + expect(await nft.ownerOf(1)).to.equal(staking.address); + }); + + it("allows users to unstake after lock period", async function () { + await staking + .connect(user1) + .stake(1, ethers.utils.parseEther("500"), [1], true); + + // Increase time to surpass the stake duration + await ethers.provider.send("evm_increaseTime", [2]); + await ethers.provider.send("evm_mine"); + + await expect(staking.connect(user1).unstake()) + .to.emit(staking, "UnStaked") + .withArgs(user1.address, ethers.constants.AnyNumber, [1]); + + // Check balances and ownership + expect(await token.balanceOf(user1.address)).to.equal( + ethers.utils.parseEther("500") + ); + expect(await nft.ownerOf(1)).to.equal(user1.address); + }); + + it("rejects staking with zero amount", async function () { + await expect( + staking.connect(user1).stake(60 * 60 * 24, 0, [1], true) + ).to.be.revertedWith("AmountZero()"); + }); + + it("rejects staking with zero duration", async function () { + await expect( + staking.connect(user1).stake(0, ethers.utils.parseEther("500"), [1], true) + ).to.be.revertedWith("DurationZero()"); + }); + + it("rejects staking when already staked without unstaking", async function () { + await staking + .connect(user1) + .stake(60 * 60 * 24, ethers.utils.parseEther("100"), [1], true); + await expect( + staking + .connect(user1) + .stake(60 * 60 * 24, ethers.utils.parseEther("100"), [2], true) + ).to.be.revertedWith("AlreadyStaked()"); + }); + + it("rejects unstaking before duration expires", async function () { + await staking + .connect(user1) + .stake(60 * 60 * 24, ethers.utils.parseEther("500"), [1], true); + + // Attempt to unstake immediately + await expect(staking.connect(user1).unstake()).to.be.revertedWith( + "NotUnlocked()" + ); + }); + + it("successfully slashes a staker based on consensus", async function () { + // This is highly dependent on your slashing mechanism. + // The following is a very simplified version assuming a direct slash call can be made. + + // Setup a scenario where `user1` can be slashed + // Note: The actual setup would depend on your contract's logic for slashing + await staking + .connect(user1) + .stake(60 * 60 * 24 * 365, ethers.utils.parseEther("500"), [1], true); + + // Assume `owner` has the authority to slash directly for this test + await expect( + staking + .connect(owner) + .slash( + [user1.address], + [ethers.utils.parseEther("100")], + ["incidentID"] + ) + ) + .to.emit(staking, "Slashed") + .withArgs( + user1.address, + owner.address, + ethers.utils.parseEther("100"), + ethers.constants.AnyNumber, + "incidentID" + ); + + // Verify the slash was successful + const postSlashStake = await staking.stakeOf(user1.address); + expect(postSlashStake.amount).to.equal(ethers.utils.parseEther("400")); + }); + + it("allows increasing the stake", async function () { + await staking + .connect(user1) + .stake(60 * 60 * 24, ethers.utils.parseEther("500"), [1], true); + + // Increase stake + await staking + .connect(user1) + .increaseStake(ethers.utils.parseEther("500"), [2]); + const postIncreaseStake = await staking.stakeOf(user1.address); + + expect(postIncreaseStake.amount).to.equal(ethers.utils.parseEther("1000")); + expect(postIncreaseStake.nftIds.length).to.equal(2); + }); +});