diff --git a/challenge-1-vesting/README.md b/challenge-1-vesting/README.md
index 9cc7a2c..f5a0d0c 100644
--- a/challenge-1-vesting/README.md
+++ b/challenge-1-vesting/README.md
@@ -8,9 +8,9 @@ OpenGuild Labs makes the repository to introduce OpenHack workshop participants
Add your information to the below list to officially participate in the workshop challenge (This is the first mission of the whole workshop)
-| Emoji | Name | Github Username | Occupations |
-| ----- | ---- | ------------------------------------- | ----------- |
-| ๐
| Ippo | [NTP-996](https://github.com/NTP-996) | DevRel |
+| Emoji | Name | Github Username | Occupations |
+| ----- | --------- | ------------------------------------------- | ------------ |
+| โ๏ธ | ฤแปฉc Minh | [DxcMint868](https://github.com/DxcMint868) | Unemployed |
## ๐ป Local development environment setup
diff --git a/challenge-1-vesting/contracts/TokenVesting.sol b/challenge-1-vesting/contracts/TokenVesting.sol
index 43d4c3a..74e4c62 100644
--- a/challenge-1-vesting/contracts/TokenVesting.sol
+++ b/challenge-1-vesting/contracts/TokenVesting.sol
@@ -29,19 +29,27 @@ import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract TokenVesting is Ownable(msg.sender), Pausable, ReentrancyGuard {
- struct VestingSchedule {
// TODO: Define the vesting schedule struct
+ struct VestingSchedule {
+ uint256 totalAmount;
+ uint256 startTime;
+ uint256 cliffDuration;
+ uint256 vestDuration;
+ uint256 amountClaimed;
+ bool revoked;
}
// Token being vested
// TODO: Add state variables
-
+ address public token;
// Mapping from beneficiary to vesting schedule
// TODO: Add state variables
+ mapping(address => VestingSchedule) public vestingSchedules;
// Whitelist of beneficiaries
// TODO: Add state variables
+ mapping(address => bool) public whitelist;
// Events
event VestingScheduleCreated(address indexed beneficiary, uint256 amount);
@@ -50,9 +58,9 @@ contract TokenVesting is Ownable(msg.sender), Pausable, ReentrancyGuard {
event BeneficiaryWhitelisted(address indexed beneficiary);
event BeneficiaryRemovedFromWhitelist(address indexed beneficiary);
- constructor(address tokenAddress) {
- // TODO: Initialize the contract
-
+ constructor(address _tokenAddress) {
+ // TODO: Initialize the contract
+ token = _tokenAddress;
}
// Modifier to check if beneficiary is whitelisted
@@ -76,25 +84,111 @@ contract TokenVesting is Ownable(msg.sender), Pausable, ReentrancyGuard {
address beneficiary,
uint256 amount,
uint256 cliffDuration,
- uint256 vestingDuration,
+ uint256 vestDuration,
uint256 startTime
- ) external onlyOwner onlyWhitelisted(beneficiary) whenNotPaused {
+ )
+ external
+ onlyOwner
+ onlyWhitelisted(beneficiary)
+ whenNotPaused
+ nonReentrant
+ {
// TODO: Implement vesting schedule creation
+ require(
+ startTime > block.timestamp,
+ "Vesting schedule must start in the future"
+ );
+ require(amount > 0, "What are you even trying to vest?");
+
+ require(
+ vestDuration > 0,
+ "Vest duration must be greater than 0, or this contract will miserably fail, bro"
+ );
+
+ VestingSchedule memory schedule = VestingSchedule(
+ amount,
+ startTime,
+ cliffDuration,
+ vestDuration,
+ 0,
+ false
+ );
+
+ vestingSchedules[beneficiary] = schedule;
+
+ IERC20(token).transferFrom(owner(), address(this), amount);
+
+ emit VestingScheduleCreated(beneficiary, amount);
}
function calculateVestedAmount(
address beneficiary
) public view returns (uint256) {
- // TODO: Implement vested amount calculation
+ VestingSchedule memory schedule = vestingSchedules[beneficiary];
+ require(schedule.totalAmount > 0, "Vesting schedule not found");
+
+ uint256 currTimestamp = block.timestamp;
+ uint256 cliffEndTimestamp = schedule.startTime + schedule.cliffDuration;
+
+ // If current time is before the cliff, return 0
+ if (currTimestamp <= cliffEndTimestamp) {
+ return 0;
+ }
+
+ // If vesting is fully completed, return total vested amount
+ if (currTimestamp >= schedule.startTime + schedule.vestDuration) {
+ return schedule.totalAmount;
+ }
+
+ // Calculate vested amount
+ uint256 vestedTime = currTimestamp - schedule.startTime;
+ uint256 vestedAmount = (schedule.totalAmount * vestedTime) /
+ schedule.vestDuration;
+
+ return vestedAmount;
}
- function claimVestedTokens() external nonReentrant whenNotPaused {
- // TODO: Implement token claiming
+ function claimVestedTokens() external whenNotPaused nonReentrant {
+ // TODO: Implement token claiming
+ address beneficiary = _msgSender();
+
+ VestingSchedule memory schedule = vestingSchedules[beneficiary];
+ require(!schedule.revoked, "Vesting schedule revoked");
+
+ uint256 vestedAmount = calculateVestedAmount(beneficiary);
+ uint256 claimableAmount = vestedAmount - schedule.amountClaimed;
+ if (claimableAmount == 0) {
+ revert("No tokens to claim");
+ }
+
+ vestingSchedules[beneficiary].amountClaimed = vestedAmount;
+ emit TokensClaimed(beneficiary, claimableAmount);
+
+ IERC20(token).transfer(beneficiary, claimableAmount);
}
- function revokeVesting(address beneficiary) external onlyOwner {
+ function revokeVesting(
+ address beneficiary
+ ) external onlyOwner nonReentrant {
// TODO: Implement vesting revocation
-
+ VestingSchedule memory schedule = vestingSchedules[beneficiary];
+ require(!schedule.revoked, "Already revoked");
+
+ schedule.revoked = true;
+ vestingSchedules[beneficiary] = schedule;
+ emit VestingRevoked(beneficiary);
+
+ uint256 vestedAmount = calculateVestedAmount(beneficiary);
+ uint256 unclaimedAmount = vestedAmount - schedule.amountClaimed;
+ if (unclaimedAmount > 0) {
+ IERC20(token).transfer(beneficiary, unclaimedAmount);
+ }
+ if (schedule.totalAmount - vestedAmount > 0) {
+ IERC20(token).transfer(
+ owner(),
+ schedule.totalAmount - vestedAmount
+ );
+ }
}
function pause() external onlyOwner {
@@ -145,4 +239,4 @@ Solution template (key points to implement):
- Calculate and transfer unvested tokens back
- Mark schedule as revoked
- Emit event
-*/
\ No newline at end of file
+*/
diff --git a/challenge-2-yield-farm/contracts/yeild.sol b/challenge-2-yield-farm/contracts/yeild.sol
index 421496a..84fb02a 100644
--- a/challenge-2-yield-farm/contracts/yeild.sol
+++ b/challenge-2-yield-farm/contracts/yeild.sol
@@ -71,10 +71,14 @@ contract YieldFarm is ReentrancyGuard, Ownable {
uint256 _rewardRate
) Ownable(msg.sender) {
// TODO: Initialize contract state
+ lpToken = IERC20(_lpToken);
+ rewardToken = IERC20(_rewardToken);
+ rewardRate = _rewardRate;
+ lastUpdateTime = block.timestamp;
}
function updateReward(address _user) internal {
- rewardPerTokenStored = rewardPerToken();
+ rewardPerTokenStored = rewardPerToken(); // get the current reward per token
lastUpdateTime = block.timestamp;
if (_user != address(0)) {
@@ -90,6 +94,15 @@ contract YieldFarm is ReentrancyGuard, Ownable {
// 1. Calculate rewards since last update
// 2. Apply boost multiplier
// 3. Return total pending rewards
+ if (totalStaked == 0) {
+ return 0;
+ }
+
+ uint256 timeDelta = block.timestamp - lastUpdateTime;
+ return
+ rewardPerTokenStored +
+ (timeDelta * rewardRate * 1e18) /
+ totalStaked;
}
function earned(address _user) public view returns (uint256) {
@@ -98,6 +111,14 @@ contract YieldFarm is ReentrancyGuard, Ownable {
// 1. Calculate rewards since last update
// 2. Apply boost multiplier
// 3. Return total pending rewards
+ UserInfo memory user = userInfo[_user];
+ uint256 boostMultiplier = calculateBoostMultiplier(_user);
+ uint256 newRewardPerToken = rewardPerToken();
+ uint256 newReward = (user.amount * newRewardPerToken) /
+ 1e18 -
+ user.rewardDebt;
+
+ return (newReward * boostMultiplier) / 100;
}
/**
@@ -105,12 +126,32 @@ contract YieldFarm is ReentrancyGuard, Ownable {
* @param _amount Amount of LP tokens to stake
*/
function stake(uint256 _amount) external nonReentrant {
- // TODO: Implement staking logic
- // Requirements:
- // 1. Update rewards
- // 2. Transfer LP tokens from user
- // 3. Update user info and total staked amount
- // 4. Emit Staked event
+ require(_amount > 0, "Cannot stake 0");
+
+ address userAddress = msg.sender;
+ UserInfo storage user = userInfo[userAddress];
+
+ // Update user's staking info
+ if (user.amount == 0) {
+ // First time staking, set start time for boost calculation
+ user.startTime = block.timestamp;
+ }
+
+ uint256 claimableRewardTokens = earned(userAddress);
+ if (claimableRewardTokens > 0) {
+ rewardToken.transfer(userAddress, claimableRewardTokens);
+ }
+
+ // Transfer LP tokens from user to contract
+ lpToken.transferFrom(userAddress, address(this), _amount);
+
+ // Update user's staked amount and total staked in contract
+ updateReward(userAddress);
+
+ user.amount += _amount;
+ totalStaked += _amount;
+
+ emit Staked(userAddress, _amount);
}
/**
@@ -124,6 +165,30 @@ contract YieldFarm is ReentrancyGuard, Ownable {
// 2. Transfer LP tokens to user
// 3. Update user info and total staked amount
// 4. Emit Withdrawn event
+
+ address userAddr = msg.sender;
+ UserInfo storage user = userInfo[userAddr];
+
+ require(user.amount >= _amount, "Insufficient balance");
+
+ uint256 claimableRewardTokens = earned(userAddr);
+ if (claimableRewardTokens > 0) {
+ user.pendingRewards = 0;
+ rewardToken.transfer(userAddr, claimableRewardTokens);
+ }
+
+ updateReward(userAddr);
+
+ totalStaked -= _amount;
+ user.amount -= _amount;
+
+ if (user.amount == 0) {
+ user.startTime = 0;
+ }
+
+ emit Withdrawn(userAddr, _amount);
+
+ lpToken.transfer(userAddr, _amount);
}
/**
@@ -136,6 +201,14 @@ contract YieldFarm is ReentrancyGuard, Ownable {
// 2. Transfer rewards to user
// 3. Update user reward debt
// 4. Emit RewardsClaimed event
+ address userAddr = msg.sender;
+
+ uint256 claimableRewardTokens = earned(userAddr);
+ if (claimableRewardTokens > 0) {
+ updateReward(userAddr);
+ rewardToken.transfer(userAddr, claimableRewardTokens);
+ emit RewardsClaimed(userAddr, claimableRewardTokens);
+ }
}
/**
@@ -147,6 +220,18 @@ contract YieldFarm is ReentrancyGuard, Ownable {
// 1. Transfer all LP tokens back to user
// 2. Reset user info
// 3. Emit EmergencyWithdrawn event
+ address userAddr = msg.sender;
+ UserInfo storage user = userInfo[userAddr];
+
+ lpToken.transfer(userAddr, user.amount);
+
+ totalStaked -= user.amount;
+ user.amount = 0;
+ user.startTime = 0;
+ user.rewardDebt = 0;
+ user.pendingRewards = 0;
+
+ emit EmergencyWithdrawn(userAddr, user.amount);
}
/**
@@ -161,6 +246,17 @@ contract YieldFarm is ReentrancyGuard, Ownable {
// Requirements:
// 1. Calculate staking duration
// 2. Return appropriate multiplier based on duration thresholds
+ UserInfo memory user = userInfo[_user];
+ uint256 stakedDuration = block.timestamp - user.startTime;
+ if (stakedDuration > BOOST_THRESHOLD_3) {
+ return 200;
+ } else if (stakedDuration > BOOST_THRESHOLD_2) {
+ return 150;
+ } else if (stakedDuration > BOOST_THRESHOLD_1) {
+ return 125;
+ } else {
+ return 100;
+ }
}
/**
@@ -172,6 +268,11 @@ contract YieldFarm is ReentrancyGuard, Ownable {
// Requirements:
// 1. Update rewards before changing rate
// 2. Set new reward rate
+ // Update rewards before changing rate
+ updateReward(address(0));
+
+ // Set new reward rate
+ rewardRate = _newRate;
}
/**
diff --git a/challenge-2-yield-farm/package-lock.json b/challenge-2-yield-farm/package-lock.json
index 4cbdfb0..72d0088 100644
--- a/challenge-2-yield-farm/package-lock.json
+++ b/challenge-2-yield-farm/package-lock.json
@@ -13,6 +13,7 @@
},
"devDependencies": {
"@nomicfoundation/hardhat-toolbox": "^5.0.0",
+ "dotenv": "^16.4.7",
"hardhat": "^2.22.17"
}
},
@@ -3383,6 +3384,19 @@
"node": ">=8"
}
},
+ "node_modules/dotenv": {
+ "version": "16.4.7",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
+ "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz",
diff --git a/challenge-2-yield-farm/package.json b/challenge-2-yield-farm/package.json
index 5366ac8..910b509 100644
--- a/challenge-2-yield-farm/package.json
+++ b/challenge-2-yield-farm/package.json
@@ -14,6 +14,7 @@
},
"devDependencies": {
"@nomicfoundation/hardhat-toolbox": "^5.0.0",
+ "dotenv": "^16.4.7",
"hardhat": "^2.22.17"
},
"dependencies": {
diff --git a/challenge-3-frontend/abis/Token.json b/challenge-3-frontend/abis/Token.json
new file mode 100644
index 0000000..dbf2ae6
--- /dev/null
+++ b/challenge-3-frontend/abis/Token.json
@@ -0,0 +1,353 @@
+{
+ "_format": "hh-sol-artifact-1",
+ "contractName": "MockERC20",
+ "sourceName": "contracts/token.sol",
+ "abi": [
+ {
+ "inputs": [
+ {
+ "internalType": "string",
+ "name": "name",
+ "type": "string"
+ },
+ {
+ "internalType": "string",
+ "name": "symbol",
+ "type": "string"
+ }
+ ],
+ "stateMutability": "nonpayable",
+ "type": "constructor"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "spender",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "allowance",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "needed",
+ "type": "uint256"
+ }
+ ],
+ "name": "ERC20InsufficientAllowance",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "sender",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "balance",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "needed",
+ "type": "uint256"
+ }
+ ],
+ "name": "ERC20InsufficientBalance",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "approver",
+ "type": "address"
+ }
+ ],
+ "name": "ERC20InvalidApprover",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "receiver",
+ "type": "address"
+ }
+ ],
+ "name": "ERC20InvalidReceiver",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "sender",
+ "type": "address"
+ }
+ ],
+ "name": "ERC20InvalidSender",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "spender",
+ "type": "address"
+ }
+ ],
+ "name": "ERC20InvalidSpender",
+ "type": "error"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "spender",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "value",
+ "type": "uint256"
+ }
+ ],
+ "name": "Approval",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "from",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "to",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "value",
+ "type": "uint256"
+ }
+ ],
+ "name": "Transfer",
+ "type": "event"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ },
+ {
+ "internalType": "address",
+ "name": "spender",
+ "type": "address"
+ }
+ ],
+ "name": "allowance",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "spender",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "value",
+ "type": "uint256"
+ }
+ ],
+ "name": "approve",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "account",
+ "type": "address"
+ }
+ ],
+ "name": "balanceOf",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "decimals",
+ "outputs": [
+ {
+ "internalType": "uint8",
+ "name": "",
+ "type": "uint8"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "account",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "mint",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "name",
+ "outputs": [
+ {
+ "internalType": "string",
+ "name": "",
+ "type": "string"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "symbol",
+ "outputs": [
+ {
+ "internalType": "string",
+ "name": "",
+ "type": "string"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "totalSupply",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "to",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "value",
+ "type": "uint256"
+ }
+ ],
+ "name": "transfer",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "from",
+ "type": "address"
+ },
+ {
+ "internalType": "address",
+ "name": "to",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "value",
+ "type": "uint256"
+ }
+ ],
+ "name": "transferFrom",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ }
+ ],
+ "bytecode": "0x60806040523480156200001157600080fd5b5060405162000a6c38038062000a6c833981016040819052620000349162000123565b818160036200004483826200021c565b5060046200005382826200021c565b5050505050620002e8565b634e487b7160e01b600052604160045260246000fd5b600082601f8301126200008657600080fd5b81516001600160401b0380821115620000a357620000a36200005e565b604051601f8301601f19908116603f01168101908282118183101715620000ce57620000ce6200005e565b81604052838152602092508683858801011115620000eb57600080fd5b600091505b838210156200010f5785820183015181830184015290820190620000f0565b600093810190920192909252949350505050565b600080604083850312156200013757600080fd5b82516001600160401b03808211156200014f57600080fd5b6200015d8683870162000074565b935060208501519150808211156200017457600080fd5b50620001838582860162000074565b9150509250929050565b600181811c90821680620001a257607f821691505b602082108103620001c357634e487b7160e01b600052602260045260246000fd5b50919050565b601f8211156200021757600081815260208120601f850160051c81016020861015620001f25750805b601f850160051c820191505b818110156200021357828155600101620001fe565b5050505b505050565b81516001600160401b038111156200023857620002386200005e565b62000250816200024984546200018d565b84620001c9565b602080601f8311600181146200028857600084156200026f5750858301515b600019600386901b1c1916600185901b17855562000213565b600085815260208120601f198616915b82811015620002b95788860151825594840194600190910190840162000298565b5085821015620002d85787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b61077480620002f86000396000f3fe608060405234801561001057600080fd5b506004361061008e5760003560e01c806306fdde0314610093578063095ea7b3146100b157806318160ddd146100d457806323b872dd146100e6578063313ce567146100f957806340c10f191461010857806370a082311461011d57806395d89b4114610146578063a9059cbb1461014e578063dd62ed3e14610161575b600080fd5b61009b610174565b6040516100a89190610589565b60405180910390f35b6100c46100bf3660046105f3565b610206565b60405190151581526020016100a8565b6002545b6040519081526020016100a8565b6100c46100f436600461061d565b610220565b604051601281526020016100a8565b61011b6101163660046105f3565b610244565b005b6100d861012b366004610659565b6001600160a01b031660009081526020819052604090205490565b61009b610252565b6100c461015c3660046105f3565b610261565b6100d861016f36600461067b565b61026f565b606060038054610183906106ae565b80601f01602080910402602001604051908101604052809291908181526020018280546101af906106ae565b80156101fc5780601f106101d1576101008083540402835291602001916101fc565b820191906000526020600020905b8154815290600101906020018083116101df57829003601f168201915b5050505050905090565b60003361021481858561029a565b60019150505b92915050565b60003361022e8582856102ac565b610239858585610308565b506001949350505050565b61024e8282610367565b5050565b606060048054610183906106ae565b600033610214818585610308565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6102a7838383600161039d565b505050565b60006102b8848461026f565b9050600019811461030257818110156102f357828183604051637dc7a0d960e11b81526004016102ea939291906106e8565b60405180910390fd5b6103028484848403600061039d565b50505050565b6001600160a01b038316610332576000604051634b637e8f60e11b81526004016102ea9190610709565b6001600160a01b03821661035c57600060405163ec442f0560e01b81526004016102ea9190610709565b6102a7838383610472565b6001600160a01b03821661039157600060405163ec442f0560e01b81526004016102ea9190610709565b61024e60008383610472565b6001600160a01b0384166103c757600060405163e602df0560e01b81526004016102ea9190610709565b6001600160a01b0383166103f1576000604051634a1406b160e11b81526004016102ea9190610709565b6001600160a01b038085166000908152600160209081526040808320938716835292905220829055801561030257826001600160a01b0316846001600160a01b03167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9258460405161046491815260200190565b60405180910390a350505050565b6001600160a01b03831661049d578060026000828254610492919061071d565b909155506104fc9050565b6001600160a01b038316600090815260208190526040902054818110156104dd5783818360405163391434e360e21b81526004016102ea939291906106e8565b6001600160a01b03841660009081526020819052604090209082900390555b6001600160a01b03821661051857600280548290039055610537565b6001600160a01b03821660009081526020819052604090208054820190555b816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8360405161057c91815260200190565b60405180910390a3505050565b600060208083528351808285015260005b818110156105b65785810183015185820160400152820161059a565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b03811681146105ee57600080fd5b919050565b6000806040838503121561060657600080fd5b61060f836105d7565b946020939093013593505050565b60008060006060848603121561063257600080fd5b61063b846105d7565b9250610649602085016105d7565b9150604084013590509250925092565b60006020828403121561066b57600080fd5b610674826105d7565b9392505050565b6000806040838503121561068e57600080fd5b610697836105d7565b91506106a5602084016105d7565b90509250929050565b600181811c908216806106c257607f821691505b6020821081036106e257634e487b7160e01b600052602260045260246000fd5b50919050565b6001600160a01b039390931683526020830191909152604082015260600190565b6001600160a01b0391909116815260200190565b8082018082111561021a57634e487b7160e01b600052601160045260246000fdfea26469706673582212209347d21ac20032ee3f25505032ec818f7c691d846517784428650571f95574d964736f6c63430008140033",
+ "deployedBytecode": "0x608060405234801561001057600080fd5b506004361061008e5760003560e01c806306fdde0314610093578063095ea7b3146100b157806318160ddd146100d457806323b872dd146100e6578063313ce567146100f957806340c10f191461010857806370a082311461011d57806395d89b4114610146578063a9059cbb1461014e578063dd62ed3e14610161575b600080fd5b61009b610174565b6040516100a89190610589565b60405180910390f35b6100c46100bf3660046105f3565b610206565b60405190151581526020016100a8565b6002545b6040519081526020016100a8565b6100c46100f436600461061d565b610220565b604051601281526020016100a8565b61011b6101163660046105f3565b610244565b005b6100d861012b366004610659565b6001600160a01b031660009081526020819052604090205490565b61009b610252565b6100c461015c3660046105f3565b610261565b6100d861016f36600461067b565b61026f565b606060038054610183906106ae565b80601f01602080910402602001604051908101604052809291908181526020018280546101af906106ae565b80156101fc5780601f106101d1576101008083540402835291602001916101fc565b820191906000526020600020905b8154815290600101906020018083116101df57829003601f168201915b5050505050905090565b60003361021481858561029a565b60019150505b92915050565b60003361022e8582856102ac565b610239858585610308565b506001949350505050565b61024e8282610367565b5050565b606060048054610183906106ae565b600033610214818585610308565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b6102a7838383600161039d565b505050565b60006102b8848461026f565b9050600019811461030257818110156102f357828183604051637dc7a0d960e11b81526004016102ea939291906106e8565b60405180910390fd5b6103028484848403600061039d565b50505050565b6001600160a01b038316610332576000604051634b637e8f60e11b81526004016102ea9190610709565b6001600160a01b03821661035c57600060405163ec442f0560e01b81526004016102ea9190610709565b6102a7838383610472565b6001600160a01b03821661039157600060405163ec442f0560e01b81526004016102ea9190610709565b61024e60008383610472565b6001600160a01b0384166103c757600060405163e602df0560e01b81526004016102ea9190610709565b6001600160a01b0383166103f1576000604051634a1406b160e11b81526004016102ea9190610709565b6001600160a01b038085166000908152600160209081526040808320938716835292905220829055801561030257826001600160a01b0316846001600160a01b03167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9258460405161046491815260200190565b60405180910390a350505050565b6001600160a01b03831661049d578060026000828254610492919061071d565b909155506104fc9050565b6001600160a01b038316600090815260208190526040902054818110156104dd5783818360405163391434e360e21b81526004016102ea939291906106e8565b6001600160a01b03841660009081526020819052604090209082900390555b6001600160a01b03821661051857600280548290039055610537565b6001600160a01b03821660009081526020819052604090208054820190555b816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8360405161057c91815260200190565b60405180910390a3505050565b600060208083528351808285015260005b818110156105b65785810183015185820160400152820161059a565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b03811681146105ee57600080fd5b919050565b6000806040838503121561060657600080fd5b61060f836105d7565b946020939093013593505050565b60008060006060848603121561063257600080fd5b61063b846105d7565b9250610649602085016105d7565b9150604084013590509250925092565b60006020828403121561066b57600080fd5b610674826105d7565b9392505050565b6000806040838503121561068e57600080fd5b610697836105d7565b91506106a5602084016105d7565b90509250929050565b600181811c908216806106c257607f821691505b6020821081036106e257634e487b7160e01b600052602260045260246000fd5b50919050565b6001600160a01b039390931683526020830191909152604082015260600190565b6001600160a01b0391909116815260200190565b8082018082111561021a57634e487b7160e01b600052601160045260246000fdfea26469706673582212209347d21ac20032ee3f25505032ec818f7c691d846517784428650571f95574d964736f6c63430008140033",
+ "linkReferences": {},
+ "deployedLinkReferences": {}
+}
diff --git a/challenge-3-frontend/abis/TokenVesting.json b/challenge-3-frontend/abis/TokenVesting.json
new file mode 100644
index 0000000..e4cea15
--- /dev/null
+++ b/challenge-3-frontend/abis/TokenVesting.json
@@ -0,0 +1,415 @@
+{
+ "_format": "hh-sol-artifact-1",
+ "contractName": "TokenVesting",
+ "sourceName": "contracts/TokenVesting.sol",
+ "abi": [
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "_tokenAddress",
+ "type": "address"
+ }
+ ],
+ "stateMutability": "nonpayable",
+ "type": "constructor"
+ },
+ {
+ "inputs": [],
+ "name": "EnforcedPause",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "ExpectedPause",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ }
+ ],
+ "name": "OwnableInvalidOwner",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "account",
+ "type": "address"
+ }
+ ],
+ "name": "OwnableUnauthorizedAccount",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "ReentrancyGuardReentrantCall",
+ "type": "error"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ }
+ ],
+ "name": "BeneficiaryRemovedFromWhitelist",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ }
+ ],
+ "name": "BeneficiaryWhitelisted",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "previousOwner",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "newOwner",
+ "type": "address"
+ }
+ ],
+ "name": "OwnershipTransferred",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": false,
+ "internalType": "address",
+ "name": "account",
+ "type": "address"
+ }
+ ],
+ "name": "Paused",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "TokensClaimed",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": false,
+ "internalType": "address",
+ "name": "account",
+ "type": "address"
+ }
+ ],
+ "name": "Unpaused",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ }
+ ],
+ "name": "VestingRevoked",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "VestingScheduleCreated",
+ "type": "event"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ }
+ ],
+ "name": "addToWhitelist",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ }
+ ],
+ "name": "calculateVestedAmount",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "claimVestedTokens",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "cliffDuration",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "vestDuration",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "startTime",
+ "type": "uint256"
+ }
+ ],
+ "name": "createVestingSchedule",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "owner",
+ "outputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "pause",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "paused",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ }
+ ],
+ "name": "removeFromWhitelist",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "renounceOwnership",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "beneficiary",
+ "type": "address"
+ }
+ ],
+ "name": "revokeVesting",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "token",
+ "outputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "newOwner",
+ "type": "address"
+ }
+ ],
+ "name": "transferOwnership",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "unpause",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "name": "vestingSchedules",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "totalAmount",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "startTime",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "cliffDuration",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "vestDuration",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "amountClaimed",
+ "type": "uint256"
+ },
+ {
+ "internalType": "bool",
+ "name": "revoked",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "name": "whitelist",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ }
+ ],
+ "bytecode": "0x608060405234801561001057600080fd5b5060405161112638038061112683398101604081905261002f916100e5565b338061005557604051631e4fbdf760e01b81526000600482015260240160405180910390fd5b61005e81610095565b506000805460ff60a01b1916905560018055600280546001600160a01b0319166001600160a01b0392909216919091179055610115565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6000602082840312156100f757600080fd5b81516001600160a01b038116811461010e57600080fd5b9392505050565b611002806101246000396000f3fe608060405234801561001057600080fd5b50600436106100c55760003560e01c80631bf0b08b146100ca5780633b0da260146100df5780633f4ba83a146100f25780635c975abb146100fa578063715018a6146101175780638456cb591461011f5780638ab1d681146101275780638da5cb5b1461013a5780639b19251a1461014f578063e43252d714610172578063e74f3fbb14610185578063f2fde38b1461018d578063fc0c546a146101a0578063fdb20ccb146101b3578063ffa06b2a1461022b575b600080fd5b6100dd6100d8366004610e9e565b61024c565b005b6100dd6100ed366004610ee0565b6105c3565b6100dd61084f565b610102610861565b60405190151581526020015b60405180910390f35b6100dd610871565b6100dd610883565b6100dd610135366004610ee0565b610893565b6101426108e4565b60405161010e9190610f02565b61010261015d366004610ee0565b60046020526000908152604090205460ff1681565b6100dd610180366004610ee0565b6108f3565b6100dd61098f565b6100dd61019b366004610ee0565b610b82565b600254610142906001600160a01b031681565b6101fc6101c1366004610ee0565b600360208190526000918252604090912080546001820154600283015493830154600484015460059094015492949193919290919060ff1686565b6040805196875260208701959095529385019290925260608401526080830152151560a082015260c00161010e565b61023e610239366004610ee0565b610bbd565b60405190815260200161010e565b610254610cf9565b6001600160a01b038516600090815260046020526040902054859060ff166102c15760405162461bcd60e51b815260206004820152601b60248201527a10995b99599a58da585c9e481b9bdd081dda1a5d195b1a5cdd1959602a1b60448201526064015b60405180910390fd5b6102c9610d2b565b6102d1610d51565b4282116103325760405162461bcd60e51b815260206004820152602960248201527f56657374696e67207363686564756c65206d75737420737461727420696e207460448201526868652066757475726560b81b60648201526084016102b8565b6000851161038c5760405162461bcd60e51b815260206004820152602160248201527f576861742061726520796f75206576656e20747279696e6720746f20766573746044820152603f60f81b60648201526084016102b8565b6000831161041a5760405162461bcd60e51b815260206004820152604f60248201527f56657374206475726174696f6e206d757374206265206772656174657220746860448201527f616e20302c206f72207468697320636f6e74726163742077696c6c206d69736560648201526e7261626c79206661696c2c2062726f60881b608482015260a4016102b8565b60006040518060c00160405280878152602001848152602001868152602001858152602001600081526020016000151581525090508060036000896001600160a01b03166001600160a01b03168152602001908152602001600020600082015181600001556020820151816001015560408201518160020155606082015181600301556080820151816004015560a08201518160050160006101000a81548160ff021916908315150217905550905050600260009054906101000a90046001600160a01b03166001600160a01b03166323b872dd6104f66108e4565b6040516001600160e01b031960e084901b1681526001600160a01b039091166004820152306024820152604481018990526064016020604051808303816000875af1158015610549573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061056d9190610f16565b50866001600160a01b03167f969705509595726740fe60cc30769bbd53c883efff4d8e70108a82817e0392a9876040516105a991815260200190565b60405180910390a2506105bb60018055565b505050505050565b6105cb610cf9565b6105d3610d51565b6001600160a01b038116600090815260036020818152604092839020835160c081018552815481526001820154928101929092526002810154938201939093529082015460608201526004820154608082015260059091015460ff1615801560a08301526106755760405162461bcd60e51b815260206004820152600f60248201526e105b1c9958591e481c995d9bdad959608a1b60448201526064016102b8565b600160a082018181526001600160a01b03841660008181526003602081815260408084208851815591880151968201969096558587015160028201556060870151918101919091556080860151600482015592516005909301805460ff19169315159390931790925591517f68d870ac0aff3819234e8a1fc8f357b40d75212f2dc8594b97690fa205b3bab29190a2600061070f83610bbd565b905060008260800151826107239190610f4e565b905080156107a25760025460405163a9059cbb60e01b81526001600160a01b039091169063a9059cbb9061075d9087908590600401610f67565b6020604051808303816000875af115801561077c573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107a09190610f16565b505b82516000906107b2908490610f4e565b1115610840576002546001600160a01b031663a9059cbb6107d16108e4565b85516107de908690610f4e565b6040518363ffffffff1660e01b81526004016107fb929190610f67565b6020604051808303816000875af115801561081a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061083e9190610f16565b505b50505061084c60018055565b50565b610857610cf9565b61085f610d7b565b565b600054600160a01b900460ff1690565b610879610cf9565b61085f6000610dca565b61088b610cf9565b61085f610e1a565b61089b610cf9565b6001600160a01b038116600081815260046020526040808220805460ff19169055517f1ecef1b5180dc14b16608c5c5ec1fa28998e2f94e460b91c1b50bdfb643cc1389190a250565b6000546001600160a01b031690565b6108fb610cf9565b6001600160a01b0381166109435760405162461bcd60e51b815260206004820152600f60248201526e496e76616c6964206164647265737360881b60448201526064016102b8565b6001600160a01b038116600081815260046020526040808220805460ff19166001179055517f07e751107375f503d05dfa76b5038ce1c5b7d46e9e45768f913ac333780394399190a250565b610997610d2b565b61099f610d51565b33600081815260036020818152604092839020835160c081018552815481526001820154928101929092526002810154938201939093529082015460608201526004820154608082015260059091015460ff1615801560a0830152610a415760405162461bcd60e51b815260206004820152601860248201527715995cdd1a5b99c81cd8da19591d5b19481c995d9bdad95960421b60448201526064016102b8565b6000610a4c83610bbd565b90506000826080015182610a609190610f4e565b905080600003610aa75760405162461bcd60e51b81526020600482015260126024820152714e6f20746f6b656e7320746f20636c61696d60701b60448201526064016102b8565b6001600160a01b03841660008181526003602052604090819020600401849055517f896e034966eaaf1adc54acc0f257056febbd300c9e47182cf761982cf1f5e43090610af79084815260200190565b60405180910390a260025460405163a9059cbb60e01b81526001600160a01b039091169063a9059cbb90610b319087908590600401610f67565b6020604051808303816000875af1158015610b50573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610b749190610f16565b505050505061085f60018055565b610b8a610cf9565b6001600160a01b038116610bb4576000604051631e4fbdf760e01b81526004016102b89190610f02565b61084c81610dca565b6001600160a01b0381166000908152600360208181526040808420815160c08101835281548082526001830154948201949094526002820154928101929092529283015460608201526004830154608082015260059092015460ff16151560a0830152610c695760405162461bcd60e51b815260206004820152601a60248201527915995cdd1a5b99c81cd8da19591d5b19481b9bdd08199bdd5b9960321b60448201526064016102b8565b604081015160208201514291600091610c829190610f80565b9050808211610c9657506000949350505050565b82606001518360200151610caa9190610f80565b8210610cb95750505192915050565b6000836020015183610ccb9190610f4e565b905060008460600151828660000151610ce49190610f93565b610cee9190610faa565b979650505050505050565b33610d026108e4565b6001600160a01b03161461085f573360405163118cdaa760e01b81526004016102b89190610f02565b610d33610861565b1561085f5760405163d93c066560e01b815260040160405180910390fd5b600260015403610d7457604051633ee5aeb560e01b815260040160405180910390fd5b6002600155565b610d83610e5d565b6000805460ff60a01b191690557f5db9ee0a495bf2e6ff9c91a7834c1ba4fdd244a5e8aa4e537bd38aeae4b073aa335b604051610dc09190610f02565b60405180910390a1565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b610e22610d2b565b6000805460ff60a01b1916600160a01b1790557f62e78cea01bee320cd4e420270b5ea74000d11b0c9f74754ebdbfc544b05a258610db33390565b610e65610861565b61085f57604051638dfc202b60e01b815260040160405180910390fd5b80356001600160a01b0381168114610e9957600080fd5b919050565b600080600080600060a08688031215610eb657600080fd5b610ebf86610e82565b97602087013597506040870135966060810135965060800135945092505050565b600060208284031215610ef257600080fd5b610efb82610e82565b9392505050565b6001600160a01b0391909116815260200190565b600060208284031215610f2857600080fd5b81518015158114610efb57600080fd5b634e487b7160e01b600052601160045260246000fd5b81810381811115610f6157610f61610f38565b92915050565b6001600160a01b03929092168252602082015260400190565b80820180821115610f6157610f61610f38565b8082028115828204841417610f6157610f61610f38565b600082610fc757634e487b7160e01b600052601260045260246000fd5b50049056fea2646970667358221220c959251d2b14022f03b3227478d3f07bbba6712558cbf46497703f39879c40a464736f6c63430008140033",
+ "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100c55760003560e01c80631bf0b08b146100ca5780633b0da260146100df5780633f4ba83a146100f25780635c975abb146100fa578063715018a6146101175780638456cb591461011f5780638ab1d681146101275780638da5cb5b1461013a5780639b19251a1461014f578063e43252d714610172578063e74f3fbb14610185578063f2fde38b1461018d578063fc0c546a146101a0578063fdb20ccb146101b3578063ffa06b2a1461022b575b600080fd5b6100dd6100d8366004610e9e565b61024c565b005b6100dd6100ed366004610ee0565b6105c3565b6100dd61084f565b610102610861565b60405190151581526020015b60405180910390f35b6100dd610871565b6100dd610883565b6100dd610135366004610ee0565b610893565b6101426108e4565b60405161010e9190610f02565b61010261015d366004610ee0565b60046020526000908152604090205460ff1681565b6100dd610180366004610ee0565b6108f3565b6100dd61098f565b6100dd61019b366004610ee0565b610b82565b600254610142906001600160a01b031681565b6101fc6101c1366004610ee0565b600360208190526000918252604090912080546001820154600283015493830154600484015460059094015492949193919290919060ff1686565b6040805196875260208701959095529385019290925260608401526080830152151560a082015260c00161010e565b61023e610239366004610ee0565b610bbd565b60405190815260200161010e565b610254610cf9565b6001600160a01b038516600090815260046020526040902054859060ff166102c15760405162461bcd60e51b815260206004820152601b60248201527a10995b99599a58da585c9e481b9bdd081dda1a5d195b1a5cdd1959602a1b60448201526064015b60405180910390fd5b6102c9610d2b565b6102d1610d51565b4282116103325760405162461bcd60e51b815260206004820152602960248201527f56657374696e67207363686564756c65206d75737420737461727420696e207460448201526868652066757475726560b81b60648201526084016102b8565b6000851161038c5760405162461bcd60e51b815260206004820152602160248201527f576861742061726520796f75206576656e20747279696e6720746f20766573746044820152603f60f81b60648201526084016102b8565b6000831161041a5760405162461bcd60e51b815260206004820152604f60248201527f56657374206475726174696f6e206d757374206265206772656174657220746860448201527f616e20302c206f72207468697320636f6e74726163742077696c6c206d69736560648201526e7261626c79206661696c2c2062726f60881b608482015260a4016102b8565b60006040518060c00160405280878152602001848152602001868152602001858152602001600081526020016000151581525090508060036000896001600160a01b03166001600160a01b03168152602001908152602001600020600082015181600001556020820151816001015560408201518160020155606082015181600301556080820151816004015560a08201518160050160006101000a81548160ff021916908315150217905550905050600260009054906101000a90046001600160a01b03166001600160a01b03166323b872dd6104f66108e4565b6040516001600160e01b031960e084901b1681526001600160a01b039091166004820152306024820152604481018990526064016020604051808303816000875af1158015610549573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061056d9190610f16565b50866001600160a01b03167f969705509595726740fe60cc30769bbd53c883efff4d8e70108a82817e0392a9876040516105a991815260200190565b60405180910390a2506105bb60018055565b505050505050565b6105cb610cf9565b6105d3610d51565b6001600160a01b038116600090815260036020818152604092839020835160c081018552815481526001820154928101929092526002810154938201939093529082015460608201526004820154608082015260059091015460ff1615801560a08301526106755760405162461bcd60e51b815260206004820152600f60248201526e105b1c9958591e481c995d9bdad959608a1b60448201526064016102b8565b600160a082018181526001600160a01b03841660008181526003602081815260408084208851815591880151968201969096558587015160028201556060870151918101919091556080860151600482015592516005909301805460ff19169315159390931790925591517f68d870ac0aff3819234e8a1fc8f357b40d75212f2dc8594b97690fa205b3bab29190a2600061070f83610bbd565b905060008260800151826107239190610f4e565b905080156107a25760025460405163a9059cbb60e01b81526001600160a01b039091169063a9059cbb9061075d9087908590600401610f67565b6020604051808303816000875af115801561077c573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906107a09190610f16565b505b82516000906107b2908490610f4e565b1115610840576002546001600160a01b031663a9059cbb6107d16108e4565b85516107de908690610f4e565b6040518363ffffffff1660e01b81526004016107fb929190610f67565b6020604051808303816000875af115801561081a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061083e9190610f16565b505b50505061084c60018055565b50565b610857610cf9565b61085f610d7b565b565b600054600160a01b900460ff1690565b610879610cf9565b61085f6000610dca565b61088b610cf9565b61085f610e1a565b61089b610cf9565b6001600160a01b038116600081815260046020526040808220805460ff19169055517f1ecef1b5180dc14b16608c5c5ec1fa28998e2f94e460b91c1b50bdfb643cc1389190a250565b6000546001600160a01b031690565b6108fb610cf9565b6001600160a01b0381166109435760405162461bcd60e51b815260206004820152600f60248201526e496e76616c6964206164647265737360881b60448201526064016102b8565b6001600160a01b038116600081815260046020526040808220805460ff19166001179055517f07e751107375f503d05dfa76b5038ce1c5b7d46e9e45768f913ac333780394399190a250565b610997610d2b565b61099f610d51565b33600081815260036020818152604092839020835160c081018552815481526001820154928101929092526002810154938201939093529082015460608201526004820154608082015260059091015460ff1615801560a0830152610a415760405162461bcd60e51b815260206004820152601860248201527715995cdd1a5b99c81cd8da19591d5b19481c995d9bdad95960421b60448201526064016102b8565b6000610a4c83610bbd565b90506000826080015182610a609190610f4e565b905080600003610aa75760405162461bcd60e51b81526020600482015260126024820152714e6f20746f6b656e7320746f20636c61696d60701b60448201526064016102b8565b6001600160a01b03841660008181526003602052604090819020600401849055517f896e034966eaaf1adc54acc0f257056febbd300c9e47182cf761982cf1f5e43090610af79084815260200190565b60405180910390a260025460405163a9059cbb60e01b81526001600160a01b039091169063a9059cbb90610b319087908590600401610f67565b6020604051808303816000875af1158015610b50573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610b749190610f16565b505050505061085f60018055565b610b8a610cf9565b6001600160a01b038116610bb4576000604051631e4fbdf760e01b81526004016102b89190610f02565b61084c81610dca565b6001600160a01b0381166000908152600360208181526040808420815160c08101835281548082526001830154948201949094526002820154928101929092529283015460608201526004830154608082015260059092015460ff16151560a0830152610c695760405162461bcd60e51b815260206004820152601a60248201527915995cdd1a5b99c81cd8da19591d5b19481b9bdd08199bdd5b9960321b60448201526064016102b8565b604081015160208201514291600091610c829190610f80565b9050808211610c9657506000949350505050565b82606001518360200151610caa9190610f80565b8210610cb95750505192915050565b6000836020015183610ccb9190610f4e565b905060008460600151828660000151610ce49190610f93565b610cee9190610faa565b979650505050505050565b33610d026108e4565b6001600160a01b03161461085f573360405163118cdaa760e01b81526004016102b89190610f02565b610d33610861565b1561085f5760405163d93c066560e01b815260040160405180910390fd5b600260015403610d7457604051633ee5aeb560e01b815260040160405180910390fd5b6002600155565b610d83610e5d565b6000805460ff60a01b191690557f5db9ee0a495bf2e6ff9c91a7834c1ba4fdd244a5e8aa4e537bd38aeae4b073aa335b604051610dc09190610f02565b60405180910390a1565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b610e22610d2b565b6000805460ff60a01b1916600160a01b1790557f62e78cea01bee320cd4e420270b5ea74000d11b0c9f74754ebdbfc544b05a258610db33390565b610e65610861565b61085f57604051638dfc202b60e01b815260040160405180910390fd5b80356001600160a01b0381168114610e9957600080fd5b919050565b600080600080600060a08688031215610eb657600080fd5b610ebf86610e82565b97602087013597506040870135966060810135965060800135945092505050565b600060208284031215610ef257600080fd5b610efb82610e82565b9392505050565b6001600160a01b0391909116815260200190565b600060208284031215610f2857600080fd5b81518015158114610efb57600080fd5b634e487b7160e01b600052601160045260246000fd5b81810381811115610f6157610f61610f38565b92915050565b6001600160a01b03929092168252602082015260400190565b80820180821115610f6157610f61610f38565b8082028115828204841417610f6157610f61610f38565b600082610fc757634e487b7160e01b600052601260045260246000fd5b50049056fea2646970667358221220c959251d2b14022f03b3227478d3f07bbba6712558cbf46497703f39879c40a464736f6c63430008140033",
+ "linkReferences": {},
+ "deployedLinkReferences": {}
+}
diff --git a/challenge-3-frontend/app/page.tsx b/challenge-3-frontend/app/page.tsx
index fb01185..b438f32 100644
--- a/challenge-3-frontend/app/page.tsx
+++ b/challenge-3-frontend/app/page.tsx
@@ -26,6 +26,9 @@ export default function Home() {
Mint/Redeem LST Bifrost
+
+ Token vesting portal
+
diff --git a/challenge-3-frontend/app/providers.tsx b/challenge-3-frontend/app/providers.tsx
index 91243fe..f375659 100644
--- a/challenge-3-frontend/app/providers.tsx
+++ b/challenge-3-frontend/app/providers.tsx
@@ -1,94 +1,68 @@
-'use client';
+"use client";
-import * as React from 'react';
+import * as React from "react";
import {
RainbowKitProvider,
getDefaultWallets,
getDefaultConfig,
-} from '@rainbow-me/rainbowkit';
+} from "@rainbow-me/rainbowkit";
import {
phantomWallet,
trustWallet,
ledgerWallet,
-} from '@rainbow-me/rainbowkit/wallets';
-import {
- manta,
- moonbaseAlpha,
- moonbeam
-} from 'wagmi/chains';
-import { defineChain } from 'viem';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { WagmiProvider, http, createConfig } from 'wagmi';
-import { Provider as JotaiProvider } from 'jotai';
-// import according to docs
+} from "@rainbow-me/rainbowkit/wallets";
+import { sepolia, manta, moonbaseAlpha, moonbeam } from "wagmi/chains";
+import { defineChain } from "viem";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { WagmiProvider, http } from "wagmi";
+import { Provider as JotaiProvider } from "jotai";
export const westendAssetHub = defineChain({
id: 420420421,
name: "Westend AssetHub",
nativeCurrency: {
decimals: 18,
- name: 'Westend',
- symbol: 'WND',
+ name: "Westend",
+ symbol: "WND",
},
rpcUrls: {
default: {
- http: ['https://westend-asset-hub-eth-rpc.polkadot.io'],
- webSocket: ['wss://westend-asset-hub-eth-rpc.polkadot.io'],
+ http: ["https://westend-asset-hub-eth-rpc.polkadot.io"],
+ webSocket: ["wss://westend-asset-hub-eth-rpc.polkadot.io"],
},
},
blockExplorers: {
- default: { name: 'Explorer', url: 'https://assethub-westend.subscan.io' },
+ default: { name: "Explorer", url: "https://assethub-westend.subscan.io" },
},
contracts: {
multicall3: {
- address: '0x5545dec97cb957e83d3e6a1e82fabfacf9764cf1',
+ address: "0x5545dec97cb957e83d3e6a1e82fabfacf9764cf1",
blockCreated: 10174702,
},
},
-})
-
-export const localConfig = createConfig({
- chains: [
- westendAssetHub,
- manta,
- moonbaseAlpha,
- moonbeam,
- ],
- transports: {
- [westendAssetHub.id]: http(),
- [manta.id]: http(),
- [moonbaseAlpha.id]: http(),
- [moonbeam.id]: http(),
- },
- ssr: true,
});
const { wallets } = getDefaultWallets();
-// initialize and destructure wallets object
-const config = getDefaultConfig({
- appName: "DOTUI", // Name your app
- projectId: "ddf8cf3ee0013535c3760d4c79c9c8b9", // Enter your WalletConnect Project ID here
+const localConfig = getDefaultConfig({
+ appName: "pkVester", // Name your app
+ projectId: "ed53c978c176ff8e0e1c463760d1bd75", // Enter your WalletConnect Project ID here
wallets: [
...wallets,
{
- groupName: 'Other',
+ groupName: "Other",
wallets: [phantomWallet, trustWallet, ledgerWallet],
},
],
- chains: [
- westendAssetHub,
- moonbeam,
- moonbaseAlpha,
- manta
- ],
+ chains: [westendAssetHub, moonbeam, moonbaseAlpha, manta, sepolia],
transports: {
[westendAssetHub.id]: http(),
[moonbeam.id]: http(),
[moonbaseAlpha.id]: http(),
[manta.id]: http(),
+ [sepolia.id]: http(),
},
- ssr: true, // Because it is Nextjs's App router, you need to declare ssr as true
+ ssr: true,
});
const queryClient = new QueryClient();
@@ -96,11 +70,9 @@ const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
-
+
-
- {children}
-
+ {children}
diff --git a/challenge-3-frontend/app/token-vesting/page.tsx b/challenge-3-frontend/app/token-vesting/page.tsx
new file mode 100644
index 0000000..c653eb2
--- /dev/null
+++ b/challenge-3-frontend/app/token-vesting/page.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import Navbar from "@/components/navbar";
+import { Separator } from "@/components/ui/separator";
+import { useAccount, useReadContract } from "wagmi";
+import AdminPanel from "@/components/token-vesting/admin-panel";
+import BeneficiaryPanel from "@/components/token-vesting/beneficiary-panel";
+import { Address } from "viem";
+import { useToast } from "@/hooks/use-toast";
+import { Toaster } from "@/components/ui/toaster";
+import { Button } from "@/components/ui/button";
+import { abi as TokenVestingAbi } from "@/abis/TokenVesting.json";
+// import { westendAssetHub } from "@/app/providers";
+import { sepolia, moonbaseAlpha } from "wagmi/chains";
+
+// Simulated contract address - in production this would come from environment variables or a config
+const CONTRACT_ADDRESS =
+ "0xc5BF7a8634721D1366396707E24533C6ac786Fae" as Address;
+const TOKEN_ADDRESS = "0xd5954beef69b90978ec667b1fcf696d102dcde97" as Address;
+
+export default function TokenVestingPage() {
+ const { address, isConnected } = useAccount();
+ const [isAdmin, setIsAdmin] = useState(false);
+ const { toast } = useToast();
+ const { data: owner, error: ownerError } = useReadContract({
+ address: CONTRACT_ADDRESS,
+ abi: TokenVestingAbi,
+ functionName: "owner",
+ chainId: sepolia.id,
+ });
+
+ useEffect(() => {
+ if (!address) return;
+ if (ownerError) {
+ console.error("Error fetching contract owner", ownerError);
+ return;
+ }
+ console.log("Owner: ", owner);
+ setIsAdmin(owner === address);
+ }, [owner, address]);
+
+ // In a real application, you'd check if the connected address is the contract owner
+ // For now, we'll have a toggle to simulate admin/beneficiary view
+ const toggleRole = () => {
+ setIsAdmin(!isAdmin);
+ toast({
+ title: `Switched to ${!isAdmin ? "Admin" : "Beneficiary"} view`,
+ description: "This is for demonstration purposes only",
+ });
+ };
+
+ return (
+
+
+
+
+
+ Token Vesting Portal
+
+ {/* */}
+
+
+ {!isConnected ? (
+
+
+ Connect your wallet to continue
+
+
+ You need to connect your wallet to interact with the vesting
+ contract
+
+
+ ) : (
+
+
+
+ {isAdmin ? "Administrator Panel" : "Beneficiary Dashboard"}
+
+
+ Switch to {isAdmin ? "Beneficiary" : "Admin"} View
+
+
+
+
+
+ Overview
+ {isAdmin && (
+ Create Schedule
+ )}
+ {isAdmin && (
+ Manage Beneficiaries
+ )}
+ {!isAdmin && (
+ Claim Tokens
+ )}
+
+
+
+ {isAdmin ? (
+
+ ) : (
+
+ )}
+
+
+ {isAdmin && (
+
+
+
+ )}
+
+ {isAdmin && (
+
+
+
+ )}
+
+ {!isAdmin && (
+
+
+
+ )}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/challenge-3-frontend/app/wallet/page.tsx b/challenge-3-frontend/app/wallet/page.tsx
index b77996d..ae32f98 100644
--- a/challenge-3-frontend/app/wallet/page.tsx
+++ b/challenge-3-frontend/app/wallet/page.tsx
@@ -1,18 +1,15 @@
"use client";
import SigpassKit from "@/components/sigpasskit";
-import Link from "next/link";
+import Navbar from "@/components/navbar";
export default function WalletPage() {
return (
-
-
- Home
- Wallet
- Send transaction
- Write contract
-
-
Wallet
-
+
);
}
\ No newline at end of file
diff --git a/challenge-3-frontend/components/navbar.tsx b/challenge-3-frontend/components/navbar.tsx
index d212169..d96ace8 100644
--- a/challenge-3-frontend/components/navbar.tsx
+++ b/challenge-3-frontend/components/navbar.tsx
@@ -1,32 +1,137 @@
+"use client";
+
import Link from "next/link";
+import { useAccount, useConnect, useDisconnect } from "wagmi";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+// import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { useEffect, useState } from "react";
+import { ConnectButton } from "@rainbow-me/rainbowkit";
+import { Wallet } from "lucide-react";
+import PortfolioCard from "./portfolio-card";
export default function Navbar() {
+ const { address, isConnected } = useAccount();
+ const { disconnect } = useDisconnect();
+ const [mounted, setMounted] = useState(false);
+
+ // This effect is to prevent hydration mismatch
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // Format address for display
+ const formatAddress = (addr: string) => {
+ if (!addr) return "";
+ return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
+ };
+
+ // Get initials for avatar
+ const getInitials = (addr: string) => {
+ if (!addr) return "";
+ return addr.slice(0, 2);
+ };
+
return (
-
-
- Home
-
-
- Wallet
-
-
- Send transaction
-
-
- Write contract
-
-
- Mint/Redeem LST Bifrost
-
+
+
+
+ Home
+
+
+ Wallet
+
+
+ Send Transaction
+
+
+ Write Contract
+
+
+ Token Vesting
+
+
+ Mint/Redeem LST
+
+
+
+ {mounted && (
+
+ {isConnected ? (
+
+
+
+
+ {getInitials(address || "")}
+
+
+ {formatAddress(address || "")}
+
+
+
+
+ My Account
+
+ {
+ navigator.clipboard.writeText(address || "");
+ }}
+ >
+ Copy Address
+
+ disconnect()}
+ >
+ Disconnect
+
+
+
+ ) : (
+
+ {({
+ account,
+ chain,
+ openAccountModal,
+ openChainModal,
+ openConnectModal,
+ mounted: rainbowMounted,
+ }) => (
+
+
+ Connect Wallet
+
+ )}
+
+ )}
+
+ )}
);
}
diff --git a/challenge-3-frontend/components/token-vesting/admin-panel.tsx b/challenge-3-frontend/components/token-vesting/admin-panel.tsx
new file mode 100644
index 0000000..5672e99
--- /dev/null
+++ b/challenge-3-frontend/components/token-vesting/admin-panel.tsx
@@ -0,0 +1,840 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Address, parseUnits, formatUnits } from "viem";
+import {
+ useAccount,
+ useWriteContract,
+ useWaitForTransactionReceipt,
+} from "wagmi";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useToast } from "@/hooks/use-toast";
+import { abi as TokenVestingAbi } from "@/abis/TokenVesting.json";
+import { abi as TokenAbi } from "@/abis/Token.json";
+import {
+ BeneficiaryInfo,
+ VestingFormData,
+ WhitelistFormData,
+} from "@/lib/types";
+import {
+ Table,
+ TableHeader,
+ TableBody,
+ TableHead,
+ TableRow,
+ TableCell,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { formatDate } from "@/lib/utils";
+import { Separator } from "@/components/ui/separator";
+import { reverse } from "dns";
+
+interface AdminPanelProps {
+ contractAddress: Address;
+ tokenAddress: Address;
+ view: "overview" | "create" | "manage";
+}
+
+export default function AdminPanel({
+ contractAddress,
+ tokenAddress,
+ view,
+}: AdminPanelProps) {
+ const { address } = useAccount();
+ const { toast } = useToast();
+
+ // State for vesting form
+ const [vestingForm, setVestingForm] = useState
({
+ beneficiary: "",
+ amount: "",
+ cliffDuration: "",
+ vestDuration: "",
+ startTimestamp: "",
+ });
+
+ // State for whitelist form
+ const [whitelistForm, setWhitelistForm] = useState({
+ beneficiary: "",
+ });
+
+ // State for beneficiaries
+ const [beneficiaries, setBeneficiaries] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [pendingRemoval, setPendingRemoval] = useState(null);
+ const [pendingRevoke, setPendingRevoke] = useState(null);
+
+ // Contract write operations
+ const { data: approveHash, writeContract: approveToken } = useWriteContract();
+ const { data: createHash, writeContract: createSchedule } =
+ useWriteContract();
+ const { data: whitelistHash, writeContract: addToWhitelist } =
+ useWriteContract();
+ const { data: removeHash, writeContract: removeFromWhitelist } =
+ useWriteContract();
+ const { data: revokeHash, writeContract: revokeVesting } = useWriteContract();
+
+ // Handle transaction receipts
+ const { isLoading: isApproveLoading, status: approveStatus } =
+ useWaitForTransactionReceipt({
+ hash: approveHash,
+ });
+
+ useEffect(() => {
+ if (isApproveLoading) {
+ return;
+ }
+ if (approveStatus === "success") {
+ toast({
+ title: "Approval successful",
+ description: "You can now create the vesting schedule",
+ });
+ handleCreateSchedule();
+ }
+ }, [approveStatus]);
+
+ const { isLoading: isCreateLoading, status: createStatus } =
+ useWaitForTransactionReceipt({
+ hash: createHash,
+ });
+
+ useEffect(() => {
+ if (isCreateLoading) {
+ return;
+ }
+ if (createStatus === "success") {
+ toast({
+ title: "Success!",
+ description: "Vesting schedule created successfully",
+ });
+
+ // Add/update the beneficiary with vesting schedule in localStorage
+ const currentTime = Math.floor(Date.now() / 1000);
+ const startTime = vestingForm.startTimestamp
+ ? parseInt(vestingForm.startTimestamp)
+ : currentTime + 60;
+
+ const newBeneficiary: BeneficiaryInfo = {
+ address: vestingForm.beneficiary as Address,
+ isWhitelisted: true, // When creating a vesting schedule, beneficiary is automatically whitelisted
+ vestingSchedule: {
+ totalAmount: parseUnits(vestingForm.amount, 18),
+ startTime: BigInt(startTime),
+ cliffDuration: BigInt(
+ parseInt(vestingForm.cliffDuration) * 24 * 60 * 60
+ ),
+ vestDuration: BigInt(
+ parseInt(vestingForm.vestDuration) * 24 * 60 * 60
+ ),
+ amountClaimed: BigInt(0),
+ revoked: false,
+ },
+ vestedAmount: BigInt(0),
+ claimableAmount: BigInt(0),
+ };
+
+ // Update or add the beneficiary
+ const updatedBeneficiaries = [...beneficiaries];
+ const existingIndex = updatedBeneficiaries.findIndex(
+ (b) => b.address === newBeneficiary.address
+ );
+
+ if (existingIndex >= 0) {
+ updatedBeneficiaries[existingIndex] = {
+ ...updatedBeneficiaries[existingIndex],
+ ...newBeneficiary,
+ };
+ } else {
+ updatedBeneficiaries.push(newBeneficiary);
+ }
+
+ // Persist to localStorage
+ try {
+ localStorage.setItem(
+ "vestingBeneficiaries",
+ JSON.stringify(
+ updatedBeneficiaries,
+ (key, value) => {
+ // Convert bigint to string
+ return typeof value === "bigint" ? String(value) : value;
+ },
+ 2
+ )
+ );
+ console.log("Updated vesting schedules stored in localStorage");
+ } catch (err) {
+ console.error(
+ "Failed to store vesting schedules in localStorage:",
+ err
+ );
+ }
+
+ resetVestingForm();
+ fetchBeneficiaries(); // Refresh the beneficiaries list
+ } else if (createStatus === "error") {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to create vesting schedule",
+ });
+ }
+ }, [createStatus]);
+
+ const { isLoading: isWhitelistLoading, status: whitelistStatus } =
+ useWaitForTransactionReceipt({
+ hash: whitelistHash,
+ });
+
+ useEffect(() => {
+ if (isWhitelistLoading) {
+ return;
+ }
+ if (whitelistStatus === "success") {
+ toast({
+ title: "Success!",
+ description: "Beneficiary added to whitelist",
+ });
+
+ // Create new beneficiary object
+ const newBeneficiary: BeneficiaryInfo = {
+ address: whitelistForm.beneficiary as Address,
+ isWhitelisted: true,
+ };
+
+ // Check if the beneficiary already exists in the array
+ const updatedBeneficiaries = [...beneficiaries];
+ const existingIndex = updatedBeneficiaries.findIndex(
+ (b) => b.address === newBeneficiary.address
+ );
+
+ // If exists, update it, otherwise add it
+ if (existingIndex >= 0) {
+ updatedBeneficiaries[existingIndex] = {
+ ...updatedBeneficiaries[existingIndex],
+ isWhitelisted: true,
+ };
+ } else {
+ updatedBeneficiaries.push(newBeneficiary);
+ }
+
+ // Persist to localStorage for later retrieval
+ try {
+ localStorage.setItem(
+ "vestingBeneficiaries",
+ JSON.stringify(
+ updatedBeneficiaries,
+ (key, value) => {
+ // Convert bigint to string
+ return typeof value === "bigint" ? String(value) : value;
+ },
+ 2
+ )
+ );
+ console.log("Beneficiaries stored in localStorage");
+ } catch (err) {
+ console.error("Failed to store beneficiaries in localStorage:", err);
+ }
+
+ // Clear whitelist input form
+ setWhitelistForm((prev) => ({ ...prev, beneficiary: "" }));
+ } else if (whitelistStatus === "error") {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to add to whitelist",
+ });
+ }
+ fetchBeneficiaries(); // Refresh the beneficiaries list
+ }, [whitelistStatus]);
+
+ const { isLoading: isRemoveLoading, status: removeStatus } =
+ useWaitForTransactionReceipt({
+ hash: removeHash,
+ });
+
+ useEffect(() => {
+ if (isRemoveLoading) {
+ return;
+ }
+ if (removeStatus === "success") {
+ // Changed from revokeStatus to removeStatus
+ toast({
+ title: "Success!",
+ description: "Beneficiary removed from whitelist",
+ });
+
+ // Update localStorage to mark the beneficiary as not whitelisted
+ const updatedBeneficiaries = beneficiaries.map((ben) => {
+ if (ben.address === pendingRemoval) {
+ return {
+ ...ben,
+ isWhitelisted: false,
+ };
+ }
+ return ben;
+ });
+
+ // Persist to localStorage
+ try {
+ localStorage.setItem(
+ "vestingBeneficiaries",
+ JSON.stringify(
+ updatedBeneficiaries,
+ (key, value) => {
+ // Convert bigint to string
+ return typeof value === "bigint" ? String(value) : value;
+ },
+ 2
+ )
+ );
+ console.log("Updated whitelist stored in localStorage");
+ } catch (err) {
+ console.error("Failed to store whitelist in localStorage:", err);
+ }
+
+ fetchBeneficiaries(); // Refresh the beneficiaries list
+ } else if (removeStatus === "error") {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to remove from whitelist",
+ });
+ }
+ }, [removeStatus]);
+
+ const { isLoading: isRevokeLoading, status: revokeStatus } =
+ useWaitForTransactionReceipt({
+ hash: revokeHash,
+ });
+
+ useEffect(() => {
+ if (isRevokeLoading) {
+ return;
+ }
+ if (revokeStatus === "success") {
+ toast({
+ title: "Success!",
+ description: "Vesting schedule revoked",
+ });
+
+ // Update localStorage to mark the schedule as revoked
+ const updatedBeneficiaries = beneficiaries.map((ben) => {
+ if (ben.address === pendingRevoke) {
+ return {
+ ...ben,
+ vestingSchedule: ben.vestingSchedule
+ ? {
+ ...ben.vestingSchedule,
+ revoked: true,
+ }
+ : undefined,
+ };
+ }
+ return ben;
+ });
+
+ // Persist to localStorage
+ try {
+ localStorage.setItem(
+ "vestingBeneficiaries",
+ JSON.stringify(
+ updatedBeneficiaries,
+ (key, value) => {
+ // Convert bigint to string
+ return typeof value === "bigint" ? String(value) : value;
+ },
+ 2
+ )
+ );
+ console.log("Updated vesting schedules stored in localStorage");
+ } catch (err) {
+ console.error(
+ "Failed to store vesting schedules in localStorage:",
+ err
+ );
+ }
+
+ fetchBeneficiaries(); // Refresh the beneficiaries list
+ }
+ }, [revokeStatus]);
+
+ // Helper function to fetch beneficiary data
+ const fetchBeneficiaries = async () => {
+ // In a real implementation, you would query events or use a subgraph
+ // For this demo, we'll just use a mock
+ let mockBeneficiaries: BeneficiaryInfo[] = [];
+
+ try {
+ const storedData = localStorage.getItem("vestingBeneficiaries");
+ if (storedData && storedData !== "") {
+ mockBeneficiaries = JSON.parse(storedData, (key, value) => {
+ // Convert string to bigint
+ return key === "totalAmount" ||
+ key === "startTime" ||
+ key === "cliffDuration" ||
+ key === "vestDuration" ||
+ key === "amountClaimed" ||
+ key === "vestedAmount" ||
+ key === "claimableAmount"
+ ? BigInt(value)
+ : value;
+ });
+ }
+ } catch (error) {
+ console.error("Error parsing localStorage data:", error);
+ }
+
+ if (mockBeneficiaries.length === 0) {
+ mockBeneficiaries = [
+ {
+ address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as Address,
+ isWhitelisted: true,
+ vestingSchedule: {
+ totalAmount: parseUnits("10000", 18),
+ startTime: BigInt(Math.floor(Date.now() / 100)),
+ cliffDuration: BigInt(30 * 24 * 60 * 60), // 30 days
+ vestDuration: BigInt(365 * 24 * 60 * 60), // 1 year
+ amountClaimed: BigInt(0),
+ revoked: false,
+ },
+ vestedAmount: parseUnits("2000", 18),
+ claimableAmount: parseUnits("2000", 18),
+ },
+ {
+ address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" as Address,
+ isWhitelisted: true,
+ vestingSchedule: undefined,
+ },
+ {
+ address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906" as Address,
+ isWhitelisted: false,
+ },
+ ];
+ }
+
+ setBeneficiaries(mockBeneficiaries);
+ };
+
+ useEffect(() => {
+ fetchBeneficiaries();
+ }, []);
+
+ const handleInputChange = (
+ e: React.ChangeEvent,
+ formType: "vesting" | "whitelist"
+ ) => {
+ const { name, value } = e.target;
+ if (formType === "vesting") {
+ setVestingForm((prev) => ({ ...prev, [name]: value }));
+ } else {
+ setWhitelistForm((prev) => ({ ...prev, [name]: value }));
+ }
+ };
+
+ const resetVestingForm = () => {
+ setVestingForm({
+ beneficiary: "",
+ amount: "",
+ cliffDuration: "",
+ vestDuration: "",
+ startTimestamp: "",
+ });
+ };
+
+ const handleSubmitVesting = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ try {
+ // In a real implementation, validate the form first
+
+ // First approve tokens for the vesting contract
+ approveToken({
+ address: tokenAddress,
+ abi: TokenAbi,
+ functionName: "approve",
+ args: [
+ contractAddress,
+ parseUnits(vestingForm.amount, 18), // Assuming token has 18 decimals
+ ],
+ });
+ } catch (error) {
+ console.error("Error creating vesting schedule:", error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to create vesting schedule",
+ });
+ }
+ };
+
+ const handleCreateSchedule = () => {
+ const currentTime = Math.floor(Date.now() / 1000);
+ const startTime = vestingForm.startTimestamp
+ ? parseInt(vestingForm.startTimestamp)
+ : currentTime + 60; // Default to 1 minute from now
+
+ createSchedule({
+ address: contractAddress,
+ abi: TokenVestingAbi,
+ functionName: "createVestingSchedule",
+ args: [
+ vestingForm.beneficiary as Address,
+ parseUnits(vestingForm.amount, 18), // Assuming token has 18 decimals
+ BigInt(parseInt(vestingForm.cliffDuration) * 24 * 60 * 60), // Convert days to seconds
+ BigInt(parseInt(vestingForm.vestDuration) * 24 * 60 * 60), // Convert days to seconds
+ BigInt(startTime),
+ ],
+ });
+ };
+
+ const handleWhitelistSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ try {
+ addToWhitelist({
+ address: contractAddress,
+ abi: TokenVestingAbi,
+ functionName: "addToWhitelist",
+ args: [whitelistForm.beneficiary as Address],
+ });
+ } catch (error) {
+ console.error("Error adding to whitelist:", error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to add to whitelist",
+ });
+ }
+ };
+
+ const handleRemoveFromWhitelist = (beneficiaryAddress: Address) => {
+ try {
+ setPendingRemoval(beneficiaryAddress);
+ removeFromWhitelist({
+ address: contractAddress,
+ abi: TokenVestingAbi,
+ functionName: "removeFromWhitelist",
+ args: [beneficiaryAddress],
+ });
+ } catch (error) {
+ console.error("Error removing from whitelist:", error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to remove from whitelist",
+ });
+ setPendingRemoval(null);
+ }
+ };
+
+ const handleRevokeVesting = (beneficiaryAddress: Address) => {
+ try {
+ setPendingRevoke(beneficiaryAddress);
+ revokeVesting({
+ address: contractAddress,
+ abi: TokenVestingAbi,
+ functionName: "revokeVesting",
+ args: [beneficiaryAddress],
+ });
+ } catch (error) {
+ console.error("Error revoking vesting:", error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to revoke vesting",
+ });
+ setPendingRevoke(null);
+ }
+ };
+ return (
+
+ {view === "overview" && (
+
+
+ Vesting Schedule Overview
+ View all active vesting schedules
+
+
+
+
+
+ Beneficiary
+ Whitelist Status
+ Total Amount
+ Vested Amount
+ Status
+ Actions
+
+
+
+ {beneficiaries.map((beneficiary) => (
+
+
+ {beneficiary.address}
+
+
+ {beneficiary.isWhitelisted ? (
+ Whitelisted
+ ) : (
+ Not Whitelisted
+ )}
+
+
+ {beneficiary.vestingSchedule
+ ? formatUnits(
+ beneficiary.vestingSchedule.totalAmount,
+ 18
+ )
+ : "-"}
+
+
+ {beneficiary.vestedAmount
+ ? formatUnits(beneficiary.vestedAmount, 18)
+ : "-"}
+
+
+ {beneficiary.vestingSchedule?.revoked ? (
+ Revoked
+ ) : beneficiary.vestingSchedule ? (
+ Active
+ ) : (
+ No Schedule
+ )}
+
+
+
+ {beneficiary.isWhitelisted ? (
+
+ handleRemoveFromWhitelist(beneficiary.address)
+ }
+ disabled={isRemoveLoading}
+ >
+ Remove
+
+ ) : (
+ {
+ setWhitelistForm({
+ beneficiary: beneficiary.address,
+ });
+ handleWhitelistSubmit(new Event("click") as any);
+ }}
+ disabled={isWhitelistLoading}
+ >
+ Whitelist
+
+ )}
+
+ {beneficiary.vestingSchedule &&
+ !beneficiary.vestingSchedule.revoked && (
+
+ handleRevokeVesting(beneficiary.address)
+ }
+ disabled={isRevokeLoading}
+ >
+ Revoke
+
+ )}
+
+
+
+ ))}
+
+ {beneficiaries.length === 0 && (
+
+
+ No beneficiaries found
+
+
+ )}
+
+
+
+
+ )}
+
+ {view === "create" && (
+
+
+ Create Vesting Schedule
+
+ Set up a new token vesting schedule for a beneficiary
+
+
+
+
+
+
+ )}
+
+ {view === "manage" && (
+
+
+ Manage Beneficiaries
+
+ Add or remove beneficiaries from the whitelist
+
+
+
+
+
+
+
+
+
Current Whitelist
+
+ {beneficiaries
+ .filter((b) => b.isWhitelisted)
+ .map((beneficiary) => (
+
+
+ {beneficiary.address}
+
+
+ handleRemoveFromWhitelist(beneficiary.address)
+ }
+ disabled={isRemoveLoading}
+ >
+ Remove
+
+
+ ))}
+
+ {beneficiaries.filter((b) => b.isWhitelisted).length === 0 && (
+
+ No whitelisted beneficiaries
+
+ )}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/challenge-3-frontend/components/token-vesting/beneficiary-panel.tsx b/challenge-3-frontend/components/token-vesting/beneficiary-panel.tsx
new file mode 100644
index 0000000..ba93613
--- /dev/null
+++ b/challenge-3-frontend/components/token-vesting/beneficiary-panel.tsx
@@ -0,0 +1,571 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { Address, formatUnits } from "viem";
+import {
+ useReadContract,
+ useWriteContract,
+ useWaitForTransactionReceipt,
+} from "wagmi";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Progress } from "@/components/ui/progress";
+import { useToast } from "@/hooks/use-toast";
+import { abi as TokenVestingAbi } from "@/abis/TokenVesting.json";
+import { abi as TokenAbi } from "@/abis/Token.json";
+import { VestingSchedule } from "@/lib/types";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Badge } from "@/components/ui/badge";
+import { InfoCircledIcon } from "@radix-ui/react-icons";
+// import { westendAssetHub } from "@/app/providers";
+import { moonbaseAlpha, sepolia } from "wagmi/chains";
+
+interface BeneficiaryPanelProps {
+ contractAddress: Address;
+ address: Address;
+ view: "overview" | "claim";
+}
+
+export default function BeneficiaryPanel({
+ contractAddress,
+ address,
+ view,
+}: BeneficiaryPanelProps) {
+ const { toast } = useToast();
+ const [isWhitelisted, setIsWhitelisted] = useState(null);
+ const [vestingSchedule, setVestingSchedule] =
+ useState(null);
+ const [vestedAmount, setVestedAmount] = useState(null);
+ const [claimableAmount, setClaimableAmount] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [refetchNow, setRefetchNow] = useState(false);
+
+ // For scrolling to claim card
+ const claimCardRef = useRef(null);
+
+ // Contract write operation
+ const { data: claimHash, writeContract: claimTokens } = useWriteContract();
+
+ // Handle transaction receipt
+ const { isLoading: isClaimLoading, status: claimStatus } =
+ useWaitForTransactionReceipt({
+ hash: claimHash,
+ });
+
+ useEffect(() => {
+ if (isClaimLoading) {
+ return;
+ }
+ if (claimStatus === "success") {
+ toast({
+ title: "Success!",
+ description: "Tokens claimed successfully",
+ });
+ setRefetchNow(true);
+ fetchBeneficiaryData(); // Refresh data after claiming
+
+ return () => {
+ setRefetchNow(false);
+ };
+ }
+ }, [claimStatus]);
+
+ // Fetch vesting schedule with better error handling and logging
+ const REFETCH_INTERVAL = 1000; // Miliseconds
+ const {
+ data: schedule,
+ error: scheduleError,
+ isLoading: scheduleLoading,
+ } = useReadContract({
+ address: contractAddress,
+ abi: TokenVestingAbi,
+ functionName: "vestingSchedules",
+ args: [address],
+ chainId: sepolia.id,
+ query: {
+ refetchInterval(query) {
+ return refetchNow ? 0 : REFETCH_INTERVAL;
+ },
+ },
+ });
+
+ // Log both successful data and errors
+ useEffect(() => {
+ if (scheduleError) {
+ console.error("Schedule fetch error:", scheduleError);
+ }
+ if (schedule) {
+ console.log("Raw schedule data received:", schedule);
+ // Log structure to understand the format
+ console.log("Schedule data type:", typeof schedule);
+ if (typeof schedule === "object") {
+ console.log("Schedule keys:", Object.keys(schedule));
+ }
+ }
+ }, [schedule, scheduleError]);
+
+ // Similar for the whitelist check
+ const { data: whitelisted, error: whitelistError } = useReadContract({
+ address: contractAddress,
+ abi: TokenVestingAbi,
+ functionName: "whitelist",
+ args: [address],
+ chainId: sepolia.id,
+ query: {
+ refetchInterval(query) {
+ return refetchNow ? 0 : REFETCH_INTERVAL;
+ },
+ },
+ });
+
+ // Log whitelist errors
+ useEffect(() => {
+ if (whitelistError) {
+ console.error("Whitelist fetch error:", whitelistError);
+ }
+ }, [whitelistError]);
+
+ // Fetch vested amount
+ const { data: vested } = useReadContract({
+ address: contractAddress,
+ abi: TokenVestingAbi,
+ functionName: "calculateVestedAmount",
+ args: [address],
+ chainId: sepolia.id,
+ query: {
+ refetchInterval(query) {
+ return refetchNow ? 0 : REFETCH_INTERVAL;
+ },
+ },
+ });
+
+ // Helper function to fetch all beneficiary data
+ const fetchBeneficiaryData = async () => {
+ setIsLoading(true);
+
+ try {
+ // Set whitelist status
+ setIsWhitelisted(
+ whitelisted !== undefined ? (whitelisted as boolean) : false
+ );
+
+ // Set vesting schedule if there's one, handling both array and object formats
+ console.log("Processing schedule data:", schedule);
+
+ if (schedule) {
+ let scheduleData;
+
+ // Handle if schedule is returned as an array (tuple)
+ if (Array.isArray(schedule)) {
+ console.log("Schedule is an array with length:", schedule.length);
+ // Map the tuple to our expected object structure
+ scheduleData = {
+ totalAmount: schedule[0],
+ startTime: schedule[1],
+ cliffDuration: schedule[2],
+ vestDuration: schedule[3],
+ amountClaimed: schedule[4],
+ revoked: schedule[5],
+ };
+ } else {
+ scheduleData = schedule as any;
+ }
+
+ // Check if we have a valid schedule with a non-zero amount
+ if (
+ scheduleData &&
+ scheduleData.totalAmount &&
+ scheduleData.totalAmount > 0
+ ) {
+ console.log("Valid schedule found:", scheduleData);
+
+ setVestingSchedule({
+ totalAmount: scheduleData.totalAmount,
+ startTime: scheduleData.startTime,
+ cliffDuration: scheduleData.cliffDuration,
+ vestDuration: scheduleData.vestDuration,
+ amountClaimed: scheduleData.amountClaimed,
+ revoked: scheduleData.revoked,
+ });
+
+ // Set vested amount if available
+ if (vested !== undefined) {
+ setVestedAmount(vested as bigint);
+ // Calculate claimable amount
+ const claimable = (vested as bigint) - scheduleData.amountClaimed;
+ setClaimableAmount(claimable);
+ } else {
+ // Calculate vested amount based on current time
+ const currentTime = BigInt(Math.floor(Date.now() / 1000));
+ const startTime = scheduleData.startTime;
+ const cliffTime = startTime + scheduleData.cliffDuration;
+ const endTime = startTime + scheduleData.vestDuration;
+
+ if (currentTime < cliffTime) {
+ // During cliff period, nothing is vested
+ setVestedAmount(BigInt(0));
+ setClaimableAmount(BigInt(0));
+ } else if (currentTime >= endTime) {
+ // After vesting period, everything is vested
+ setVestedAmount(scheduleData.totalAmount);
+ setClaimableAmount(
+ BigInt(scheduleData.totalAmount) -
+ BigInt(scheduleData.amountClaimed)
+ );
+ } else {
+ // Linear vesting during the vesting period
+ const timeFromCliff = currentTime - cliffTime;
+ const vestingPeriod = endTime - cliffTime;
+ // Convert all values to bigint to avoid type mismatch in division
+ const vestedAmt =
+ (scheduleData.totalAmount * timeFromCliff) /
+ BigInt(vestingPeriod);
+
+ setVestedAmount(vestedAmt);
+ setClaimableAmount(
+ BigInt(vestedAmt) - BigInt(scheduleData.amountClaimed)
+ );
+ }
+ }
+ } else {
+ console.log("Schedule exists but has zero amount or is invalid");
+ setVestingSchedule(null);
+ setVestedAmount(null);
+ setClaimableAmount(null);
+ }
+ } else {
+ console.info("No schedule data received from contract");
+ setVestingSchedule(null);
+ setVestedAmount(null);
+ setClaimableAmount(null);
+ }
+ } catch (error) {
+ console.error("Error in fetchBeneficiaryData:", error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to fetch vesting data",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchBeneficiaryData();
+ }, [address, whitelisted, schedule, vested]);
+
+ const handleClaimTokens = () => {
+ try {
+ claimTokens({
+ address: contractAddress,
+ abi: TokenVestingAbi,
+ functionName: "claimVestedTokens",
+ });
+ } catch (error) {
+ console.error("Error claiming tokens:", error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to claim tokens",
+ });
+ }
+ };
+
+ const formatTimestamp = (timestamp: bigint) => {
+ return new Date(Number(timestamp) * 1000).toLocaleString();
+ };
+
+ const calculateProgress = () => {
+ if (!vestingSchedule || !vestedAmount) return 0;
+ return Number((vestedAmount * BigInt(100)) / vestingSchedule.totalAmount);
+ };
+
+ const isCliffPeriod = () => {
+ if (!vestingSchedule) return false;
+ const currentTime = BigInt(Math.floor(Date.now() / 1000));
+ return (
+ currentTime < vestingSchedule.startTime + vestingSchedule.cliffDuration
+ );
+ };
+
+ const isVestingComplete = () => {
+ if (!vestingSchedule) return false;
+ const currentTime = BigInt(Math.floor(Date.now() / 1000));
+ return (
+ currentTime >= vestingSchedule.startTime + vestingSchedule.vestDuration
+ );
+ };
+
+ useEffect(() => {
+ if (!view) return;
+
+ console.log("view value changed to: ", view);
+ if (view === "claim") {
+ // Add a small delay to ensure the DOM is updated
+ setTimeout(() => {
+ console.log("Attempting to scroll to claim card...");
+ if (claimCardRef.current) {
+ claimCardRef.current.scrollIntoView({ behavior: "smooth" });
+ } else {
+ console.log("claimCardRef still not defined after delay");
+ }
+ }, 100);
+ }
+ }, [view]);
+
+ return (
+
+ {schedule as any}
+ {isLoading ? (
+
+
+
+
+
+
+
+
+
+
+ ) : !isWhitelisted ? (
+
+
+ Not Whitelisted
+
+ Your address has not been whitelisted by the administrator yet
+
+
+
+
+
+
+ You need to be whitelisted by the administrator to participate
+ in the token vesting program.
+
+
+
+
+ ) : !vestingSchedule ? (
+
+
+ No Vesting Schedule
+
+ You are whitelisted but don't have an active vesting schedule
+
+
+
+
+
+
+ Contact the administrator to create a vesting schedule for your
+ address.
+
+
+
+
+ ) : (
+
+
+
+
+ Your Vesting Schedule
+ {vestingSchedule.revoked && (
+ Revoked
+ )}
+ {isVestingComplete() && (
+ Completed
+ )}
+ {isCliffPeriod() && (
+ In Cliff Period
+ )}
+
+
+ Track the progress of your token vesting schedule
+
+
+
+
+
+
+ Progress
+ {calculateProgress()}%
+
+
+
+
+
+
+
+ Total Amount
+
+
+ {formatUnits(vestingSchedule.totalAmount, 18)} tokens
+
+
+
+
+
+ Vested Amount
+
+
+ {vestedAmount ? formatUnits(vestedAmount, 18) : "0"}{" "}
+ tokens
+
+
+
+
+
+ Claimed Amount
+
+
+ {formatUnits(vestingSchedule.amountClaimed, 18)} tokens
+
+
+
+
+
+ Claimable Now
+
+
+ {claimableAmount ? formatUnits(claimableAmount, 18) : "0"}{" "}
+ tokens
+
+
+
+
+
+
+
+
+ Start Date
+
+
+ {formatTimestamp(vestingSchedule.startTime)}
+
+
+
+
+
+ Cliff End Date
+
+
+ {formatTimestamp(
+ vestingSchedule.startTime +
+ vestingSchedule.cliffDuration
+ )}
+
+
+
+
+
+ Vesting End Date
+
+
+ {formatTimestamp(
+ vestingSchedule.startTime +
+ vestingSchedule.vestDuration
+ )}
+
+
+
+
+
+
+
+
+ {view === "claim" && (
+
+
+ Claim Tokens
+ Claim your vested tokens
+
+
+
+
+
+
+
+ Claimable Amount
+
+
+ {claimableAmount
+ ? formatUnits(claimableAmount, 18)
+ : "0"}{" "}
+ tokens
+
+
+
+ {vestingSchedule.revoked && (
+
+ Vesting Revoked
+
+ )}
+
+ {isCliffPeriod() && (
+
+ In Cliff Period
+
+ )}
+
+
+
+
+
+ {isClaimLoading ? "Claiming..." : "Claim Tokens"}
+
+
+ {(!claimableAmount || claimableAmount <= 0) &&
+ !isCliffPeriod() &&
+ !vestingSchedule.revoked && (
+
+ You don't have any tokens to claim at the moment.
+
+ )}
+
+ {isCliffPeriod() && (
+
+ You are still in the cliff period. Tokens will be
+ available to claim after{" "}
+ {formatTimestamp(
+ vestingSchedule.startTime +
+ vestingSchedule.cliffDuration
+ )}
+
+ )}
+
+ {vestingSchedule.revoked && (
+
+ Your vesting schedule has been revoked by the
+ administrator.
+
+ )}
+
+
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/challenge-3-frontend/components/ui/badge.tsx b/challenge-3-frontend/components/ui/badge.tsx
new file mode 100644
index 0000000..bb6f6a2
--- /dev/null
+++ b/challenge-3-frontend/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/challenge-3-frontend/components/ui/card.tsx b/challenge-3-frontend/components/ui/card.tsx
new file mode 100644
index 0000000..0e899e9
--- /dev/null
+++ b/challenge-3-frontend/components/ui/card.tsx
@@ -0,0 +1,85 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+};
diff --git a/challenge-3-frontend/components/ui/dropdown-menu.tsx b/challenge-3-frontend/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..f69a0d6
--- /dev/null
+++ b/challenge-3-frontend/components/ui/dropdown-menu.tsx
@@ -0,0 +1,200 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/challenge-3-frontend/components/ui/progress.tsx b/challenge-3-frontend/components/ui/progress.tsx
new file mode 100644
index 0000000..339efec
--- /dev/null
+++ b/challenge-3-frontend/components/ui/progress.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+
+import { cn } from "@/lib/utils"
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+))
+Progress.displayName = ProgressPrimitive.Root.displayName
+
+export { Progress }
\ No newline at end of file
diff --git a/challenge-3-frontend/components/ui/table.tsx b/challenge-3-frontend/components/ui/table.tsx
new file mode 100644
index 0000000..bb0650a
--- /dev/null
+++ b/challenge-3-frontend/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Table.displayName = "Table";
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableHeader.displayName = "TableHeader";
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableBody.displayName = "TableBody";
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+));
+TableFooter.displayName = "TableFooter";
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableRow.displayName = "TableRow";
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableHead.displayName = "TableHead";
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableCell.displayName = "TableCell";
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableCaption.displayName = "TableCaption";
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/challenge-3-frontend/hooks/use-toast.ts b/challenge-3-frontend/hooks/use-toast.ts
index 02e111d..af106b7 100644
--- a/challenge-3-frontend/hooks/use-toast.ts
+++ b/challenge-3-frontend/hooks/use-toast.ts
@@ -3,15 +3,12 @@
// Inspired by react-hot-toast library
import * as React from "react"
-import type {
- ToastActionElement,
- ToastProps,
-} from "@/components/ui/toast"
+import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
-const TOAST_LIMIT = 1
-const TOAST_REMOVE_DELAY = 1000000
+const TOAST_LIMIT = 5
+const TOAST_REMOVE_DELAY = 5000
-type ToasterToast = ToastProps & {
+type ToasterToastProps = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
@@ -37,23 +34,23 @@ type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
- toast: ToasterToast
+ toast: ToasterToastProps
}
| {
type: ActionType["UPDATE_TOAST"]
- toast: Partial
+ toast: Partial
}
| {
type: ActionType["DISMISS_TOAST"]
- toastId?: ToasterToast["id"]
+ toastId?: string
}
| {
type: ActionType["REMOVE_TOAST"]
- toastId?: ToasterToast["id"]
+ toastId?: string
}
interface State {
- toasts: ToasterToast[]
+ toasts: ToasterToastProps[]
}
const toastTimeouts = new Map>()
@@ -93,8 +90,6 @@ export const reducer = (state: State, action: Action): State => {
case "DISMISS_TOAST": {
const { toastId } = action
- // ! Side effects ! - This could be extracted into a dismissToast() action,
- // but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
@@ -129,7 +124,7 @@ export const reducer = (state: State, action: Action): State => {
}
}
-const listeners: Array<(state: State) => void> = []
+const listeners: ((state: State) => void)[] = []
let memoryState: State = { toasts: [] }
@@ -140,12 +135,12 @@ function dispatch(action: Action) {
})
}
-type Toast = Omit
+type Toast = Omit
function toast({ ...props }: Toast) {
const id = genId()
- const update = (props: ToasterToast) =>
+ const update = (props: ToasterToastProps) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
diff --git a/challenge-3-frontend/lib/types.ts b/challenge-3-frontend/lib/types.ts
new file mode 100644
index 0000000..b28dbfb
--- /dev/null
+++ b/challenge-3-frontend/lib/types.ts
@@ -0,0 +1,30 @@
+import { Address } from "viem";
+
+export interface VestingSchedule {
+ totalAmount: bigint;
+ startTime: bigint;
+ cliffDuration: bigint;
+ vestDuration: bigint;
+ amountClaimed: bigint;
+ revoked: boolean;
+}
+
+export interface BeneficiaryInfo {
+ address: Address;
+ isWhitelisted: boolean;
+ vestingSchedule?: VestingSchedule;
+ vestedAmount?: bigint;
+ claimableAmount?: bigint;
+}
+
+export interface VestingFormData {
+ beneficiary: string;
+ amount: string;
+ cliffDuration: string;
+ vestDuration: string;
+ startTimestamp: string;
+}
+
+export interface WhitelistFormData {
+ beneficiary: string;
+}
diff --git a/challenge-3-frontend/lib/utils.ts b/challenge-3-frontend/lib/utils.ts
index d47db6e..be9cb8b 100644
--- a/challenge-3-frontend/lib/utils.ts
+++ b/challenge-3-frontend/lib/utils.ts
@@ -1,10 +1,19 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
+ return twMerge(clsx(inputs));
}
-export function truncateHash(hash: string, startLength: number = 6, endLength: number = 4) {
+export function truncateHash(
+ hash: string,
+ startLength: number = 6,
+ endLength: number = 4
+) {
return `${hash.slice(0, startLength)}...${hash.slice(-endLength)}`;
}
+
+export function formatDate(timestamp: number | bigint): string {
+ const date = new Date(Number(timestamp) * 1000);
+ return date.toLocaleDateString() + " " + date.toLocaleTimeString();
+}
diff --git a/challenge-3-frontend/package-lock.json b/challenge-3-frontend/package-lock.json
index dec4c63..be75dff 100644
--- a/challenge-3-frontend/package-lock.json
+++ b/challenge-3-frontend/package-lock.json
@@ -10,7 +10,10 @@
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-dialog": "^1.1.3",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
+ "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.1",
+ "@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
@@ -1274,6 +1277,76 @@
}
}
},
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz",
+ "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-menu": "2.1.6",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-controllable-state": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
+ "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
+ "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
@@ -1314,6 +1387,15 @@
}
}
},
+ "node_modules/@radix-ui/react-icons": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
+ "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/@radix-ui/react-id": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz",
@@ -1355,6 +1437,300 @@
}
}
},
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz",
+ "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-collection": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-direction": "1.1.0",
+ "@radix-ui/react-dismissable-layer": "1.1.5",
+ "@radix-ui/react-focus-guards": "1.1.1",
+ "@radix-ui/react-focus-scope": "1.1.2",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-popper": "1.2.2",
+ "@radix-ui/react-portal": "1.1.4",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-roving-focus": "1.1.2",
+ "@radix-ui/react-slot": "1.1.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
+ "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
+ "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-slot": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
+ "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-escape-keydown": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz",
+ "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
+ "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.0",
+ "@radix-ui/react-use-rect": "1.1.0",
+ "@radix-ui/react-use-size": "1.1.0",
+ "@radix-ui/rect": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
+ "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
+ "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
+ "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-collection": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-direction": "1.1.0",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-controllable-state": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
+ "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/react-remove-scroll": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
+ "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-popper": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
@@ -1458,6 +1834,71 @@
}
}
},
+ "node_modules/@radix-ui/react-progress": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
+ "integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
+ "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
+ "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
diff --git a/challenge-3-frontend/package.json b/challenge-3-frontend/package.json
index f4b780e..407d534 100644
--- a/challenge-3-frontend/package.json
+++ b/challenge-3-frontend/package.json
@@ -11,7 +11,10 @@
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-dialog": "^1.1.3",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
+ "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.1",
+ "@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",