diff --git a/README.md b/README.md index 1cc026c..abf73b9 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,114 @@ -# Digital Last Will +# Digital Will dApp — Frontend Refactor & UX Stabilization -## Inspired by -- Alchemy University Certification Project +## Overview -**Digital Last Will** is a decentralized application that allows individuals to create a smart contract-powered digital will. The system ensures that assets (ETH or ERC20 tokens) are securely distributed to designated beneficiaries if the original owner becomes inactive for a specified period. +This project is a **frontend refactor and UX stabilization** of an existing Ethereum-based **Digital Will decentralized application**. -> Secure your assets. Automate your legacy. Trust code, not courts. +The original application enables users to create a digital will that distributes ETH to beneficiaries after a defined period of inactivity. +This fork focuses on improving **usability, visual clarity, wallet reliability, and frontend robustness** — without modifying the original smart contract logic. -## Features +The project is considered **feature-complete for frontend improvements** and is suitable for portfolio presentation. -### Core Features -- **Create a Digital Will** with multiple beneficiaries and custom distribution percentages -- **Deposit ETH** directly into your will -- **Ping Mechanism** to prove you’re still active ("alive") -- **Automated Will Execution** after a configurable period of inactivity -- **Manual Executor Access**: Any user can trigger the will once it's eligible -- **View, Update or Cancel** your will at any time (optional) +--- + +## What Was Done + +### Frontend & UX Improvements + +- Refactored dashboard layout and visual hierarchy +- Introduced a clean **law-firm / notary-inspired UI** +- Added an animated, accessible **Connect Wallet** button using Framer Motion +- Improved sidebar navigation and reusable card components +- Hardened UI rendering against invalid or partial contract state +- Defensive formatting for BigNumber values and timestamps +- Reduced UI crashes caused by undefined or cleared contract data + +### Wallet & Web3 Integration + +- Stabilized wallet connection logic +- Supported **MetaMask** and **Core Wallet** +- Prevented duplicate `eth_requestAccounts` calls +- Improved network validation and connection feedback +- Reduced common Web3 frontend issues (BigNumber overflow, stale signer, reload loops) + +### Code Quality & Reliability + +- Normalized smart contract return data +- Improved custom hooks (`useGetWills`) for safer reads +- Clear separation of concerns: + - Context (wallet & provider) + - Hooks (contract reads) + - UI components +- Added defensive guards to prevent runtime crashes + +--- + +## Known Limitation (Intentional) + +The **My Will** page may show inconsistent state in edge cases (e.g. cancelled or executed wills). + +This is due to **semantic ambiguity in the original smart contract**, where: + +- Cancelled or executed wills clear beneficiary data +- The same mapping slot is reused +- Frontend cannot reliably infer lifecycle history without additional contract state + +Fixing this correctly would require **smart contract redesign**, which was intentionally **out of scope** for this frontend-focused refactor. + +The **Dashboard** reflects aggregate state correctly. +Deeper lifecycle semantics are deferred by design. +--- ## Tech Stack -| Layer | Tech | -|-------------|--------------------------| -| Smart Contract | Solidity | -| Framework | Hardhat | -| Frontend | React.js, Tailwind CSS | -| Blockchain | Ethereum Sepolia Testnet | -| Wallet | MetaMask | +### Frontend + +- **React** (Vite) +- **Tailwind CSS** +- **Framer Motion** +- **React Router** +- **Lucide Icons** + +### Web3 + +- **ethers.js (v5)** +- **MetaMask** +- **Core Wallet** +- **Hardhat** (local development & testing) + +### Tooling + +- **Vite** +- **ESLint** +- **GitHub Desktop** + +--- + +## Project Status + +- ✅ Frontend refactor complete +- ✅ Wallet integration stabilized +- ✅ Deployed and demo-ready +- ❌ Smart contract redesign intentionally out of scope + +--- + +## Why This Project Matters + +This project demonstrates the ability to: + +- Refactor and stabilize an existing Web3 codebase +- Improve UX under smart contract constraints +- Debug real-world dApp integration issues +- Balance engineering quality with delivery +- Know when to ship instead of over-engineering --- -## Getting Started +## Notes -### 1. Clone the Repo +This repository represents a **frontend-focused contribution** to an existing dApp. +All smart contract logic remains unchanged from the original implementation. -```bash -git clone https://github.com/papilo-cloud/digital-will.git -cd digital-will -``` \ No newline at end of file +--- diff --git a/contracts/Will.sol b/contracts/Will.sol index 0bc553f..858ab5d 100644 --- a/contracts/Will.sol +++ b/contracts/Will.sol @@ -35,15 +35,14 @@ contract CreateWill { function createWill(address[] memory _beneficiaries, uint256[] memory _amounts, uint256 _deathTimeout) external payable { require(usersWill[msg.sender].beneficiaries.length == 0 || usersWill[msg.sender].cancelled || usersWill[msg.sender].executed, "Will already exists"); - require(msg.value >= 1 ether, "Minimum of 1 ether required to create will"); - require(_beneficiaries.length == _amounts.length, "Bebeficiaries and amount must be of the same length"); - require(_beneficiaries.length > 0 && _amounts.length <= 10, "1 to 10 beneficiaries allowed"); + require(msg.value >= 0.01 ether, "Minimum of 0.01 ether required to create will"); // Changed from 1 ether + require(_beneficiaries.length == _amounts.length, "Beneficiaries and amount must be of the same length"); + require(_beneficiaries.length > 0 && _beneficiaries.length <= 10, "1 to 10 beneficiaries allowed"); uint256 total = 0; for (uint i = 0; i < _beneficiaries.length; i++) { require(_beneficiaries[i] != address(0), "Beneficiary address must be a valid address"); - require(_amounts[i] > 0 && _amounts[i] < 100 ether, "Invalid mount"); - + require(_amounts[i] > 0, "Amount must be greater than 0"); // Removed max limit total += _amounts[i]; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0d030df..e0ee74e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.1.11", + "buffer": "^6.0.3", "clsx": "^2.1.1", "ethers": "^5.7.0", + "framer-motion": "^12.23.25", "lucide-react": "^0.526.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -21,13 +23,13 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.21", + "autoprefixer": "^10.4.22", "eslint": "^8.55.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.5.6", - "tailwindcss": "^4.1.11", + "tailwindcss": "^4.1.17", "vite": "^5.0.8" } }, @@ -1872,6 +1874,11 @@ "tailwindcss": "4.1.11" } }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==" + }, "node_modules/@tailwindcss/oxide": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", @@ -2119,6 +2126,11 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2454,9 +2466,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", "dev": true, "funding": [ { @@ -2472,11 +2484,10 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -2514,6 +2525,34 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.2.tgz", + "integrity": "sha512-PxSsosKQjI38iXkmb3d0Y32efqyA0uW4s41u4IVBsLlWLhCiYNpH/AfNOVWRqCQBlD8TFJTz6OUWNd4DFJCnmw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bech32": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", @@ -2544,9 +2583,9 @@ "license": "MIT" }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -2562,12 +2601,12 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -2576,6 +2615,29 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2637,9 +2699,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "dev": true, "funding": [ { @@ -2654,8 +2716,7 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] }, "node_modules/chalk": { "version": "4.1.2", @@ -2901,11 +2962,10 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.191", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz", - "integrity": "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==", - "dev": true, - "license": "ISC" + "version": "1.5.265", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.265.tgz", + "integrity": "sha512-B7IkLR1/AE+9jR2LtVF/1/6PFhY5TlnEHnlrKmGk7PvkJibg5jr+mLXLLzq3QYl6PA1T/vLDthQPqIPAlS/PPA==", + "dev": true }, "node_modules/elliptic": { "version": "6.6.1", @@ -3161,7 +3221,6 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -3547,19 +3606,44 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, - "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.25", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.25.tgz", + "integrity": "sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3898,6 +3982,25 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4874,6 +4977,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4907,11 +5023,10 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true }, "node_modules/normalize-range": { "version": "0.1.2", @@ -5193,7 +5308,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5915,10 +6029,10 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", - "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", - "license": "MIT" + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true }, "node_modules/tapable": { "version": "2.2.2", @@ -5962,6 +6076,11 @@ "dev": true, "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6086,9 +6205,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", "dev": true, "funding": [ { @@ -6104,7 +6223,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" diff --git a/frontend/package.json b/frontend/package.json index 7824c53..080ff95 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "@tailwindcss/vite": "^4.1.11", + "buffer": "^6.0.3", "clsx": "^2.1.1", "ethers": "^5.7.0", + "framer-motion": "^12.23.25", "lucide-react": "^0.526.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -23,13 +25,13 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.21", + "autoprefixer": "^10.4.22", "eslint": "^8.55.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.5.6", - "tailwindcss": "^4.1.11", + "tailwindcss": "^4.1.17", "vite": "^5.0.8" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 387201b..c29f48b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { ContractProvider } from './context/ContractContext' -import CreateWillForm from './components/WillForm' +import WillForm from './components/WillForm' import ExecuteWill from './components/ExecuteWill' import { ToastContainer } from 'react-toastify' import { BrowserRouter, Route, Routes } from 'react-router-dom' @@ -15,7 +15,7 @@ function App() { } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Card/DashboardCard.jsx b/frontend/src/components/Card/DashboardCard.jsx index 4a9ced3..b48ebf5 100644 --- a/frontend/src/components/Card/DashboardCard.jsx +++ b/frontend/src/components/Card/DashboardCard.jsx @@ -1,11 +1,38 @@ +import { motion } from "framer-motion"; +import PropTypes from "prop-types"; + + const DashboardCard = ({ title, value }) => { - return ( -
-

