From 6881ffa48505ec86336cd84a7b58154dede29184 Mon Sep 17 00:00:00 2001 From: Deborah Amoni Date: Thu, 30 Jul 2026 13:59:55 +0100 Subject: [PATCH] feat: add TieredStaking and StakingNFT contracts with tests --- .github/workflows/test.yml | 38 ++++++ .gitignore | 14 +++ .gitmodules | 6 + README.md | 66 ++++++++++ foundry.lock | 14 +++ foundry.toml | 10 ++ lib/forge-std | 1 + lib/openzeppelin-contracts | 1 + script/Counter.s.sol | 19 +++ src/Counter.sol | 14 +++ src/StakingNFT.sol | 59 +++++++++ src/TieredStaking.sol | 106 ++++++++++++++++ test/Counter.t.sol | 24 ++++ test/TieredStaking.t.sol | 249 +++++++++++++++++++++++++++++++++++++ 14 files changed, 621 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 foundry.lock create mode 100644 foundry.toml create mode 160000 lib/forge-std create mode 160000 lib/openzeppelin-contracts create mode 100644 script/Counter.s.sol create mode 100644 src/Counter.sol create mode 100644 src/StakingNFT.sol create mode 100644 src/TieredStaking.sol create mode 100644 test/Counter.t.sol create mode 100644 test/TieredStaking.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b79c8d4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: CI + +permissions: {} + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + name: Foundry project + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: forge --version + + - name: Run Forge fmt + run: forge fmt --check + + - name: Run Forge build + run: forge build --sizes + + - name: Run Forge tests + run: forge test -vvv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/.gitmodules b/.gitmodules index a5e72fb..93de738 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,9 @@ [submodule "wk3- Data Types/day-4/lib/forge-std"] path = wk3- Data Types/day-4/lib/forge-std url = https://github.com/foundry-rs/forge-std.git +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/README.md b/README.md new file mode 100644 index 0000000..8817d6a --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..027ab7d --- /dev/null +++ b/foundry.lock @@ -0,0 +1,14 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.2", + "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" + } + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.7.0", + "rev": "cab19933c33c2ad1d4c7a84864a3601dddfd16f3" + } + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..7bf73da --- /dev/null +++ b/foundry.toml @@ -0,0 +1,10 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +remappings = [ + "forge-std/=lib/forge-std/src/", + "@openzeppelin/=lib/openzeppelin-contracts/" +] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..bf647bd --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit bf647bd6046f2f7da30d0c2bf435e5c76a780c1b diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..cab1993 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit cab19933c33c2ad1d4c7a84864a3601dddfd16f3 diff --git a/script/Counter.s.sol b/script/Counter.s.sol new file mode 100644 index 0000000..f01d69c --- /dev/null +++ b/script/Counter.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterScript is Script { + Counter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + counter = new Counter(); + + vm.stopBroadcast(); + } +} diff --git a/src/Counter.sol b/src/Counter.sol new file mode 100644 index 0000000..aded799 --- /dev/null +++ b/src/Counter.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} diff --git a/src/StakingNFT.sol b/src/StakingNFT.sol new file mode 100644 index 0000000..b6acc81 --- /dev/null +++ b/src/StakingNFT.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +contract StakingNFT is ERC721 { + + address public stakingContract; + + uint256 public nextTokenId = 1; + + // tier (1, 2 or 3) for each token + mapping(uint256 => uint8) public tokenTier; + + // metadata URLs per tier: Bronze, Silver, Gold + string public bronzeURI = "ipfs://YOUR_BRONZE_CID/bronze.json"; + string public silverURI = "ipfs://YOUR_SILVER_CID/silver.json"; + string public goldURI = "ipfs://YOUR_GOLD_CID/gold.json"; + + constructor(address _stakingContract) ERC721("StakingNFT", "SNFT") { + stakingContract = _stakingContract; + } + + // returns the metadata URL for the token based on its tier + function tokenURI(uint256 tokenId) public view override returns (string memory) { + require(ownerOf(tokenId) != address(0), "Token does not exist"); + + uint8 tier = tokenTier[tokenId]; + + if (tier == 3) { + return goldURI; + } else if (tier == 2) { + return silverURI; + } else { + return bronzeURI; + } + } + + // mints a tier NFT to the user, only the staking contract can call this + function mint(address user, uint8 tier) external returns (uint256) { + require(msg.sender == stakingContract, "Only the staking contract can mint"); + + uint256 tokenId = nextTokenId; + nextTokenId++; + + _mint(user, tokenId); + tokenTier[tokenId] = tier; + + return tokenId; + } + + // burns the NFT when the user withdraws, only the staking contract can call this + function burn(uint256 tokenId) external { + require(msg.sender == stakingContract, "Only the staking contract can burn"); + + _burn(tokenId); + delete tokenTier[tokenId]; + } +} diff --git a/src/TieredStaking.sol b/src/TieredStaking.sol new file mode 100644 index 0000000..4f3c784 --- /dev/null +++ b/src/TieredStaking.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {StakingNFT} from "./StakingNFT.sol"; + +contract TieredStaking { + + StakingNFT public nft; + + // receives the 10% penalty on emergency withdrawals + address public owner; + + // one stake per user + struct Stake { + uint256 amount; // ETH deposited + uint256 lockedUntil; // timestamp when lock expires + uint256 nftTokenId; // receipt NFT id + uint8 tier; // 1 = Bronze, 2 = Silver, 3 = Gold + } + + mapping(address => Stake) public stakes; + + event Staked(address indexed user, uint256 amount, uint8 tier, uint256 lockedUntil); + event Withdrawn(address indexed user, uint256 amount); + event EmergencyWithdrawn(address indexed user, uint256 amountReturned, uint256 penaltyPaid); + + // payable so the deployer can seed the contract with ETH at deploy time + constructor() payable { + owner = msg.sender; + nft = new StakingNFT(address(this)); + } + + // deposit ETH and choose a lock duration in seconds + function stake(uint256 lockDurationInSeconds) external payable { + require(msg.value > 0, "You must send some ETH to stake"); + require(lockDurationInSeconds >= 1 days, "Minimum lock is 1 day"); + require(lockDurationInSeconds <= 730 days, "Maximum lock is 730 days"); + require(stakes[msg.sender].amount == 0, "Withdraw your current stake first"); + + // assign tier based on amount sent + uint8 tier; + if (msg.value >= 50 ether) { + tier = 3; // Gold + } else if (msg.value >= 10 ether) { + tier = 2; // Silver + } else { + tier = 1; // Bronze + } + + uint256 lockedUntil = block.timestamp + lockDurationInSeconds; + + // mint the receipt NFT to the user + uint256 nftTokenId = nft.mint(msg.sender, tier); + + stakes[msg.sender] = Stake({ + amount: msg.value, + lockedUntil: lockedUntil, + nftTokenId: nftTokenId, + tier: tier + }); + + emit Staked(msg.sender, msg.value, tier, lockedUntil); + } + + // withdraw after lock expires - burns NFT, returns 100% ETH + function withdraw() external { + Stake memory myStake = stakes[msg.sender]; + + require(myStake.amount > 0, "No active stake"); + require(block.timestamp >= myStake.lockedUntil, "Stake is still locked"); + + // clear state before sending ETH to prevent re-entrancy + delete stakes[msg.sender]; + + nft.burn(myStake.nftTokenId); + + (bool sent, ) = msg.sender.call{value: myStake.amount}(""); + require(sent, "ETH transfer failed"); + + emit Withdrawn(msg.sender, myStake.amount); + } + + // withdraw anytime but lose 10% as a penalty + function emergencyWithdraw() external { + Stake memory myStake = stakes[msg.sender]; + + require(myStake.amount > 0, "No active stake"); + + // clear state before sending ETH to prevent re-entrancy + delete stakes[msg.sender]; + + nft.burn(myStake.nftTokenId); + + uint256 penaltyAmount = (myStake.amount * 10) / 100; + uint256 amountToReturn = myStake.amount - penaltyAmount; + + (bool sentToUser, ) = msg.sender.call{value: amountToReturn}(""); + require(sentToUser, "ETH transfer failed"); + + // penalty goes to the owner + (bool sentToOwner, ) = owner.call{value: penaltyAmount}(""); + require(sentToOwner, "Penalty transfer failed"); + + emit EmergencyWithdrawn(msg.sender, amountToReturn, penaltyAmount); + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol new file mode 100644 index 0000000..4831910 --- /dev/null +++ b/test/Counter.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } +} diff --git a/test/TieredStaking.t.sol b/test/TieredStaking.t.sol new file mode 100644 index 0000000..9e0854b --- /dev/null +++ b/test/TieredStaking.t.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {TieredStaking} from "../src/TieredStaking.sol"; +import {StakingNFT} from "../src/StakingNFT.sol"; + +contract TieredStakingTest is Test { + TieredStaking public staking; + StakingNFT public nft; + + address public deployer = makeAddr("deployer"); + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + function setUp() public { + vm.prank(deployer); + staking = new TieredStaking(); + nft = staking.nft(); + + vm.deal(alice, 200 ether); + vm.deal(bob, 200 ether); + } + + // ── tier tests ──────────────────────────────────────────────────────────── + + function test_Tier1AssignedBelow10Eth() public { + vm.prank(alice); + staking.stake{value: 5 ether}(1 days); + + (uint256 amount,,, uint8 tier) = staking.stakes(alice); + assertEq(amount, 5 ether); + assertEq(tier, 1); + } + + function test_Tier2AssignedAt10Eth() public { + vm.prank(alice); + staking.stake{value: 10 ether}(1 days); + + (,,, uint8 tier) = staking.stakes(alice); + assertEq(tier, 2); + } + + function test_Tier2AssignedBetween10And50Eth() public { + vm.prank(alice); + staking.stake{value: 25 ether}(1 days); + + (,,, uint8 tier) = staking.stakes(alice); + assertEq(tier, 2); + } + + function test_Tier3AssignedAt50Eth() public { + vm.prank(alice); + staking.stake{value: 50 ether}(1 days); + + (,,, uint8 tier) = staking.stakes(alice); + assertEq(tier, 3); + } + + function test_Tier3AssignedAbove50Eth() public { + vm.prank(alice); + staking.stake{value: 100 ether}(1 days); + + (,,, uint8 tier) = staking.stakes(alice); + assertEq(tier, 3); + } + + // ── NFT tests ───────────────────────────────────────────────────────────── + + function test_NFTMintedOnStake() public { + vm.prank(alice); + staking.stake{value: 5 ether}(1 days); + + (,, uint256 nftTokenId,) = staking.stakes(alice); + assertEq(nft.ownerOf(nftTokenId), alice); + assertEq(nft.tokenTier(nftTokenId), 1); + assertEq(nft.balanceOf(alice), 1); + } + + // ── lock time tests ─────────────────────────────────────────────────────── + + function test_LockedUntilSetCorrectly() public { + vm.prank(alice); + staking.stake{value: 1 ether}(30 days); + + (, uint256 lockedUntil,,) = staking.stakes(alice); + assertEq(lockedUntil, block.timestamp + 30 days); + } + + function test_RevertIfLockTooShort() public { + vm.prank(alice); + vm.expectRevert("Minimum lock is 1 day"); + staking.stake{value: 1 ether}(1 hours); + } + + function test_RevertIfLockTooLong() public { + vm.prank(alice); + vm.expectRevert("Maximum lock is 730 days"); + staking.stake{value: 1 ether}(731 days); + } + + function test_RevertWithdrawWhileLocked() public { + vm.prank(alice); + staking.stake{value: 1 ether}(7 days); + + vm.prank(alice); + vm.expectRevert("Stake is still locked"); + staking.withdraw(); + } + + // ── normal withdrawal tests ─────────────────────────────────────────────── + + function test_WithdrawAfterLockReturnsFullAmount() public { + vm.prank(alice); + staking.stake{value: 5 ether}(1 days); + + uint256 balanceBefore = alice.balance; + + vm.warp(block.timestamp + 1 days + 1); + + vm.prank(alice); + staking.withdraw(); + + assertEq(alice.balance, balanceBefore + 5 ether); + } + + function test_WithdrawBurnsNFT() public { + vm.prank(alice); + staking.stake{value: 5 ether}(1 days); + + (,, uint256 nftTokenId,) = staking.stakes(alice); + + vm.warp(block.timestamp + 1 days + 1); + vm.prank(alice); + staking.withdraw(); + + // NFT is burned - OpenZeppelin reverts when you query a burned token + vm.expectRevert(); + nft.ownerOf(nftTokenId); + + assertEq(nft.balanceOf(alice), 0); + } + + function test_WithdrawClearsStake() public { + vm.prank(alice); + staking.stake{value: 5 ether}(1 days); + + vm.warp(block.timestamp + 1 days + 1); + vm.prank(alice); + staking.withdraw(); + + (uint256 amount,,,) = staking.stakes(alice); + assertEq(amount, 0); + } + + // ── emergency withdrawal tests ──────────────────────────────────────────── + + function test_EmergencyWithdrawCharges10PercentPenalty() public { + vm.prank(alice); + staking.stake{value: 10 ether}(7 days); + + uint256 aliceBefore = alice.balance; + uint256 ownerBefore = deployer.balance; + + vm.prank(alice); + staking.emergencyWithdraw(); + + assertEq(alice.balance, aliceBefore + 9 ether); // got back 90% + assertEq(deployer.balance, ownerBefore + 1 ether); // owner got 10% + } + + function test_EmergencyWithdrawWorksWhileLocked() public { + vm.prank(alice); + staking.stake{value: 5 ether}(7 days); // lock not expired + + vm.prank(alice); + staking.emergencyWithdraw(); // should NOT revert + + (uint256 amount,,,) = staking.stakes(alice); + assertEq(amount, 0); + } + + function test_EmergencyWithdrawBurnsNFT() public { + vm.prank(alice); + staking.stake{value: 5 ether}(7 days); + + (,, uint256 nftTokenId,) = staking.stakes(alice); + + vm.prank(alice); + staking.emergencyWithdraw(); + + vm.expectRevert(); + nft.ownerOf(nftTokenId); + } + + // ── tokenURI tests ─────────────────────────────────────────────────────── + + function test_BronzeURIForTier1() public { + vm.prank(alice); + staking.stake{value: 5 ether}(1 days); + + (,, uint256 nftTokenId,) = staking.stakes(alice); + assertEq(nft.tokenURI(nftTokenId), nft.bronzeURI()); + } + + function test_SilverURIForTier2() public { + vm.prank(alice); + staking.stake{value: 10 ether}(1 days); + + (,, uint256 nftTokenId,) = staking.stakes(alice); + assertEq(nft.tokenURI(nftTokenId), nft.silverURI()); + } + + function test_GoldURIForTier3() public { + vm.prank(alice); + staking.stake{value: 50 ether}(1 days); + + (,, uint256 nftTokenId,) = staking.stakes(alice); + assertEq(nft.tokenURI(nftTokenId), nft.goldURI()); + } + + // ── revert tests ────────────────────────────────────────────────────────── + + function test_RevertOnZeroDeposit() public { + vm.prank(alice); + vm.expectRevert("You must send some ETH to stake"); + staking.stake{value: 0}(1 days); + } + + function test_RevertDoubleStake() public { + vm.startPrank(alice); + staking.stake{value: 1 ether}(1 days); + vm.expectRevert("Withdraw your current stake first"); + staking.stake{value: 1 ether}(1 days); + vm.stopPrank(); + } + + function test_RevertWithdrawNoStake() public { + vm.prank(alice); + vm.expectRevert("No active stake"); + staking.withdraw(); + } + + function test_RevertEmergencyWithdrawNoStake() public { + vm.prank(alice); + vm.expectRevert("No active stake"); + staking.emergencyWithdraw(); + } +}