From c685c6dd666b45ae11c6ad1517a6a0cd4fb18700 Mon Sep 17 00:00:00 2001 From: unrealtim-tech Date: Wed, 25 Feb 2026 10:36:02 +0100 Subject: [PATCH] feat: implement token contract --- .gitignore | 1 + assignments/TokenContract/.gitignore | 20 + assignments/TokenContract/README.md | 57 +++ .../TokenContract/contracts/Staking.sol | 432 ++++++++++++++++++ assignments/TokenContract/hardhat.config.ts | 38 ++ .../TokenContract/ignition/modules/Counter.ts | 9 + assignments/TokenContract/package.json | 20 + .../TokenContract/scripts/send-op-tx.ts | 22 + assignments/TokenContract/test/Counter.ts | 36 ++ assignments/TokenContract/tsconfig.json | 13 + 10 files changed, 648 insertions(+) create mode 100644 assignments/TokenContract/.gitignore create mode 100644 assignments/TokenContract/README.md create mode 100644 assignments/TokenContract/contracts/Staking.sol create mode 100644 assignments/TokenContract/hardhat.config.ts create mode 100644 assignments/TokenContract/ignition/modules/Counter.ts create mode 100644 assignments/TokenContract/package.json create mode 100644 assignments/TokenContract/scripts/send-op-tx.ts create mode 100644 assignments/TokenContract/test/Counter.ts create mode 100644 assignments/TokenContract/tsconfig.json diff --git a/.gitignore b/.gitignore index ab1b93b7..b6d7aef0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules # Rust build artifacts /target **/target +**/node_modules diff --git a/assignments/TokenContract/.gitignore b/assignments/TokenContract/.gitignore new file mode 100644 index 00000000..991a319e --- /dev/null +++ b/assignments/TokenContract/.gitignore @@ -0,0 +1,20 @@ +# Node modules +/node_modules + +# Compilation output +/dist + +# pnpm deploy output +/bundle + +# Hardhat Build Artifacts +/artifacts + +# Hardhat compilation (v2) support directory +/cache + +# Typechain output +/types + +# Hardhat coverage reports +/coverage diff --git a/assignments/TokenContract/README.md b/assignments/TokenContract/README.md new file mode 100644 index 00000000..968246e9 --- /dev/null +++ b/assignments/TokenContract/README.md @@ -0,0 +1,57 @@ +# Sample Hardhat 3 Beta Project (`mocha` and `ethers`) + +This project showcases a Hardhat 3 Beta project using `mocha` for tests and the `ethers` library for Ethereum interactions. + +To learn more about the Hardhat 3 Beta, please visit the [Getting Started guide](https://hardhat.org/docs/getting-started#getting-started-with-hardhat-3). To share your feedback, join our [Hardhat 3 Beta](https://hardhat.org/hardhat3-beta-telegram-group) Telegram group or [open an issue](https://github.com/NomicFoundation/hardhat/issues/new) in our GitHub issue tracker. + +## Project Overview + +This example project includes: + +- A simple Hardhat configuration file. +- Foundry-compatible Solidity unit tests. +- TypeScript integration tests using `mocha` and ethers.js +- Examples demonstrating how to connect to different types of networks, including locally simulating OP mainnet. + +## Usage + +### Running Tests + +To run all the tests in the project, execute the following command: + +```shell +npx hardhat test +``` + +You can also selectively run the Solidity or `mocha` tests: + +```shell +npx hardhat test solidity +npx hardhat test mocha +``` + +### Make a deployment to Sepolia + +This project includes an example Ignition module to deploy the contract. You can deploy this module to a locally simulated chain or to Sepolia. + +To run the deployment to a local chain: + +```shell +npx hardhat ignition deploy ignition/modules/Counter.ts +``` + +To run the deployment to Sepolia, you need an account with funds to send the transaction. The provided Hardhat configuration includes a Configuration Variable called `SEPOLIA_PRIVATE_KEY`, which you can use to set the private key of the account you want to use. + +You can set the `SEPOLIA_PRIVATE_KEY` variable using the `hardhat-keystore` plugin or by setting it as an environment variable. + +To set the `SEPOLIA_PRIVATE_KEY` config variable using `hardhat-keystore`: + +```shell +npx hardhat keystore set SEPOLIA_PRIVATE_KEY +``` + +After setting the variable, you can run the deployment with the Sepolia network: + +```shell +npx hardhat ignition deploy --network sepolia ignition/modules/Counter.ts +``` diff --git a/assignments/TokenContract/contracts/Staking.sol b/assignments/TokenContract/contracts/Staking.sol new file mode 100644 index 00000000..59bd44fa --- /dev/null +++ b/assignments/TokenContract/contracts/Staking.sol @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: SEE LICENSE IN LICENSE + +pragma solidity ^0.8.31; + +contract ERC20 { + string public name; + string public symbol; + uint8 public decimals; + + uint256 private _totalSupply; + + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor( + string memory _name, + string memory _symbol, + uint8 _decimals, + uint256 _initialSupply + ) { + name = _name; + symbol = _symbol; + decimals = _decimals; + + // FIX: mint initial supply to deployer + if (_initialSupply > 0) { + _mint(msg.sender, _initialSupply); + } + } + + function totalSupply() external view returns (uint256) { + return _totalSupply; + } + + function balanceOf(address _account) external view returns (uint256) { + return _balances[_account]; + } + + function allowance( + address _owner, + address _spender + ) external view returns (uint256) { + return _allowances[_owner][_spender]; + } + + // FIX: removed bogus `returns (uint256)`, visibility is internal (was private — too restrictive) + function _transfer(address _from, address _to, uint256 _amount) internal { + require(_to != address(0), 'transfer to zero address'); + + uint256 senderBalance = _balances[_from]; + require(senderBalance >= _amount, 'insufficient funds'); + + unchecked { + _balances[_from] = senderBalance - _amount; + _balances[_to] += _amount; + } + + emit Transfer(_from, _to, _amount); + } + + function _mint(address _account, uint256 _amount) internal { + require(_account != address(0), 'cannot mint to zero address'); + + _totalSupply += _amount; + _balances[_account] += _amount; + + emit Transfer(address(0), _account, _amount); + } + + // FIX: use >= instead of > so burning exact balance is allowed + // FIX: burn from zero-address check is pointless at runtime; kept for clarity + // FIX: tokens destroyed — do NOT credit address(0) + function _burn(uint256 _amount) internal { + require(msg.sender != address(0), 'cannot call from address 0'); + require(_balances[msg.sender] >= _amount, 'insufficient funds'); + + _balances[msg.sender] -= _amount; + _totalSupply -= _amount; + + emit Transfer(msg.sender, address(0), _amount); + } + + // FIX: renamed to `approve` (public-facing); removed leading underscore convention for external fns + function approve(address _spender, uint256 _amount) external returns (bool) { + _allowances[msg.sender][_spender] = _amount; + emit Approval(msg.sender, _spender, _amount); + return true; + } + + // FIX: renamed to `transferFrom` + function transferFrom( + address _from, + address _to, + uint256 _amount + ) external returns (bool) { + uint256 currentAllowance = _allowances[_from][msg.sender]; + require(currentAllowance >= _amount, 'insufficient allowance'); + + unchecked { + _allowances[_from][msg.sender] = currentAllowance - _amount; + } + + emit Approval(_from, msg.sender, _allowances[_from][msg.sender]); + _transfer(_from, _to, _amount); + return true; + } + + function transfer(address _to, uint256 _amount) external returns (bool) { + _transfer(msg.sender, _to, _amount); + return true; + } + + function mint(uint256 _amount) external returns (bool) { + _mint(msg.sender, _amount); + return true; + } + + // Expose internal balance for child contracts + function _balanceOf(address _account) internal view returns (uint256) { + return _balances[_account]; + } +} + +contract StakingProtocol is ERC20 { + struct Pool { + ERC20 stakingToken; + ERC20 rewardToken; + uint256 rewardRate; + uint256 lockPeriod; + uint256 penaltyBps; + uint256 totalStaked; + uint256 rewardPerTokenStored; + uint256 lastUpdateTime; + bool active; + } + + struct UserInfo { + uint256 staked; + uint256 rewardPerTokenPaid; + uint256 rewards; + uint256 stakedAt; + } + + address public owner; + + Pool[] private _pools; + + mapping(uint256 => mapping(address => UserInfo)) private _userInfo; + + event PoolCreated( + uint256 indexed poolId, + address stakingToken, + address rewardToken, + uint256 rewardRate, + uint256 lockPeriod, + uint256 penaltyBps + ); + event Staked(uint256 indexed poolId, address indexed user, uint256 amount); + event Withdrawn( + uint256 indexed poolId, + address indexed user, + uint256 amount, + uint256 penalty + ); + event RewardsClaimed( + uint256 indexed poolId, + address indexed user, + uint256 amount + ); + event EmergencyWithdraw( + uint256 indexed poolId, + address indexed user, + uint256 amount + ); + event RewardRateUpdated(uint256 indexed poolId, uint256 newRate); + event PoolStatusChanged(uint256 indexed poolId, bool active); + + modifier onlyOwner() { + require(msg.sender == owner, 'not owner'); + _; + } + + modifier validPool(uint256 poolId) { + require(poolId < _pools.length, 'invalid pool'); + _; + } + + modifier updateReward(uint256 poolId, address user) { + Pool storage pool = _pools[poolId]; + + pool.rewardPerTokenStored = _rewardPerToken(pool); + pool.lastUpdateTime = block.timestamp; + + if (user != address(0)) { + UserInfo storage info = _userInfo[poolId][user]; + info.rewards = _earned(pool, info); + info.rewardPerTokenPaid = pool.rewardPerTokenStored; + } + _; + } + + constructor() ERC20('StakingProtocol', 'SKP', 18, 0) { + owner = msg.sender; + } + + function createPool( + address stakingToken, + address rewardToken, + uint256 rewardRate, + uint256 lockPeriod, + uint256 penaltyBps + ) external onlyOwner returns (uint256 poolId) { + require(stakingToken != address(0), 'zero staking token'); + require(rewardToken != address(0), 'zero reward token'); + require(penaltyBps <= 10_000, 'penalty > 100%'); + + poolId = _pools.length; + _pools.push( + Pool({ + stakingToken: ERC20(stakingToken), + rewardToken: ERC20(rewardToken), + rewardRate: rewardRate, + lockPeriod: lockPeriod, + penaltyBps: penaltyBps, + totalStaked: 0, + rewardPerTokenStored: 0, + lastUpdateTime: block.timestamp, + active: true + }) + ); + + emit PoolCreated( + poolId, + stakingToken, + rewardToken, + rewardRate, + lockPeriod, + penaltyBps + ); + } + + function setRewardRate( + uint256 poolId, + uint256 newRate + ) external onlyOwner validPool(poolId) updateReward(poolId, address(0)) { + _pools[poolId].rewardRate = newRate; + emit RewardRateUpdated(poolId, newRate); + } + + function setPoolActive( + uint256 poolId, + bool active + ) external onlyOwner validPool(poolId) { + _pools[poolId].active = active; + emit PoolStatusChanged(poolId, active); + } + + function stake( + uint256 poolId, + uint256 amount + ) external validPool(poolId) updateReward(poolId, msg.sender) { + require(amount > 0, 'cannot stake 0'); + + Pool storage pool = _pools[poolId]; + UserInfo storage info = _userInfo[poolId][msg.sender]; + + require(pool.active, 'pool is paused'); + + pool.stakingToken.transferFrom(msg.sender, address(this), amount); + + pool.totalStaked += amount; + info.staked += amount; + info.stakedAt = block.timestamp; + + emit Staked(poolId, msg.sender, amount); + } + + function withdraw( + uint256 poolId, + uint256 amount + ) external validPool(poolId) updateReward(poolId, msg.sender) { + require(amount > 0, 'cannot withdraw 0'); + + Pool storage pool = _pools[poolId]; + UserInfo storage info = _userInfo[poolId][msg.sender]; + + require(info.staked >= amount, 'over-withdraw'); + + pool.totalStaked -= amount; + info.staked -= amount; + + uint256 penalty = 0; + if ( + pool.lockPeriod > 0 && block.timestamp < info.stakedAt + pool.lockPeriod + ) { + penalty = (amount * pool.penaltyBps) / 10_000; + } + + uint256 net = amount - penalty; + + if (penalty > 0) { + pool.stakingToken.transfer(owner, penalty); + } + pool.stakingToken.transfer(msg.sender, net); + + emit Withdrawn(poolId, msg.sender, net, penalty); + } + + function claimRewards( + uint256 poolId + ) external validPool(poolId) updateReward(poolId, msg.sender) { + UserInfo storage info = _userInfo[poolId][msg.sender]; + uint256 reward = info.rewards; + + require(reward > 0, 'no rewards'); + + info.rewards = 0; + _pools[poolId].rewardToken.transfer(msg.sender, reward); + + emit RewardsClaimed(poolId, msg.sender, reward); + } + + function emergencyWithdraw(uint256 poolId) external validPool(poolId) { + Pool storage pool = _pools[poolId]; + UserInfo storage info = _userInfo[poolId][msg.sender]; + + uint256 amount = info.staked; + require(amount > 0, 'nothing staked'); + + pool.totalStaked -= amount; + info.staked = 0; + info.rewards = 0; + info.rewardPerTokenPaid = pool.rewardPerTokenStored; + + uint256 penalty = 0; + if ( + pool.lockPeriod > 0 && block.timestamp < info.stakedAt + pool.lockPeriod + ) { + penalty = (amount * pool.penaltyBps) / 10_000; + } + + uint256 net = amount - penalty; + + if (penalty > 0) { + pool.stakingToken.transfer(owner, penalty); + } + pool.stakingToken.transfer(msg.sender, net); + + emit EmergencyWithdraw(poolId, msg.sender, amount); + } + + function poolCount() external view returns (uint256) { + return _pools.length; + } + + function getPool( + uint256 poolId + ) + external + view + validPool(poolId) + returns ( + address stakingToken, + address rewardToken, + uint256 rewardRate, + uint256 lockPeriod, + uint256 penaltyBps, + uint256 totalStaked, + bool active + ) + { + Pool storage p = _pools[poolId]; + return ( + address(p.stakingToken), + address(p.rewardToken), + p.rewardRate, + p.lockPeriod, + p.penaltyBps, + p.totalStaked, + p.active + ); + } + + function getUserInfo( + uint256 poolId, + address user + ) + external + view + validPool(poolId) + returns ( + uint256 staked, + uint256 pendingRewards, + uint256 stakedAt, + bool locked + ) + { + Pool storage pool = _pools[poolId]; + UserInfo storage info = _userInfo[poolId][user]; + + staked = info.staked; + pendingRewards = _earned(pool, info); + stakedAt = info.stakedAt; + locked = + pool.lockPeriod > 0 && + block.timestamp < info.stakedAt + pool.lockPeriod; + } + + function _rewardPerToken(Pool storage pool) internal view returns (uint256) { + if (pool.totalStaked == 0) { + return pool.rewardPerTokenStored; + } + uint256 elapsed = block.timestamp - pool.lastUpdateTime; + return + pool.rewardPerTokenStored + + (elapsed * pool.rewardRate * 1e18) / + pool.totalStaked; + } + + function _earned( + Pool storage pool, + UserInfo storage info + ) internal view returns (uint256) { + return + info.rewards + + (info.staked * (_rewardPerToken(pool) - info.rewardPerTokenPaid)) / + 1e18; + } +} diff --git a/assignments/TokenContract/hardhat.config.ts b/assignments/TokenContract/hardhat.config.ts new file mode 100644 index 00000000..7092b852 --- /dev/null +++ b/assignments/TokenContract/hardhat.config.ts @@ -0,0 +1,38 @@ +import hardhatToolboxMochaEthersPlugin from "@nomicfoundation/hardhat-toolbox-mocha-ethers"; +import { configVariable, defineConfig } from "hardhat/config"; + +export default defineConfig({ + plugins: [hardhatToolboxMochaEthersPlugin], + solidity: { + profiles: { + default: { + version: "0.8.28", + }, + production: { + version: "0.8.28", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + }, + }, + networks: { + hardhatMainnet: { + type: "edr-simulated", + chainType: "l1", + }, + hardhatOp: { + type: "edr-simulated", + chainType: "op", + }, + sepolia: { + type: "http", + chainType: "l1", + url: configVariable("SEPOLIA_RPC_URL"), + accounts: [configVariable("SEPOLIA_PRIVATE_KEY")], + }, + }, +}); diff --git a/assignments/TokenContract/ignition/modules/Counter.ts b/assignments/TokenContract/ignition/modules/Counter.ts new file mode 100644 index 00000000..042e61c8 --- /dev/null +++ b/assignments/TokenContract/ignition/modules/Counter.ts @@ -0,0 +1,9 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; + +export default buildModule("CounterModule", (m) => { + const counter = m.contract("Counter"); + + m.call(counter, "incBy", [5n]); + + return { counter }; +}); diff --git a/assignments/TokenContract/package.json b/assignments/TokenContract/package.json new file mode 100644 index 00000000..c6b49d34 --- /dev/null +++ b/assignments/TokenContract/package.json @@ -0,0 +1,20 @@ +{ + "name": "TokenContract", + "version": "1.0.0", + "type": "module", + "devDependencies": { + "@nomicfoundation/hardhat-ethers": "^4.0.4", + "@nomicfoundation/hardhat-ignition": "^3.0.7", + "@nomicfoundation/hardhat-toolbox-mocha-ethers": "^3.0.2", + "@types/chai": "^4.3.20", + "@types/chai-as-promised": "^8.0.2", + "@types/mocha": "^10.0.10", + "@types/node": "^22.19.11", + "chai": "^5.3.3", + "ethers": "^6.16.0", + "forge-std": "github:foundry-rs/forge-std#v1.9.4", + "hardhat": "^3.1.9", + "mocha": "^11.7.5", + "typescript": "~5.8.0" + } +} diff --git a/assignments/TokenContract/scripts/send-op-tx.ts b/assignments/TokenContract/scripts/send-op-tx.ts new file mode 100644 index 00000000..c10a2360 --- /dev/null +++ b/assignments/TokenContract/scripts/send-op-tx.ts @@ -0,0 +1,22 @@ +import { network } from "hardhat"; + +const { ethers } = await network.connect({ + network: "hardhatOp", + chainType: "op", +}); + +console.log("Sending transaction using the OP chain type"); + +const [sender] = await ethers.getSigners(); + +console.log("Sending 1 wei from", sender.address, "to itself"); + +console.log("Sending L2 transaction"); +const tx = await sender.sendTransaction({ + to: sender.address, + value: 1n, +}); + +await tx.wait(); + +console.log("Transaction sent successfully"); diff --git a/assignments/TokenContract/test/Counter.ts b/assignments/TokenContract/test/Counter.ts new file mode 100644 index 00000000..f8c38986 --- /dev/null +++ b/assignments/TokenContract/test/Counter.ts @@ -0,0 +1,36 @@ +import { expect } from "chai"; +import { network } from "hardhat"; + +const { ethers } = await network.connect(); + +describe("Counter", function () { + it("Should emit the Increment event when calling the inc() function", async function () { + const counter = await ethers.deployContract("Counter"); + + await expect(counter.inc()).to.emit(counter, "Increment").withArgs(1n); + }); + + it("The sum of the Increment events should match the current value", async function () { + const counter = await ethers.deployContract("Counter"); + const deploymentBlockNumber = await ethers.provider.getBlockNumber(); + + // run a series of increments + for (let i = 1; i <= 10; i++) { + await counter.incBy(i); + } + + const events = await counter.queryFilter( + counter.filters.Increment(), + deploymentBlockNumber, + "latest", + ); + + // check that the aggregated events match the current value + let total = 0n; + for (const event of events) { + total += event.args.by; + } + + expect(await counter.x()).to.equal(total); + }); +}); diff --git a/assignments/TokenContract/tsconfig.json b/assignments/TokenContract/tsconfig.json new file mode 100644 index 00000000..9b1380cc --- /dev/null +++ b/assignments/TokenContract/tsconfig.json @@ -0,0 +1,13 @@ +/* Based on https://github.com/tsconfig/bases/blob/501da2bcd640cf95c95805783e1012b992338f28/bases/node22.json */ +{ + "compilerOptions": { + "lib": ["es2023"], + "module": "node16", + "target": "es2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "node16", + "outDir": "dist" + } +}