{title}

-

{value}

-
+ +

+ {title} +

+ +

+ {value} +

+
); }; +DashboardCard.propTypes = { + title: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, +}; + + export default DashboardCard; diff --git a/frontend/src/components/Card/WillsCard.jsx b/frontend/src/components/Card/WillsCard.jsx index ed400b2..01299ab 100644 --- a/frontend/src/components/Card/WillsCard.jsx +++ b/frontend/src/components/Card/WillsCard.jsx @@ -1,21 +1,116 @@ -import React from 'react' -import TruncatedAddress from '../TruncatedAddress' -import CountdownTimer from '../../hooks/CountdownTimer' -import { ethers } from 'ethers' +import React from "react"; +import TruncatedAddress from "../TruncatedAddress"; +import CountdownTimer from "../../hooks/CountdownTimer"; +import { ethers } from "ethers"; +import { motion } from "framer-motion"; + +const statusColors = { + "Executed": "bg-green-600/20 text-green-300 border-green-600/40", + "Active": "bg-blue-600/20 text-blue-300 border-blue-600/40", + "Ready to Execute": "bg-yellow-600/20 text-yellow-300 border-yellow-600/40", + "Expired": "bg-red-600/20 text-red-300 border-red-600/40", + "Cancelled": "bg-gray-600/20 text-gray-300 border-gray-600/40" +}; + +const WillsCard = ({ deathTimeout, status, address, balance, lastPing, timeLeft, cancelled }) => { + + // Convert BigNumber values to readable formats + const formattedBalance = ethers.utils.formatEther( + ethers.BigNumber.isBigNumber(balance) ? balance : ethers.BigNumber.from(balance || 0) + ); + + const deathTimeoutSeconds = ethers.BigNumber.isBigNumber(deathTimeout) + ? deathTimeout.toString() + : deathTimeout?.toString() || "0"; + + const lastPingTimestamp = ethers.BigNumber.isBigNumber(lastPing) + ? lastPing.toString() + : lastPing?.toString() || "0"; + + const isCancelled = typeof cancelled === 'boolean' + ? cancelled + : cancelled === true || cancelled === "Yes"; + + const resolvedStatus = isCancelled ? "Cancelled" : status; -const WillsCard = ({deathTimeout, status, address, balance, lastPing, timeLeft, cancelled}) => { return ( -
- -

{parseFloat(ethers.utils.formatEther(balance.toString())).toFixed(5)} ETH

-

{status}

- {timeLeft > 0 ? - : - 'Expired' - } -

{cancelled}

-
- ) -} - -export default WillsCard \ No newline at end of file + + {/* Header */} +
+

+ Will Document +

+ + + {resolvedStatus} + +
+ + {/* Content Grid */} +
+ +
+

Owner

+ +
+ +
+

Balance

+

{parseFloat(formattedBalance).toFixed(4)} ETH

+
+ +
+

Time Left

+ {timeLeft > 0 ? ( + + ) : ( +

Expired

+ )} +
+ +
+

Death Timeout

+

+ {deathTimeoutSeconds} sec ({(parseInt(deathTimeoutSeconds) / 86400).toFixed(1)} days) +

+
+ +
+

Last Ping

+

+ {new Date(parseInt(lastPingTimestamp) * 1000).toLocaleString()} +

+
+ +
+

Cancelled

+

{isCancelled ? "Yes" : "No"}

+
+ +
+
+ ); +}; + +export default WillsCard; \ No newline at end of file diff --git a/frontend/src/components/Core/Buttons/ConnectWalletButton.jsx b/frontend/src/components/Core/Buttons/ConnectWalletButton.jsx index ab2fccf..98a1ffb 100644 --- a/frontend/src/components/Core/Buttons/ConnectWalletButton.jsx +++ b/frontend/src/components/Core/Buttons/ConnectWalletButton.jsx @@ -1,32 +1,69 @@ -import { useEffect } from 'react' -import { useContract } from '../../../context/ContractContext' -import truncate from '../../../utils/truncate' -import Button from './Button' -import ButtonText from './ButtonText' -import { toast } from 'react-toastify' +import { motion } from "framer-motion"; +import { useContract } from "../../../context/ContractContext"; +import truncate from "../../../utils/truncate"; const ConnectWalletButton = () => { - - const { connectWallet, walletAddress, isConnected, networkError } = useContract() - - useEffect(() => { - toast.error(networkError) - }, [networkError]) + const { connectWallet, walletAddress, isConnected, isConnecting } = useContract(); return ( <> - {!isConnected ? ( - - ): ( -

{truncate(walletAddress)}

- )} + {!isConnected ? ( + + {isConnecting ? "Connecting…" : "Connect Wallet"} + + ) : ( + + {truncate(walletAddress)} + + )} - ) -} + ); +}; -export default ConnectWalletButton \ No newline at end of file +export default ConnectWalletButton; diff --git a/frontend/src/components/Core/Form/TextInput.jsx b/frontend/src/components/Core/Form/TextInput.jsx index 5591a0e..3afd0e4 100644 --- a/frontend/src/components/Core/Form/TextInput.jsx +++ b/frontend/src/components/Core/Form/TextInput.jsx @@ -9,7 +9,6 @@ const TextInput = ({placeholder, onChange, value, type, className, ...props}) => className={clsx('bg-[#0e0e0e] text-white placeholder:text-[#666666] px-3 py-2 outline-0 rounded', className)} value={value} onChange={onChange} - required {...props} /> ) diff --git a/frontend/src/components/ExecuteWill.jsx b/frontend/src/components/ExecuteWill.jsx index f826ee1..49793b9 100644 --- a/frontend/src/components/ExecuteWill.jsx +++ b/frontend/src/components/ExecuteWill.jsx @@ -6,59 +6,209 @@ import DashboardLayout from './DashboardLayout' import Button from './Core/Buttons/Button' import ButtonText from './Core/Buttons/ButtonText' import TextInput from './Core/Form/TextInput' -import Card from './Card/Card' -import CardHeader from './Card/CardHeader' +import { motion } from 'framer-motion' +import { Skull, AlertCircle, CheckCircle } from 'lucide-react' const ExecuteWill = () => { const [testatorAddress, setTestatorAddress] = useState('') const [loading, setLoading] = useState(false) + const [willStatus, setWillStatus] = useState(null) - const {contract} = useContract() + const { contract } = useContract() + + // Check if will is ready to execute + const checkWillStatus = async () => { + if (!contract || !testatorAddress) return; + + if (!ethers.utils.isAddress(testatorAddress)) { + toast.error('Invalid Ethereum address') + return; + } + + try { + const will = await contract.usersWill(testatorAddress) + const now = Math.floor(Date.now() / 1000) + const timeLeft = parseInt(will.lastPing) + parseInt(will.deathTimeout) - now + + setWillStatus({ + exists: will.balance.gt(0), + balance: ethers.utils.formatEther(will.balance), + executed: will.executed, + cancelled: will.cancelled, + timeLeft, + canExecute: timeLeft <= 0 && !will.executed && !will.cancelled && will.balance.gt(0) + }) + } catch (error) { + console.error(error) + toast.error('Failed to fetch will status') + } + } const handleExecute = async () => { if (!contract) { - toast.warn('Contract not connected.') + toast.warn('Wallet not connected.') return; } + if (!ethers.utils.isAddress(testatorAddress)) { - toast.warn('Invalid address') + toast.error('Invalid Ethereum address') return; } + setLoading(true) try { const tx = await contract.executeWill(testatorAddress) - await tx.wait(); - toast.success('Will executed successfully!') + await tx.wait() + toast.success('Will executed successfully! Funds distributed to beneficiaries.') + + // Clear form and status + setTestatorAddress('') + setWillStatus(null) } catch (error) { - const message = error?.error?.message || error?.message || error; - console.error(message); + const message = error?.error?.data?.message || error?.error?.message || error?.message || 'Execution failed'; + console.error(error) toast.error(message) } finally { setLoading(false) } } - return ( - - - Execute a Wil - setTestatorAddress(e.target.value)} - /> - - - - ) +
+ +

+ Testator Information +

+ + {/* Address Input */} +
+ + setTestatorAddress(e.target.value)} + /> +

+ Enter the address of the deceased testator whose will you want to execute. +

+
+ + {/* Check Status Button */} + + + {/* Will Status Display */} + {willStatus && ( + +
+ {willStatus.canExecute ? ( + + ) : ( + + )} + +
+

+ {willStatus.canExecute + ? '✓ Will Ready for Execution' + : willStatus.exists && !willStatus.executed && !willStatus.cancelled + ? '⏳ Will Not Yet Executable' + : '✗ Will Cannot Be Executed'} +

+ +
+

Balance: {willStatus.balance} ETH

+ + {willStatus.executed && ( +

⚠️ This will has already been executed

+ )} + + {willStatus.cancelled && ( +

⚠️ This will has been cancelled

+ )} + + {!willStatus.exists && ( +

⚠️ No will exists for this address

+ )} + + {willStatus.timeLeft > 0 && !willStatus.executed && !willStatus.cancelled && ( +

+ ⏰ Time remaining: {Math.floor(willStatus.timeLeft / 86400)} days, {Math.floor((willStatus.timeLeft % 86400) / 3600)} hours +

+ )} + + {willStatus.canExecute && ( +

+ ✓ The death timeout has expired. You can now execute this will. +

+ )} +
+
+
+
+ )} + + {/* Execute Button */} + + + {/* Warning Notice */} +
+

+ ⚠️ Important: Anyone can execute a will once the death timeout has expired. + This action is irreversible and will immediately distribute all assets to the designated beneficiaries. +

+
+
+ + + ) } export default ExecuteWill \ No newline at end of file diff --git a/frontend/src/components/MyWill.jsx b/frontend/src/components/MyWill.jsx index bbd787c..0610808 100644 --- a/frontend/src/components/MyWill.jsx +++ b/frontend/src/components/MyWill.jsx @@ -1,77 +1,117 @@ -import { useEffect, useState } from 'react' -import { toast } from 'react-toastify' -import UserWill from './UserWill' -import PingWill from './PingWill' -import CancelWill from './CancelWill' -import CreateWillForm from './WillForm' -import { useContract } from '../context/ContractContext' -import DashboardLayout from './DashboardLayout' -import useGetWills from '../hooks/useGetWills' +import { useEffect, useState } from "react"; +import { toast } from "react-toastify"; +import { Link } from "react-router-dom"; +import { ethers } from "ethers"; + +import UserWill from "./UserWill"; +import PingWill from "./PingWill"; +import CancelWill from "./CancelWill"; +import DashboardLayout from "./DashboardLayout"; + +import { useContract } from "../context/ContractContext"; +import useGetWills from "../hooks/useGetWills"; const MyWill = () => { - const [loading, setLoading] = useState(true) - const [beneficiaries, setBeneficiaries] = useState(null) - const [amounts, setAmounts] = useState(null) - - const { walletAddress, contract} = useContract() - const {willInfo, hasWill, fetchAllWills} = useGetWills() - - const fetchWillInfo = async () => { - if (!contract || !walletAddress) return - - try { - const filter = await contract.filters.WillCreated(walletAddress) - const logs = await contract.queryFilter(filter) - - if (logs?.length > 0) { - const willEvent = logs[logs.length - 1] - const { beneficiaries, amounts, } = willEvent.args - - setBeneficiaries(beneficiaries) - setAmounts(amounts) - - if(fetchAllWills) fetchAllWills(); - - } - - } catch (error) { - const message = error?.error?.message || error?.message || error; - console.error(message); - toast.error(message) - } finally { - setLoading(false) - } + const [loading, setLoading] = useState(true); + const [history, setHistory] = useState([]); + + const { walletAddress, contract } = useContract(); + const { willInfo, hasActiveWill, hasEverCreatedWill, fetchAllWills } = useGetWills(); + + const fetchWillHistory = async () => { + if (!contract || !walletAddress) return; + + try { + setLoading(true); + + // All wills created by THIS wallet (events history) + const filter = contract.filters.WillCreated(walletAddress); + const logs = await contract.queryFilter(filter, 0, "latest"); + + const formatted = logs.map((log, index) => ({ + id: `${log.transactionHash}-${index}`, + blockNumber: log.blockNumber, + beneficiaries: log.args.beneficiaries, + amounts: log.args.amounts.map((a) => ethers.utils.formatEther(a)), + balance: ethers.utils.formatEther(log.args.balance), + deathTimeout: log.args.deathTimeout.toString(), + txHash: log.transactionHash, + })); + + setHistory(formatted); + + // refresh global state + if (fetchAllWills) fetchAllWills(); + } catch (err) { + const message = err?.error?.message || err?.message || err; + console.error(message); + toast.error(message); + } finally { + setLoading(false); } + }; - useEffect(() => { - fetchWillInfo(); - }, [contract, walletAddress]) + useEffect(() => { + fetchWillHistory(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contract, walletAddress]); return ( - <> -
- {hasWill ? ( - - {loading ? ( -
Loading Will data...
- ):( -
- -
- - -
-
- ) - } -
+ + {loading ? ( +
Loading Will data...
+ ) : ( + <> + {/* TOP SECTION: Active vs Not Active */} + {hasActiveWill ? ( +
+ +
+ + +
+
+ ) : ( +
+

No Active Will

+

+ {hasEverCreatedWill + ? "Your previous will was cancelled or executed. You can create a new one anytime." + : "You haven’t created a digital will yet."} +

+ + + Create Your Will + +
+ )} + + {/* HISTORY SECTION (show if any) */} +
+

Will History

+ + {history.length === 0 ? ( +

No previous wills found.

) : ( - +
+ {history.map((w) => ( +
+
Block #{w.blockNumber}
+
Balance: {w.balance} ETH
+
Beneficiaries: {w.beneficiaries.length}
+
TX: {w.txHash}
+
+ ))} +
)} -
- - - ) -} +
+ + )} + + ); +}; -export default MyWill \ No newline at end of file +export default MyWill; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 811ce88..045e55a 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,27 +1,75 @@ -import React from 'react' -import { Link } from 'react-router-dom' -import { Home, FilePlus, Trash2, RefreshCcw, Menu } from 'lucide-react'; +import React from "react"; +import { Link, useLocation } from "react-router-dom"; +import { Home, FilePlus, RefreshCcw, Menu } from "lucide-react"; +import { motion } from "framer-motion"; + +const links = [ + { to: "/", label: "Dashboard", icon: }, + { to: "/create", label: "Create Will", icon: }, + { to: "/execute", label: "Execute Will", icon: }, + { to: "/mywill", label: "My Will", icon: }, +]; const Sidebar = () => { + const { pathname } = useLocation(); + return ( -
-

Will DApp

-
- ) -} + ); + })} + + + + ); +}; -export default Sidebar \ No newline at end of file +export default Sidebar; diff --git a/frontend/src/components/WillForm.jsx b/frontend/src/components/WillForm.jsx index 0aab015..785a2a5 100644 --- a/frontend/src/components/WillForm.jsx +++ b/frontend/src/components/WillForm.jsx @@ -1,137 +1,359 @@ -import { useState } from 'react' -import { useContract } from '../context/ContractContext'; -import { ethers } from 'ethers'; -import { toast } from 'react-toastify'; -import DashboardLayout from './DashboardLayout'; -import { Trash2, Plus } from 'lucide-react'; -import Button from './Core/Buttons/Button'; -import ButtonText from './Core/Buttons/ButtonText'; -import TextInput from './Core/Form/TextInput'; - - -const CreateWillForm = ({onCreateWill}) => { - const [beneficiaries, setBeneficiaries] = useState(['']); - const [amounts, setAmounts] = useState(['']) - const [deathTimeout, setDeathTimeout] = useState(''); - const [etherValue, setEtherValue] = useState('') - const [loading, setLoading] = useState(false) +import { useState } from "react"; +import PropTypes from "prop-types"; +import { useContract } from "../context/ContractContext"; +import { ethers } from "ethers"; +import { toast } from "react-toastify"; +import { motion, AnimatePresence } from "framer-motion"; +import DashboardLayout from "./DashboardLayout"; +import { Trash2, Plus } from "lucide-react"; +import TextInput from "./Core/Form/TextInput"; +import Button from "./Core/Buttons/Button"; +import ButtonText from "./Core/Buttons/ButtonText"; + +const WillForm = ({ onCreateWill }) => { + const [step, setStep] = useState(1); + + const [beneficiaries, setBeneficiaries] = useState([""]); + const [amounts, setAmounts] = useState([""]); + + const [deathTimeout, setDeathTimeout] = useState(""); + const [etherValue, setEtherValue] = useState(""); + const [loading, setLoading] = useState(false); const { contract, walletAddress } = useContract(); - const handleChange = (setter, index, value) => { - setter(prev => { - const copy = [...prev]; - copy[index] = value; - return copy; - }) - } - - const removeField = (index) => { - const updated = beneficiaries.filter((_, i) => i !== index) - setBeneficiaries(updated) - } + // -------------------------- + // Input Helpers + // -------------------------- + const handleChange = (setter, i, value) => { + setter((prev) => { + const updated = [...prev]; + updated[i] = value.trim(); + return updated; + }); + }; + const addField = () => { - if (beneficiaries.length < 10) { - setBeneficiaries([...beneficiaries, '']) - setAmounts([...amounts, '']) + if (beneficiaries.length < 10) { + setBeneficiaries((prev) => [...prev, ""]); + setAmounts((prev) => [...prev, ""]); + } + }; + + const removeField = (i) => { + setBeneficiaries((prev) => prev.filter((_, idx) => idx !== i)); + setAmounts((prev) => prev.filter((_, idx) => idx !== i)); + }; + + const nextStep = () => { + // Validate before moving to next step + if (step === 1) { + const cleanedBeneficiaries = beneficiaries.filter((b) => b && b.trim() !== ""); + const cleanedAmounts = amounts.filter((a) => a && a.trim() !== ""); + + if (cleanedBeneficiaries.length === 0) { + toast.error("Please enter at least one beneficiary."); + return; + } + + if (cleanedBeneficiaries.length !== cleanedAmounts.length) { + toast.error("Each beneficiary must have an associated amount."); + return; + } + + // Validate addresses + for (let i = 0; i < cleanedBeneficiaries.length; i++) { + const addr = cleanedBeneficiaries[i]; + + // Check if it's a valid Ethereum address + if (!addr || addr === "" || !ethers.utils.isAddress(addr)) { + toast.error(`Invalid Ethereum address at row ${i + 1}. Please enter a valid address starting with 0x`); + return; + } + + // Additional check: make sure it's not the zero address + if (addr.toLowerCase() === "0x0000000000000000000000000000000000000000") { + toast.error(`Cannot use zero address (0x0...0) at row ${i + 1}`); + return; + } + } + + // Validate amounts + for (let i = 0; i < cleanedAmounts.length; i++) { + const amt = cleanedAmounts[i]; + if (!amt || amt === "" || isNaN(amt) || Number(amt) <= 0) { + toast.error(`Invalid ETH amount at row ${i + 1}. Must be greater than 0`); + return; + } + } + } + + if (step === 2) { + if (!deathTimeout || deathTimeout === "" || isNaN(deathTimeout) || Number(deathTimeout) <= 0) { + toast.error("Death timeout must be a valid number greater than 0."); + return; } - } + } - const handleSubmit = async (e) => { - e.preventDefault(); - if (!contract || !walletAddress) { - toast.warn('Wallet not connected') + if (step === 3) { + if (!etherValue || etherValue === "" || isNaN(etherValue) || Number(etherValue) <= 0) { + toast.error("Total ETH must be a valid positive number."); return; } - setLoading(true) - try { - const tx = await contract.createWill( - beneficiaries, - amounts.map(a => ethers.utils.parseEther(a)), - parseInt(deathTimeout), - {value: ethers.utils.parseEther(etherValue)} - ) - await tx.wait() - - if(onCreateWill) onCreateWill() - - toast.success('Will created successfully.') - } catch (error) { - const message = error?.error?.message || error?.message || error; - console.error('Revert reason:', message); - toast.error(message) + + // Check if total amount matches sum of beneficiary amounts + const cleanedAmounts = amounts.filter((a) => a && a.trim() !== ""); + const sumOfAmounts = cleanedAmounts.reduce((sum, amt) => sum + Number(amt), 0); + + if (Number(etherValue) < sumOfAmounts) { + toast.error(`Total ETH (${etherValue}) must be at least the sum of all beneficiary amounts (${sumOfAmounts})`); + return; } - setLoading(false) - } + } + + setStep((s) => s + 1); + }; + + const prevStep = () => setStep((s) => s - 1); + + // -------------------------- + // Submit Handler + // -------------------------- + const handleSubmit = async () => { + if (!contract || !walletAddress) { + toast.warn("Wallet not connected"); + return; + } + + const cleanedBeneficiaries = beneficiaries.filter((b) => b && b.trim() !== ""); + const cleanedAmounts = amounts.filter((a) => a && a.trim() !== ""); + + if (cleanedBeneficiaries.length === 0) { + toast.error("Please enter at least one beneficiary."); + return; + } + + setLoading(true); + + try { + const tx = await contract.createWill( + cleanedBeneficiaries, + cleanedAmounts.map((amt) => ethers.utils.parseEther(amt)), + Number(deathTimeout), + { value: ethers.utils.parseEther(etherValue) } + ); + + await tx.wait(); + toast.success("Will created successfully."); + + onCreateWill?.(); + + // reset + setBeneficiaries([""]); + setAmounts([""]); + setDeathTimeout(""); + setEtherValue(""); + setStep(1); + + } catch (err) { + console.error(err); + const errorMessage = err?.error?.data?.message || err?.error?.message || err?.message || "Transaction failed."; + toast.error(errorMessage); + } + + setLoading(false); + }; + + const stepMotion = { + initial: { opacity: 0, x: 40 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: -40 }, + transition: { duration: 0.35 }, + }; return ( -
-

Create your will

-
- {beneficiaries.map((b, i) => ( -
- handleChange(setBeneficiaries, i, e.target.value)} - /> - handleChange(setAmounts, i, e.target.value)} - min={'0.01'} - max={'99.99'} - step={'0.01'} - /> - {beneficiaries.length > 1 && ( - +
+ +

Create Your Will

+ + {/* Step Indicators */} +
+ {[1, 2, 3, 4].map((n) => ( +
= n ? "bg-gold" : "bg-slate/40" + }`} + >
+ ))} +
+ + + {/* ------------ STEP 1 ------------ */} + {step === 1 && ( + +

+ 1. Beneficiaries & Allocations +

+ +

+ Enter valid Ethereum addresses (starting with 0x) and the amount each beneficiary should receive. +

+ + {beneficiaries.map((b, i) => ( +
+ + handleChange(setBeneficiaries, i, e.target.value) + } + /> + + + handleChange(setAmounts, i, e.target.value) + } + /> + + {beneficiaries.length > 1 && ( + + )} +
+ ))} + + {beneficiaries.length < 10 && ( + + )} + +
+ +
+
+ )} + + {/* ------------ STEP 2 ------------ */} + {step === 2 && ( + +

+ 2. Inactivity Timer +

+ +

+ Set the inactivity period (in seconds). If you don't "ping" within this time, your will becomes executable. +

+ + setDeathTimeout(e.target.value)} + className="w-full" + /> + +
+ + +
+
+ )} + + {/* ------------ STEP 3 ------------ */} + {step === 3 && ( + +

+ 3. Deposit ETH +

+ +

+ Enter the total amount of ETH to deposit into your will. This should equal or exceed the sum of all beneficiary amounts. +

+ + setEtherValue(e.target.value)} + className="w-full" + /> + +
+ Sum of beneficiary amounts: {amounts.filter(a => a && a.trim()).reduce((sum, amt) => sum + Number(amt || 0), 0).toFixed(4)} ETH +
+ +
+ + +
+
+ )} + + {/* ------------ STEP 4 ------------ */} + {step === 4 && ( + +

+ 4. Review & Confirm +

+ +
+

Beneficiaries

+ + {beneficiaries.map((b, i) => + b?.trim() ? ( +

+ {b} — {amounts[i]} ETH +

+ ) : null )} + +

Timeout

+

{deathTimeout} seconds ({(Number(deathTimeout) / 86400).toFixed(1)} days)

+ +

Total Funding

+

{etherValue} ETH

- ))} - {beneficiaries.length < 10 && ( - - )} - - setDeathTimeout(e.target.value)} - /> - setEtherValue(e.target.value)} - /> - - -
+ +
+ + + +
+ + )} + +
- ) -} + ); +}; + +WillForm.propTypes = { + onCreateWill: PropTypes.func, +}; -export default CreateWillForm \ No newline at end of file +export default WillForm; \ No newline at end of file diff --git a/frontend/src/context/ContractContext.jsx b/frontend/src/context/ContractContext.jsx index c252412..86b5306 100644 --- a/frontend/src/context/ContractContext.jsx +++ b/frontend/src/context/ContractContext.jsx @@ -1,103 +1,143 @@ -import { Contract, ethers } from 'ethers' -import contractAbi from '../abi/CreateWill.json' -import { createContext, useContext, useEffect, useState } from 'react'; -import { contractAddress } from '../utils/contractAddress'; -import { useMemo } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { Contract, ethers } from "ethers"; +import contractAbi from "../abi/CreateWill.json"; +import { contractAddress } from "../utils/contractAddress"; - -const SEPOLIA_CHAIN_ID = '11155111'; const ContractContext = createContext(); -const ABI = contractAbi.abi; +// Allowed networks +const ALLOWED_CHAINS = ["31337", "11155111"]; // Hardhat Local, Sepolia + +// Detect Core → Avalanche → MetaMask +function getWalletSource() { + if (typeof window === "undefined") return null; + return window.core || window.avalanche || window.ethereum || null; +} -export const ContractProvider = ({children}) => { +function ContractProvider({ children }) { + const [walletAddress, setWalletAddress] = useState(null); + const [provider, setProvider] = useState(null); + const [signer, setSigner] = useState(null); const [networkError, setNetworkError] = useState(null); - const [walletAddress, setWalletAddress] = useState(null) - const [provider, setProvider] = useState(null) - const [signer, setSigner] = useState(null) + const [isConnecting, setIsConnecting] = useState(false); + // CONNECT WALLET + async function connectWallet() { + if (isConnecting) return; + setIsConnecting(true); + const wallet = getWalletSource(); - const connectWallet = async () => { - if(!window.ethereum) return alert('Metamask not installed'); + if (!wallet) { + alert("Please install Core Wallet or MetaMask."); + setIsConnecting(false); + return; + } try { - const ethProvider = new ethers.providers.Web3Provider(window.ethereum); - await ethProvider.send('eth_requestAccounts', []); - const signer = ethProvider.getSigner() + const ethProvider = new ethers.providers.Web3Provider(wallet); + await wallet.request({ method: "eth_requestAccounts" }); + + const signer = ethProvider.getSigner(); const address = await signer.getAddress(); - const network = await ethProvider.getNetwork() + const network = await ethProvider.getNetwork(); + + console.log("🔗 Connected to network:", network.chainId); + console.log("👛 Wallet address:", address); - if(network.chainId.toString() !== SEPOLIA_CHAIN_ID){ - setNetworkError('Please switch to Sepolia network') + if (!ALLOWED_CHAINS.includes(network.chainId.toString())) { + setNetworkError("Wrong network. Use Sepolia or Localhost (31337)."); + setIsConnecting(false); return; } - setWalletAddress(address) - setProvider(ethProvider) - setSigner(signer) - setNetworkError(null) - + setProvider(ethProvider); + setSigner(signer); + setWalletAddress(address); + setNetworkError(null); + + console.log("✅ Wallet connected successfully"); } catch (err) { - console.error('Connection Failed:', err); + console.error("❌ Wallet connection error:", err); + alert("Failed to connect wallet. Please try again."); } + + setIsConnecting(false); } - const disconnectWallet = () => { - setWalletAddress(''); + // DISCONNECT + function disconnectWallet() { + setWalletAddress(null); setProvider(null); - setSigner(null) - setNetworkError(null) + setSigner(null); + setNetworkError(null); + console.log("🔌 Wallet disconnected"); } + // CONTRACT INSTANCE const contract = useMemo(() => { - if(!signer) return null; - return new Contract(contractAddress, ABI, signer); - }, [signer]) + if (!signer) return null; + return new Contract(contractAddress, contractAbi.abi, signer); + }, [signer]); + // AUTO-CONNECT IF PREVIOUSLY CONNECTED useEffect(() => { - const autoConnect = async () => { - if (window.ethereum) { - // const ethProvider = new ethers.providers.Web3Provider(window.ethereum); - // const accounts = await ethProvider.listAccounts() - - // if (accounts.length > 0) { - // const signer = ethProvider.getSigner() - // const address = await signer.getAddress() - // const network = await ethProvider.getNetwork() - - // if (network.chainId.toString() === SEPOLIA_CHAIN_ID) { - // setWalletAddress(address) - // setSigner(signer) - // setProvider(ethProvider) - // setNetworkError(null) - // } else { - // setNetworkError('Please switch to Sepolia network') - // } - // } - } - window.ethereum.on('accountsChanged', () => window.location.reload()) - window.ethereum.on('chainChanged', () => window.location.reload()) - } - autoConnect() - }, []) - + const wallet = getWalletSource(); + if (!wallet?.on) return; + + const handleAccountsChanged = async (accounts) => { + console.log("👤 Accounts changed:", accounts); + + if (!accounts || accounts.length === 0) { + return; + } + + const ethProvider = new ethers.providers.Web3Provider(wallet); + const signer = ethProvider.getSigner(); + + setProvider(ethProvider); + setSigner(signer); + setWalletAddress(accounts[0]); + setNetworkError(null); + + console.log("✅ Rebound signer & provider after account change"); + }; + + const handleChainChanged = () => { + console.log("⛓️ Chain changed"); + window.location.reload(); + }; + + wallet.on("accountsChanged", handleAccountsChanged); + wallet.on("chainChanged", handleChainChanged); + + return () => { + wallet.removeListener("accountsChanged", handleAccountsChanged); + wallet.removeListener("chainChanged", handleChainChanged); + }; +}, []); + return ( - {children} - ) + ); +} + +function useContract() { + return useContext(ContractContext); } -export const useContract = () => useContext(ContractContext); \ No newline at end of file +export { ContractProvider, useContract }; \ No newline at end of file diff --git a/frontend/src/hooks/CountdownTimer.jsx b/frontend/src/hooks/CountdownTimer.jsx index 75e887a..e0e8d1b 100644 --- a/frontend/src/hooks/CountdownTimer.jsx +++ b/frontend/src/hooks/CountdownTimer.jsx @@ -1,33 +1,47 @@ -import { BigNumber } from "ethers" -import { useEffect, useState } from "react" -import formatTime from "../utils/formatTime" -import clsx from "clsx" - -const CountdownTimer = ({lastPing, deathTimeout, className, children}) => { - const [timeleft, setTimeLeft] = useState(0) - - useEffect(() => { - const lastPingTime = BigNumber.from(lastPing).toNumber() - const timeOut = BigNumber.from(deathTimeout).toNumber() - const deathTimeStamp = lastPingTime + timeOut - - const updateCountdown = () => { - const now = Math.floor(Date.now() / 1000) - const remaining = deathTimeStamp - now - setTimeLeft(remaining > 0 ? remaining : 0) - } - - updateCountdown() - const interval = setInterval(updateCountdown, 1000) - - return () => clearInterval(interval) - }, [lastPing, deathTimeout]) - - return ( -
- {children} {formatTime(timeleft)} -
- ) -} - -export default CountdownTimer \ No newline at end of file +import { useEffect, useState } from "react"; +import { ethers } from "ethers"; + +const CountdownTimer = ({ lastPing, deathTimeout }) => { + const [timeLeft, setTimeLeft] = useState(0); + + useEffect(() => { + if (!lastPing || !deathTimeout) return; + + // ✅ ENSURE BigNumber math, not JS math + const lastPingBN = ethers.BigNumber.from(lastPing); + const timeoutBN = ethers.BigNumber.from(deathTimeout); + + const endTime = lastPingBN.add(timeoutBN); + + const tick = () => { + const now = Math.floor(Date.now() / 1000); + const nowBN = ethers.BigNumber.from(now); + + if (nowBN.gte(endTime)) { + setTimeLeft(0); + return; + } + + const diff = endTime.sub(nowBN).toNumber(); // SAFE: seconds, small number + setTimeLeft(diff); + }; + + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [lastPing, deathTimeout]); + + if (timeLeft <= 0) return Expired; + + const hours = Math.floor(timeLeft / 3600); + const minutes = Math.floor((timeLeft % 3600) / 60); + const seconds = timeLeft % 60; + + return ( + + {hours}h {minutes}m {seconds}s + + ); +}; + +export default CountdownTimer; diff --git a/frontend/src/hooks/useGetWills.jsx b/frontend/src/hooks/useGetWills.jsx index 0b8325c..3a036eb 100644 --- a/frontend/src/hooks/useGetWills.jsx +++ b/frontend/src/hooks/useGetWills.jsx @@ -1,86 +1,137 @@ -import React, { useEffect, useState } from 'react' -import { useContract } from '../context/ContractContext' -import { toast } from 'react-toastify' -import { Contract, ethers } from 'ethers' -import { contractAddress } from '../utils/contractAddress' -import contractAbi from '../abi/CreateWill.json' +import { useEffect, useState, useCallback, useRef } from "react"; +import { useContract } from "../context/ContractContext"; +import { toast } from "react-toastify"; +import { Contract, ethers } from "ethers"; +import { contractAddress } from "../utils/contractAddress"; +import contractAbi from "../abi/CreateWill.json"; const ABI = contractAbi.abi; +const ZERO = ethers.BigNumber.from(0); + +const normalizeWill = (w) => { + const beneficiaries = Array.isArray(w?.[0]) ? w[0] : []; + const rawAmounts = w?.[1]; + + const amounts = Array.isArray(rawAmounts) ? rawAmounts : rawAmounts ? [rawAmounts] : []; + + return { + beneficiaries, + amounts, + executed: Boolean(w?.[2]), + lastPing: Number(w?.[3] ?? 0), + cancelled: Boolean(w?.[4]), + balance: ethers.BigNumber.isBigNumber(w?.[5]) ? w[5] : ZERO, + deathTimeout: Number(w?.[6] ?? 0), + }; +}; const useGetWills = () => { - const [wills, setWills] = useState([]) - const [willInfo, setWillInfo] = useState(null) - const [hasWill, setHasWill] = useState(false) - const [totalBalance, setTotalBalance] = useState(0) - const [willsCreated, setWillsCreated] = useState(0) - - const {contract, provider, walletAddress, isConnected} = useContract() - - const fetchAllWills = async () => { - if (!contract || !walletAddress) { - toast.warn("Connect Wallet") - return; - } + const [wills, setWills] = useState([]); + const [willInfo, setWillInfo] = useState(null); - try { - const filterInstance = new Contract(contractAddress, ABI, provider) - const filter = filterInstance.filters.WillCreated() - const events = await filterInstance.queryFilter(filter, 0, 'latest'); - - const parsed = events.map(event => ({ - testator: event.args.testator, - beneficiaries: event.args.beneficiaries, - amounts: event.args.amounts.map(a => ethers.utils.formatEther(a)), - balance: ethers.utils.formatEther(event.args.balance), - deathTimeout: event.args.deathTimeout.toString(), - blockNUmber: event.blockNumber - })) - - const totalEther = parsed.reduce((acc, cur) => parseFloat(acc) + parseFloat(cur.balance), ethers.BigNumber.from(0)) - - const will = await contract.usersWill(walletAddress) - const isCreated = will?.balance.gt(0) - - const testators = await contract.getAllTestators() - const now = Math.floor(Date.now() / 1000) - const willList = [] - - const uniqueTestators = [...new Set(testators)] - - - for (const addr of uniqueTestators) { - const wills = await contract.usersWill(addr) - const timeLeft = parseInt(wills.lastPing) + parseInt(wills.deathTimeout) - now; - - willList.push({ - address: addr, - ...wills, - timeLeft, - isDead: timeLeft <= 0 && !wills.executed && !wills.cancelled - }) - } - - // console.log(willList) - // console.log(will[0].lastPing) - - setWills(willList) - setHasWill(isCreated) - setWillInfo(will) - setTotalBalance(totalEther) - setWillsCreated(parsed?.length) - - } catch (error) { - const message = error?.error?.message || error?.message || error; - console.error(message); - toast.error(message) - } - } + // ✅ TWO FLAGS (DO NOT REUSE ONE BOOLEAN) + const [hasActiveWill, setHasActiveWill] = useState(false); + const [hasEverCreatedWill, setHasEverCreatedWill] = useState(false); + + const [totalBalance, setTotalBalance] = useState(0); + const [willsCreated, setWillsCreated] = useState(0); + const [loading, setLoading] = useState(false); + + const { contract, provider, walletAddress, isConnected } = useContract(); + const isFetching = useRef(false); + + const fetchAllWills = useCallback(async () => { + if (!contract || !provider || !walletAddress || isFetching.current) return; - useEffect(() => { - fetchAllWills() - }, [contract, isConnected, walletAddress]) + isFetching.current = true; + setLoading(true); - return {fetchAllWills, wills, willsCreated, totalBalance, hasWill, willInfo} -} + try { + // ---------- EVENTS ---------- + const filterInstance = new Contract(contractAddress, ABI, provider); + const events = await filterInstance.queryFilter(filterInstance.filters.WillCreated(), 0, "latest"); -export default useGetWills \ No newline at end of file + setWillsCreated(events.length); + setHasEverCreatedWill(events.length > 0); + + setTotalBalance( + events.reduce((acc, e) => acc + parseFloat(ethers.utils.formatEther(e.args.balance)), 0) + ); + + // ---------- USER WILL (ACTIVE STATE) ---------- + const userRaw = await contract.usersWill(walletAddress); + const userWill = normalizeWill(userRaw); + + const active = + userWill.balance.gt(0) && + !userWill.executed && + !userWill.cancelled; + + setHasActiveWill(active); + setWillInfo(userWill); + + // ---------- ALL WILLS ---------- + const testators = await contract.getAllTestators(); + const uniqueTestators = [...new Set(testators)]; + const now = Math.floor(Date.now() / 1000); + + const willList = []; + + for (const addr of uniqueTestators) { + try { + const raw = await contract.usersWill(addr); + const will = normalizeWill(raw); + + const timeLeft = will.lastPing + will.deathTimeout - now; + + willList.push({ + address: addr, + balanceEth: ethers.utils.formatEther(will.balance), + beneficiaries: will.beneficiaries, + amounts: will.amounts.map((a) => ethers.utils.formatEther(a)), + executed: will.executed, + cancelled: will.cancelled, + lastPing: will.lastPing, + deathTimeout: will.deathTimeout, + timeLeft, + status: will.executed + ? "EXECUTED" + : will.cancelled + ? "CANCELLED" + : will.balance.gt(0) + ? "ACTIVE" + : "EMPTY", + }); + } catch (e) { + console.error("Skipping broken will:", addr, e); + } + } + + setWills(willList); + console.log("✅ Wills loaded:", willList.length); + } catch (err) { + console.error("❌ Fetch error:", err); + toast.error("Failed to load wills"); + } finally { + setLoading(false); + isFetching.current = false; + } + }, [contract, provider, walletAddress]); + + useEffect(() => { + if (isConnected) fetchAllWills(); + }, [isConnected, fetchAllWills]); + + return { + fetchAllWills, + wills, + willsCreated, + totalBalance, + hasActiveWill, // ✅ + hasEverCreatedWill, // ✅ + willInfo, + loading, + }; +}; + +export default useGetWills; diff --git a/frontend/src/index.css b/frontend/src/index.css index a461c50..81f2d71 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1 +1,10 @@ -@import "tailwindcss"; \ No newline at end of file +@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@500;700&family=Inter:wght@400;600;700&display=swap'); +@import "tailwindcss"; + + + +body { + background-color: #0B1B34; /* navy */ + color: white; + font-family: 'Inter', sans-serif; +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 366a14f..cda1e14 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,9 +1,13 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' +// biome-ignore lint/style/useNodejsImportProtocol: browser polyfill +import { Buffer} from 'buffer' import './index.css' import 'react-toastify/dist/ReactToastify.css'; +window.Buffer = Buffer + ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/frontend/src/pages/DashBoard.jsx b/frontend/src/pages/DashBoard.jsx index 3012fe3..7340cdf 100644 --- a/frontend/src/pages/DashBoard.jsx +++ b/frontend/src/pages/DashBoard.jsx @@ -4,33 +4,76 @@ import useGetWills from "../hooks/useGetWills"; import WillCardHeader from "../components/Card/WillCardHeader"; import WillsCard from "../components/Card/WillsCard"; - const Dashboard = () => { + const { wills, willsCreated, totalBalance, hasWill, willInfo } = useGetWills(); - const {wills, willsCreated, totalBalance, hasWill, willInfo} = useGetWills() + // totalBalance is already formatted as a number from useGetWills + const formattedBalance = totalBalance + ? `${parseFloat(totalBalance).toFixed(4)} ETH` + : "0 ETH"; return ( -
- - - -
- - {wills?.map((will, idx) => ( - 0 ? will.timeLeft : 0} - status={will.executed ? 'Executed' : will.isDead ? 'Dead - Can execute' : 'Alive'} - balance={will.balance} - lastPing={will.lastPing} - deathTimeout={will.deathTimeout} - cancelled={will.cancelled? 'Yes' : 'No'} - /> - ))} - + + {/* Header */} +
+

+ Dashboard Overview +

+

+ Summary of your will, assets, and estate activity. +

+
+ + {/* Cards */} +
+ + + + + +
+ + {/* Wills Section */} + + {wills && wills.length > 0 ? ( + wills.map((will, idx) => ( + + )) + ) : ( +
+

No wills found

+

Create your first will to get started

+
+ )} +
+
); }; -export default Dashboard; +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/utils/contractAddress.js b/frontend/src/utils/contractAddress.js index de5ad98..4c53494 100644 --- a/frontend/src/utils/contractAddress.js +++ b/frontend/src/utils/contractAddress.js @@ -1 +1 @@ -export const contractAddress = '0x2424819Cf2288e4D348BdbC4dDe2837d324B21fF' \ No newline at end of file +export const contractAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..7ff510a --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,17 @@ +export default { + content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: { + colors: { + navy: "#0B1B34", + gold: "#D4AF37", + slate: "#A0A0A0", + }, + fontFamily: { + serif: ["Playfair Display", "serif"], + sans: ["Inter", "sans-serif"], + }, + }, + }, + plugins: [], +}; diff --git a/package.json b/package.json index 044791f..04d6fe4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "test": "test" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "vite", + "build": "vite build", + "preview": "vite preview" }, "keywords": [], "author": "",