diff --git a/README.md b/README.md index e5e426a..f2db265 100644 --- a/README.md +++ b/README.md @@ -1 +1,189 @@ -# TNT \ No newline at end of file +# TNT – Trust Network Tokens + +[![Powered by Stability Nexus](https://stability.nexus/logo.png)](https://stability.nexus)TNT Live DApp + +> **Decentralised Trust-as-a-Service.** +> Create, issue **non-transferable ERC-721 trust badges** and manage verifiable relationships on–chain. + +--- + +## Table of Contents + +1. [Demo](#demo) +2. [About](#about) +3. [Key Features](#key-features) +4. [Architecture](#architecture) + * [Smart-Contracts](#smart-contracts) + * [Web Front-end](#web-front-end) +5. [Getting Started](#getting-started) + * [Prerequisites](#prerequisites) + * [Clone & Install](#clone--install) + * [Hardhat Tasks](#hardhat-tasks) + * [Running Tests](#running-tests) + * [Deploying Contracts](#deploying-contracts) + * [Running the Front-end](#running-the-front-end) +6. [Folder Structure](#folder-structure) +7. [Contributing](#contributing) +8. [License](#license) +9. [Screencast](#screencast) + +--- + +## Demo + +The latest version is live at **[tnt.stability.nexus](https://tnt.stability.nexus)** – connect your wallet and start building your trust network in seconds. + +--- + +## About + +**TNT** (Trust Network Tokens) is an open-source framework for issuing *revocable*, *non-transferable* ERC-721 tokens that encode trust relationships between addresses. + +* 100 % on-chain verifiability – each TNT is minted as an ERC-721 with immutable metadata. +* Non-transferable by design – ownership can only be **minted** or **burned**. +* Optional revocation – issuers can invalidate a badge if trust is broken. +* Factory pattern – anyone can deploy a custom TNT collection with a single transaction. +* Built with **Solidity 0.8.20**, **Hardhat**, **Foundry**, **Next.js 15** and **wagmi v2**. + +--- + +## Key Features + +| Category | Feature | +|----------|---------| +| Tokens | Non-transferable ERC-721, per-token issuer map, on-chain timestamp, optional image URL | +| Revocation | Fine-grained `REVOKER_ROLE` lets projects decide who can revoke badges | +| Factory | One-click deployment of new TNT collections (`Factory.createTNT`) | +| Pagination | On-chain pagination helpers for large token sets (`getPageUserTNTs`, `getPageDeployedTNTs`) | +| Front-end | Wallet connect (RainbowKit / wagmi), dark mode, responsive UI, Tailwind CSS | +| DX | Type-safe ABIs generated for React hooks, Hardhat & Foundry parity, GitHub Actions CI | + +--- + +## Architecture + +### Smart Contracts + +Contract | Responsibility | Roles +---------|----------------|------ +`Factory.sol` | Deploy new TNT collections and index relationships | – +`TNT.sol` | ERC-721 implementation enforcing non-transferability and optional revocation | `DEFAULT_ADMIN_ROLE`, `MINTER_ROLE`, `REVOKER_ROLE` + +Contracts are **upgrade-free**: every deployment is immutable. Security best-practices from **OpenZeppelin Contracts v5** are applied. + +### Web Front-end + +* **Next.js (React 18)** – file-system routing, server side rendering & ISR. +* **Tailwind CSS + shadcn/UI** – modern accessible components. +* **wagmi + RainbowKit** – wallet connectivity for EVM chains. +* **ethers v6** – contract interaction & signing. +* Deployed on **Vercel** with preview URLs for every PR. + +--- + +## Getting Started + +### Prerequisites + +* Node ≥ 18 & npm ≥ 9 (or pnpm 8) +* Foundry (`curl -L https://foundry.paradigm.xyz | bash && foundryup`) +* Hardhat (`npm i -g hardhat`) +* A testnet/private key with ETH for gas (for deployment) + +### Clone & Install + +```bash +# clone +$ git clone https://github.com/StabilityNexus/TNT.git +$ cd TNT + +# install root dev-deps (contracts) +$ npm install + +# install web deps +$ cd web && npm install && cd .. +``` + +### Hardhat Tasks + +```bash +# compile solidity +$ npx hardhat compile + +# start local node +$ npx hardhat node +``` + +### Running Tests + +Choose your flavour: + +```bash +# Hardhat + Mocha +$ npx hardhat test + +# Foundry (blazing fast!) +$ forge test -vvv +``` + +### Deploying Contracts + +```bash +# example: deploy a revocable TNT collection to sepolia +$ npx hardhat run scripts/deploy.js --network sepolia +``` + +The script echoes the new contract address; add it to `web/src/contractsABI` to surface it in the UI. + +### Running the Front-end + +```bash +$ cd web +$ cp .sample.env .env.local # configure RPC URL & chain ID +$ npm run dev +``` + +Visit `http://localhost:3000` and connect MetaMask. + +--- + +## Folder Structure + +``` +TNT/ +├── contracts/ # Solidity sources (Foundry layout) +│ ├── src/TNT.sol +│ └── src/Factory.sol +├── test/ # Hardhat JS/TS tests +├── web/ # Next.js 15 dApp +│ ├── src/app # App-router pages +│ ├── src/contractsABI # ABIs auto-generated from /contracts +│ └── tailwind.config.ts +├── .github/workflows # CI – lint, build & test +└── hardhat.config.js # Hardhat config +``` + +--- + +## Contributing + +Pull requests are welcome! +1. Fork the repo & create a feature branch. +2. Commit descriptive messages and add unit tests where possible. +3. Open a PR – CI must be green. + +For larger features please open an Issue first to discuss new ideas. + +--- + +## License + +This repository is licensed under the **Apache 2.0** License. See [LICENSE](LICENSE) for details. + +--- + +## Screencast + +--- + +Made with ⚡ by the **[Stability Nexus](https://stability.nexus)** team. Welcome to the future of trust! diff --git a/hardhat.config.js b/hardhat.config.js index b63f0c2..378525f 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -2,5 +2,18 @@ require("@nomicfoundation/hardhat-toolbox"); /** @type import('hardhat/config').HardhatUserConfig */ module.exports = { - solidity: "0.8.28", + solidity: { + version: "0.8.28", + settings: { + optimizer: { + enabled: true, + runs: 1 // Optimize for deployment size + } + } + }, + networks: { + hardhat: { + allowUnlimitedContractSize: true // Useful for local testing + } + } }; diff --git a/test/TNT.test.js b/test/TNT.test.js index 3d5058f..e815fc6 100644 --- a/test/TNT.test.js +++ b/test/TNT.test.js @@ -19,7 +19,7 @@ describe("TNT and Factory Contracts", function () { TNT = TNTContract; // Deploy revokable TNT - let tx = await factory.createTNT("TestToken", "TTK", true); + let tx = await factory.createTNT("TestToken", "TTK", true,"https://mydomain.com/image.png"); let receipt = await tx.wait(); let event = receipt.logs.find( (log) => 'fragment' in log && log.fragment.name === 'TNTCreated' @@ -28,7 +28,7 @@ describe("TNT and Factory Contracts", function () { tnt = await ethers.getContractAt("TNT", tntAddress); // Deploy non-revokable TNT - tx = await factory.createTNT("NonRevokableToken", "NRT", false); + tx = await factory.createTNT("NonRevokableToken", "NRT", false,"https://mydomain.com/image.png"); receipt = await tx.wait(); event = receipt.logs.find( (log) => 'fragment' in log && log.fragment.name === 'TNTCreated' @@ -76,17 +76,17 @@ describe("TNT and Factory Contracts", function () { const tokenId = 0; await expect( nonRevokableTnt.connect(owner).revokeToken(tokenId) - ).to.be.revertedWith("Token is non-revokable"); + ).to.be.revertedWithCustomError(nonRevokableTnt, "NotRevokable"); }); it("should restrict transfers of tokens", async function () { await tnt.grantMinterRole(addr1.address); await tnt.connect(addr1).issueToken(addr2.address); const tokenId = 0; - + await expect( tnt.connect(addr2).transferFrom(addr2.address, addr1.address, tokenId) - ).to.be.revertedWith("TNTs are non-transferable"); + ).to.be.revertedWithCustomError(tnt, "NonTransferable"); }); it("should allow the admin to grant roles", async function () { @@ -120,4 +120,173 @@ describe("TNT and Factory Contracts", function () { expect(tokenOwner).to.equal(addr2.address); expect(tokenIssuer).to.equal(addr1.address); }); + // Additional tests for TNT contract + it("should return all issued tokens for a user", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + + const [tokenIds, issuers] = await tnt.getAllIssuedTokens(addr2.address); + expect(tokenIds.length).to.equal(1); + expect(issuers[0]).to.equal(addr1.address); + }); + + it("should return active tokens for a user", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + + const [tokenIds, issuers] = await tnt.getActiveTokens(addr2.address); + expect(tokenIds.length).to.equal(1); + expect(await tnt.hasActiveTokens(addr2.address)).to.equal(true); + expect(await tnt.getActiveTokenCount(addr2.address)).to.equal(1); + }); + + it("should allow users to burn their own tokens", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + + const tokenId = 0; + await tnt.connect(addr2).burnToken(tokenId); + expect(await tnt.hasActiveTokens(addr2.address)).to.equal(false); + }); + + it("should return all participants count", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + await tnt.connect(addr1).issueToken(addr1.address); + + expect(await tnt.getAllParticipantsCount()).to.equal(2); + }); + + it("should return recipients in paginated format", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + await tnt.connect(addr1).issueToken(addr1.address); + + const recipients = await tnt.getRecipients(0, 2); + expect(recipients.length).to.equal(2); + }); + + it("should allow admin to update image URL", async function () { + const newImageURL = "https://new-image.com/image.png"; + await tnt.setImageURL(newImageURL); + expect(await tnt.imageURL()).to.equal(newImageURL); + }); + + // Additional tests for Factory contract + it("should return paginated user TNTs", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + + const userTNTs = await factory.getPageUserTNTs(addr2.address, 0, 1); + expect(userTNTs.length).to.equal(1); + }); + + it("should return paginated deployed TNTs", async function () { + const deployedTNTs = await factory.getPageDeployedTNTs(owner.address, 0, 2); + expect(deployedTNTs.length).to.equal(2); + }); + + it("should return correct user TNT count", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + + expect(await factory.getUserTNTCount(addr2.address)).to.equal(1); + }); + + it("should return correct deployed TNT count", async function () { + expect(await factory.getDeployedTNTCount(owner.address)).to.equal(2); + }); + + it("should register issued tokens and update Factory userTNTs mapping", async function () { + // addr1 issues token to addr2 via revokable TNT (created by owner) + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + + const userTNTs = await factory.getUserTNTs(addr2.address); + expect(userTNTs).to.include(tntAddress); + }); + + it("should unregister tokens from Factory after burning", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + const tokenId = 0; + + await tnt.connect(addr2).burnToken(tokenId); + + const userTNTs = await factory.getUserTNTs(addr2.address); + // After burn, the token should be unregistered + expect(userTNTs).to.not.include(tntAddress); + }); + + it("should unregister tokens from Factory after revocation", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.grantRole(await tnt.REVOKER_ROLE(), addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + const tokenId = 0; + + await tnt.connect(addr1).revokeToken(tokenId); + + const userTNTs = await factory.getUserTNTs(addr2.address); + expect(userTNTs).to.not.include(tntAddress); + }); + + it("should revert pagination for user TNTs with invalid indices", async function () { + await expect(factory.getPageUserTNTs(addr1.address, 2, 1)).to.be.revertedWithCustomError(factory, "InvalidIndex"); + await expect(factory.getPageUserTNTs(addr1.address, 10, 11)).to.be.revertedWithCustomError(factory, "InvalidIndex"); + }); + + it("should revert pagination for deployed TNTs with invalid indices", async function () { + await expect(factory.getPageDeployedTNTs(owner.address, 5, 3)).to.be.revertedWithCustomError(factory, "InvalidIndex"); + await expect(factory.getPageDeployedTNTs(owner.address, 100, 101)).to.be.revertedWithCustomError(factory, "InvalidIndex"); + }); + + it("should allow only admin to grant roles and revert on unauthorized attempts", async function () { + await expect(tnt.connect(addr1).grantMinterRole(addr2.address)).to.be.reverted; + await expect(tnt.connect(addr1).grantRevokerRole(addr2.address)).to.be.reverted; + + await tnt.grantMinterRole(addr1.address); + expect(await tnt.hasRole(await tnt.MINTER_ROLE(), addr1.address)).to.equal(true); + }); + + it("should revert revokeToken if caller is not token issuer", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.grantRole(await tnt.REVOKER_ROLE(), addr1.address); // only addr1 has REVOKER_ROLE + await tnt.connect(addr1).issueToken(addr2.address); + + await tnt.grantRole(await tnt.REVOKER_ROLE(), addr2.address); + + await expect( + tnt.connect(addr2).revokeToken(0) + ).to.be.revertedWithCustomError(tnt, "NotIssuer"); + }); + + it("should revert burnToken if caller is not owner", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + + // addr1 tries to burn addr2's token + await expect( + tnt.connect(addr1).burnToken(0) + ).to.be.revertedWithCustomError(tnt, "NotOwner"); + }); + + it("should revert getRecipients with invalid indices", async function () { + await tnt.grantMinterRole(addr1.address); + await tnt.connect(addr1).issueToken(addr2.address); + + await expect(tnt.getRecipients(1, 0)).to.be.revertedWithCustomError(tnt, "InvalidIndex"); + await expect(tnt.getRecipients(100, 101)).to.be.revertedWithCustomError(tnt, "InvalidIndex"); + }); + + it("should revert setImageURL when called by non-admin", async function () { + await expect( + tnt.connect(addr1).setImageURL("https://malicious.com/image.png") + ).to.be.reverted; + }); + + it("should support standard interfaces", async function () { + expect(await tnt.supportsInterface("0x80ac58cd")).to.equal(true); // ERC721 + expect(await tnt.supportsInterface("0x7965db0b")).to.equal(true); // AccessControl + }); + }); diff --git a/web/package.json b/web/package.json index f1c5f5f..2c85e4d 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", "@rainbow-me/rainbowkit": "^2.2.3", + "@tanstack/react-query": "^5.84.1", "axios": "^1.7.9", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/web/src/app/[tnt]/InteractionClient.tsx b/web/src/app/[tnt]/InteractionClient.tsx index 259a7ba..e16ddc6 100644 --- a/web/src/app/[tnt]/InteractionClient.tsx +++ b/web/src/app/[tnt]/InteractionClient.tsx @@ -106,13 +106,13 @@ export default function InteractionClient() { setUserTokens({ activeTokens: [], inactiveTokens: [], totalTokens: 0 }); return; } - + try { setIsLoadingUserTokens(true); const publicClient = getPublicClient(config as any, { chainId }); if (!publicClient) return; - - // Get user's tokens from this specific TNT contract + + // Fetch all tokens and active tokens in parallel const [allTokensData, activeTokensData] = await Promise.all([ publicClient.readContract({ address: tokenAddress, @@ -127,37 +127,47 @@ export default function InteractionClient() { args: [address], }) as Promise<[bigint[], `0x${string}`[]]>, ]); - - const [allTokenIds, allIssuers] = allTokensData; - const [activeTokenIds, activeIssuers] = activeTokensData; - - if (allTokenIds.length > 0) { - const activeTokenNumbers = activeTokenIds.map(id => Number(id)); - const allTokenNumbers = allTokenIds.map(id => Number(id)); - - const activeTokens = activeTokenNumbers.map((tokenId, idx) => ({ - tokenId, - issuer: activeIssuers[idx] || '0x0', - })); - - const inactiveTokens = allTokenNumbers - .filter(tokenId => !activeTokenNumbers.includes(tokenId)) - .map(tokenId => { - const originalIndex = allTokenNumbers.indexOf(tokenId); - return { - tokenId, - issuer: allIssuers[originalIndex] || '0x0', - }; - }); - - setUserTokens({ - activeTokens, - inactiveTokens, - totalTokens: allTokenNumbers.length, - }); - } else { + + const [allTokenIdsRaw, allIssuers] = allTokensData; + const [activeTokenIdsRaw, activeIssuers] = activeTokensData; + + if (allTokenIdsRaw.length === 0) { setUserTokens({ activeTokens: [], inactiveTokens: [], totalTokens: 0 }); + return; + } + + // Convert BigInts to numbers + const allTokenIds = allTokenIdsRaw.map(id => Number(id)); + const activeTokenIds = activeTokenIdsRaw.map(id => Number(id)); + + // Use a set for quick membership check + const activeTokenSet = new Set(activeTokenIds); + + // Map active tokens with issuer + const activeTokens = activeTokenIds.map((tokenId, idx) => ({ + tokenId, + issuer: activeIssuers[idx] || '0x0', + })); + + // Map inactive tokens with issuer, filtering efficiently + const inactiveTokens = []; + const allTokenIdToIndex = new Map(allTokenIds.map((id, i) => [id, i])); + + for (const tokenId of allTokenIds) { + if (!activeTokenSet.has(tokenId)) { + const originalIndex = allTokenIdToIndex.get(tokenId) ?? -1; + inactiveTokens.push({ + tokenId, + issuer: originalIndex !== -1 ? (allIssuers[originalIndex] || '0x0') : '0x0', + }); + } } + + setUserTokens({ + activeTokens, + inactiveTokens, + totalTokens: allTokenIds.length, + }); } catch (error) { console.error("Error fetching user tokens:", error); setUserTokens({ activeTokens: [], inactiveTokens: [], totalTokens: 0 }); @@ -165,6 +175,7 @@ export default function InteractionClient() { setIsLoadingUserTokens(false); } }, [tokenAddress, chainId, address]); + const formatAddress = (address: string) => { return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; diff --git a/web/src/app/create/page.tsx b/web/src/app/create/page.tsx index 86037a3..7112dfc 100644 --- a/web/src/app/create/page.tsx +++ b/web/src/app/create/page.tsx @@ -14,6 +14,7 @@ import { TNTFactoryAbi } from "@/utils/contractsABI/TNTFactory"; import { Info } from "lucide-react"; import { useTheme } from "next-themes"; import { getPublicClient } from "@wagmi/core"; +import React from "react"; interface DeployContractProps { tokenName: string; @@ -46,7 +47,7 @@ const fields = [ }, ]; -export default function CreateTNT() { +const CreateTNT = React.memo(() => { const [formData, setFormData] = useState({ tokenName: "", tokenSymbol: "", @@ -375,4 +376,8 @@ export default function CreateTNT() { ); -} +}); + +CreateTNT.displayName = 'CreateTNT'; + +export default CreateTNT; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 0c04591..8ce3496 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -7,12 +7,18 @@ import { WalletProvider } from "@/hooks/WalletProvider"; import { Toaster } from "react-hot-toast"; import ProtectedRouteProvider from "@/components/ProtectedRouteProvider"; -const inter = Inter({ subsets: ["latin"] }); +const inter = Inter({ + subsets: ["latin"], + display: 'swap', // Optimize font loading +}); export const metadata: Metadata = { title: "TNT - Trust Network Tokens", description: "Issue and manage Trust Network Tokens (TNTs) - the future of decentralized trust", + keywords: ["blockchain", "tokens", "trust", "decentralized", "TNT", "ERC721"], + viewport: "width=device-width, initial-scale=1", + robots: "index, follow", }; export default function RootLayout({ @@ -32,7 +38,17 @@ export default function RootLayout({ {children} - + diff --git a/web/src/app/my-tnts/page.tsx b/web/src/app/my-tnts/page.tsx index b50c9a3..e6d257c 100644 --- a/web/src/app/my-tnts/page.tsx +++ b/web/src/app/my-tnts/page.tsx @@ -12,6 +12,7 @@ import { TNTAbi } from "@/utils/contractsABI/TNT"; import { TNTCacheManager } from "@/utils/indexedDB"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import React from "react"; interface TNTDetails { chainId: string; @@ -28,7 +29,7 @@ interface PaginationInfo { itemsPerPage: number; } -export default function MyTNTsPage() { +const MyTNTsPage = React.memo(() => { const [ownedTNTs, setOwnedTNTs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -48,26 +49,24 @@ export default function MyTNTsPage() { const fetchTotalCount = useCallback(async (): Promise => { try { - let totalCount = 0; - const chainPromises = Object.entries(TNTVaultFactories).map( async ([chainId, factoryAddress]) => { try { const publicClient = getPublicClient(config as any, { chainId: parseInt(chainId), }); - + if (!publicClient || !address) { return 0; } - + const count = (await publicClient.readContract({ address: factoryAddress as `0x${string}`, abi: TNTFactoryAbi, functionName: "getDeployedTNTCount", args: [address as `0x${string}`], })) as bigint; - + return Number(count); } catch (error) { console.error(`Error fetching count for chain ${chainId}:`, error); @@ -75,26 +74,22 @@ export default function MyTNTsPage() { } } ); - const results = await Promise.all(chainPromises); - totalCount = results.reduce((sum, count) => sum + count, 0); - - return totalCount; + return results.reduce((sum, count) => sum + count, 0); } catch (error) { console.error("Error fetching total count:", error); return 0; } }, [address]); - + const fetchPaginatedTNTs = useCallback( async (page: number, forceRefresh: boolean = false) => { try { setIsLoading(true); setError(null); - + if (!address) return; - - // Try to get cached data first (unless force refresh) + if (!forceRefresh) { const cachedResult = await cacheManager.getCachedTNTsPaginated( address, @@ -102,7 +97,7 @@ export default function MyTNTsPage() { page, pagination.itemsPerPage ); - + if (cachedResult) { setOwnedTNTs(cachedResult.data); setPagination((prev) => ({ @@ -115,51 +110,38 @@ export default function MyTNTsPage() { return; } } else { - // Force refresh - invalidate cache first await cacheManager.invalidateCache(address, "owned"); } - - // If no cache or force refresh, fetch from blockchain - const totalCount = await fetchTotalCount(); - const totalPages = Math.ceil(totalCount / pagination.itemsPerPage); - - setPagination((prev) => ({ - ...prev, - currentPage: page, - totalPages, - totalCount, - })); - - if (totalCount === 0) { - setOwnedTNTs([]); - return; - } - - // Fetch all TNTs for caching - let allTNTs: TNTDetails[] = []; - - for (const [chainId, factoryAddress] of Object.entries( - TNTVaultFactories - )) { - try { - const publicClient = getPublicClient(config as any, { - chainId: parseInt(chainId), - }); - - if (!publicClient || !address) { - continue; + + const countsPerChain = await Promise.all( + Object.entries(TNTVaultFactories).map(async ([chainId, factoryAddress]) => { + try { + const publicClient = getPublicClient(config as any, { + chainId: parseInt(chainId), + }); + if (!publicClient || !address) return { chainId, count: 0, factoryAddress, publicClient }; + + const count = (await publicClient.readContract({ + address: factoryAddress as `0x${string}`, + abi: TNTFactoryAbi, + functionName: "getDeployedTNTCount", + args: [address as `0x${string}`], + })) as bigint; + + return { chainId, count: Number(count), factoryAddress, publicClient }; + } catch (error) { + console.error(`Error fetching count for chain ${chainId}:`, error); + return { chainId, count: 0, factoryAddress, publicClient: null }; } - - const chainCount = (await publicClient.readContract({ - address: factoryAddress as `0x${string}`, - abi: TNTFactoryAbi, - functionName: "getDeployedTNTCount", - args: [address as `0x${string}`], - })) as bigint; - - const chainCountNum = Number(chainCount); - - if (chainCountNum > 0) { + }) + ); + + const addressesPerChain = await Promise.all( + countsPerChain.map(async ({ chainId, count, factoryAddress, publicClient }) => { + if (!publicClient || !factoryAddress || count === 0) { + return { chainId, tntAddresses: [] as `0x${string}`[], publicClient }; + } + try { const tntAddresses = (await publicClient.readContract({ address: factoryAddress as `0x${string}`, abi: TNTFactoryAbi, @@ -167,45 +149,47 @@ export default function MyTNTsPage() { args: [ address as `0x${string}`, BigInt(0), - BigInt(chainCountNum), + BigInt(count), ], })) as `0x${string}`[]; - - const chainTNTs = await fetchTNTDetailsForAddresses( - tntAddresses, - chainId, - publicClient - ); - - allTNTs = allTNTs.concat(chainTNTs); + + return { chainId, tntAddresses, publicClient }; + } catch (error) { + console.error(`Error fetching TNT addresses for chain ${chainId}:`, error); + return { chainId, tntAddresses: [] as `0x${string}`[], publicClient }; } - } catch (error) { - console.error(`Error fetching TNTs for chain ${chainId}:`, error); - } - } - - // Cache all TNTs + }) + ); + + const tntDetailsPerChain = await Promise.all( + addressesPerChain.map(({ chainId, tntAddresses, publicClient }) => + fetchTNTDetailsForAddresses(tntAddresses, chainId, publicClient) + ) + ); + + let allTNTs: TNTDetails[] = tntDetailsPerChain.flat(); + if (allTNTs.length > 0) { try { await cacheManager.cacheTNTs(allTNTs, address, "owned"); } catch (cacheError) { - console.warn( - "Failed to cache TNTs, but continuing with display:", - cacheError - ); - // Continue even if caching fails + console.warn("Failed to cache TNTs, but continuing with display:", cacheError); } } - - // Apply pagination to display + + const totalCount = allTNTs.length; + const totalPages = Math.ceil(totalCount / pagination.itemsPerPage); const startIndex = (page - 1) * pagination.itemsPerPage; - const endIndex = Math.min( - startIndex + pagination.itemsPerPage, - allTNTs.length - ); + const endIndex = Math.min(startIndex + pagination.itemsPerPage, totalCount); const paginatedTNTs = allTNTs.slice(startIndex, endIndex); - + setOwnedTNTs(paginatedTNTs); + setPagination((prev) => ({ + ...prev, + currentPage: page, + totalPages, + totalCount, + })); } catch (error) { console.error("Error fetching paginated TNTs:", error); setError("Failed to fetch TNTs. Please try again later."); @@ -215,6 +199,8 @@ export default function MyTNTsPage() { }, [address, pagination.itemsPerPage, fetchTotalCount, cacheManager] ); + + const fetchTNTDetailsForAddresses = async ( tntAddresses: `0x${string}`[], @@ -276,7 +262,6 @@ export default function MyTNTsPage() { const pages = []; const { currentPage, totalPages } = pagination; - // Always show first page if (totalPages > 0) { pages.push(1); } @@ -463,15 +448,19 @@ export default function MyTNTsPage() { width={400} height={180} className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" + loading="lazy" onError={(e) => { // Fall back to a gradient background const target = e.currentTarget; - target.src = - "data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='180' viewBox='0 0 400 180'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%23805ad5;stop-opacity:0.5' /%3E%3Cstop offset='100%25' style='stop-color:%23f6ad55;stop-opacity:0.5' /%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='400' height='180' fill='url(%23grad)' /%3E%3C/svg%3E"; - target.classList.add("object-cover"); + target.style.display = 'none'; + target.nextElementSibling?.classList.remove('hidden'); }} - loading="lazy" /> +
+
+ 🎭 +
+
) : (
@@ -663,4 +652,8 @@ export default function MyTNTsPage() {
); -} +}); + +MyTNTsPage.displayName = 'MyTNTsPage'; + +export default MyTNTsPage; \ No newline at end of file diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index a39c651..292a1c3 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -5,8 +5,9 @@ import { FeaturesSection } from "@/components/LandingPage/features-section"; import { CtaSection } from "@/components/LandingPage/cta-section"; import { Footer } from "@/components/Footer"; import { ScrollAnimationProvider } from "@/components/scroll-animation-provider"; +import React from "react"; -export default function Home() { +const HomePage = React.memo(() => { return (
@@ -21,4 +22,8 @@ export default function Home() {
); -} +}); + +HomePage.displayName = 'HomePage'; + +export default HomePage; diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index aa24216..7afe0cf 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -12,6 +12,7 @@ import { TNTAbi } from "@/utils/contractsABI/TNT"; import { TNTCacheManager } from "@/utils/indexedDB"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import React from "react"; interface TNTDetails { chainId: string; @@ -28,7 +29,20 @@ interface PaginationInfo { itemsPerPage: number; } -export default function ProfilePage() { +const Profile = React.memo(() => { + const { address, isConnected } = useAccount(); + + if (!isConnected) { + return ( +
+
+

Please connect your wallet

+

You need to connect your wallet to view your profile.

+
+
+ ); + } + const [ownedTNTs, setOwnedTNTs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -38,7 +52,6 @@ export default function ProfilePage() { totalCount: 0, itemsPerPage: 6, }); - const { address } = useAccount(); const [mounted, setMounted] = useState(false); const [cacheManager] = useState(() => new TNTCacheManager()); @@ -638,4 +651,8 @@ export default function ProfilePage() { ); -} +}); + +Profile.displayName = 'Profile'; + +export default Profile; diff --git a/web/src/app/token-actions/page.tsx b/web/src/app/token-actions/page.tsx index 61974d3..96d5eba 100644 --- a/web/src/app/token-actions/page.tsx +++ b/web/src/app/token-actions/page.tsx @@ -125,22 +125,21 @@ export default function TokenActionsPage() { const fetchRecipients = useCallback( async (page: number) => { if (!contractAddress || !chainId) return; - + + setIsLoadingRecipients(true); try { - setIsLoadingRecipients(true); const publicClient = getPublicClient(config as any, { chainId }); if (!publicClient) return; - - // Get total participants count + + // 1. Fetch total participants count const totalCount = (await publicClient.readContract({ address: contractAddress, abi: TNTAbi, functionName: "getAllParticipantsCount", })) as bigint; - const count = Number(totalCount); const totalPages = Math.ceil(count / recipientsPagination.itemsPerPage); - + if (count === 0) { setRecipients([]); setRecipientsPagination((prev) => ({ @@ -151,72 +150,64 @@ export default function TokenActionsPage() { })); return; } - - // Calculate start and end indices for pagination + + // 2. Calculate pagination bounds const startIndex = (page - 1) * recipientsPagination.itemsPerPage; - const endIndex = Math.min( - startIndex + recipientsPagination.itemsPerPage, - count - ); - - // Get recipients for current page + const endIndex = Math.min(startIndex + recipientsPagination.itemsPerPage, count); + + // 3. Fetch recipient addresses of this page const recipientAddresses = (await publicClient.readContract({ address: contractAddress, abi: TNTAbi, functionName: "getRecipients", args: [BigInt(startIndex), BigInt(endIndex)], })) as `0x${string}`[]; - - // Get token details for each recipient - const recipientInfoPromises = recipientAddresses.map( - async (recipientAddress) => { - try { - const [allTokensData, activeTokensData] = await Promise.all([ - publicClient.readContract({ - address: contractAddress, - abi: TNTAbi, - functionName: "getAllIssuedTokens", - args: [recipientAddress], - }) as Promise<[bigint[], `0x${string}`[]]>, - publicClient.readContract({ - address: contractAddress, - abi: TNTAbi, - functionName: "getActiveTokens", - args: [recipientAddress], - }) as Promise<[bigint[], `0x${string}`[]]>, - ]); - - const [allTokenIds, allIssuers] = allTokensData; - const [activeTokenIds] = activeTokensData; - - // Convert to numbers for easier comparison - const activeTokenNumbers = activeTokenIds.map((id) => Number(id)); - - return { - address: recipientAddress, - tokenIds: allTokenIds.map((id) => Number(id)), - issuers: allIssuers, - activeTokenIds: activeTokenNumbers, - hasActiveTokens: activeTokenIds.length > 0, - }; - } catch (error) { - console.error( - `Error fetching tokens for ${recipientAddress}:`, - error - ); - return { - address: recipientAddress, - tokenIds: [], - issuers: [], - activeTokenIds: [], - hasActiveTokens: false, - }; - } + + // 4. Fetch each recipient's all tokens and active tokens **in parallel** + const recipientInfoPromises = recipientAddresses.map(async (recipientAddress) => { + try { + // Parallel calls for each recipient + const [allTokensData, activeTokensData] = await Promise.all([ + publicClient.readContract({ + address: contractAddress, + abi: TNTAbi, + functionName: "getAllIssuedTokens", + args: [recipientAddress], + }) as Promise<[bigint[], `0x${string}`[]]>, + publicClient.readContract({ + address: contractAddress, + abi: TNTAbi, + functionName: "getActiveTokens", + args: [recipientAddress], + }) as Promise<[bigint[], `0x${string}`[]]>, + ]); + + const [allTokenIds, allIssuers] = allTokensData; + const [activeTokenIds] = activeTokensData; + + return { + address: recipientAddress, + tokenIds: allTokenIds.map((id) => Number(id)), + issuers: allIssuers, + activeTokenIds: activeTokenIds.map((id) => Number(id)), + hasActiveTokens: activeTokenIds.length > 0, + }; + } catch (error) { + console.error(`Error fetching tokens for ${recipientAddress}:`, error); + return { + address: recipientAddress, + tokenIds: [], + issuers: [], + activeTokenIds: [], + hasActiveTokens: false, + }; } - ); - + }); + + // 5. Await all recipient token info fetches in parallel const recipientInfos = await Promise.all(recipientInfoPromises); - + + // 6. Update state setRecipients(recipientInfos); setRecipientsPagination((prev) => ({ ...prev, @@ -233,6 +224,7 @@ export default function TokenActionsPage() { }, [contractAddress, chainId, recipientsPagination.itemsPerPage] ); + const handleRecipientsPageChange = (page: number) => { if (page >= 1 && page <= recipientsPagination.totalPages) { diff --git a/web/src/components/LandingPage/hero-section.tsx b/web/src/components/LandingPage/hero-section.tsx index 07b9b6b..3fb0b35 100644 --- a/web/src/components/LandingPage/hero-section.tsx +++ b/web/src/components/LandingPage/hero-section.tsx @@ -6,7 +6,9 @@ import { SectionWrapper } from "@/components/ui/section-wrapper"; import Image from "next/image"; import TNT from "@/components/icons/TNT.svg"; import Link from "next/link"; -export function HeroSection() { +import React from "react"; + +export const HeroSection = React.memo(() => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { @@ -94,6 +96,7 @@ export function HeroSection() { width={80} height={80} className="w-16 h-16 md:w-20 md:h-20 object-contain" + priority /> @@ -107,4 +110,6 @@ export function HeroSection() { ); -} +}); + +HeroSection.displayName = 'HeroSection'; diff --git a/web/src/components/Navbar.tsx b/web/src/components/Navbar.tsx index 1171f48..744298f 100644 --- a/web/src/components/Navbar.tsx +++ b/web/src/components/Navbar.tsx @@ -7,6 +7,8 @@ import { Menu, X } from "lucide-react"; import Image from "next/image"; import { ConnectButton } from "@rainbow-me/rainbowkit"; import Link from "next/link"; +import React from "react"; + interface NavLink { name: string; href: string; @@ -18,7 +20,7 @@ const appLinks = [ { name: "Profile", href: "/profile" }, ]; -export function Navbar() { +export const Navbar = React.memo(() => { const [isScrolled, setIsScrolled] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -94,4 +96,6 @@ export function Navbar() { )} ); -} +}); + +Navbar.displayName = 'Navbar'; diff --git a/web/src/components/ProtectedRouteProvider.tsx b/web/src/components/ProtectedRouteProvider.tsx index e0fc27e..b92d7c8 100644 --- a/web/src/components/ProtectedRouteProvider.tsx +++ b/web/src/components/ProtectedRouteProvider.tsx @@ -2,7 +2,7 @@ import { useAccount } from "wagmi"; import { usePathname } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import WalletLockScreen from "@/components/WalletLockScreen"; interface ProtectedRouteProviderProps { @@ -22,15 +22,18 @@ export default function ProtectedRouteProvider({ // Check if current route is public const isPublicRoute = publicRoutes.includes(pathname); - useEffect(() => { - // Add a small delay to prevent flash of loading state + const handleLoadingTimeout = useCallback(() => { const timer = setTimeout(() => { setIsLoading(false); - }, 500); + }, 300); // Reduced from 500ms for better UX return () => clearTimeout(timer); }, []); + useEffect(() => { + return handleLoadingTimeout(); + }, [handleLoadingTimeout]); + // Show loading state briefly to prevent flash if (isLoading) { return ( diff --git a/web/src/components/ui/skeleton.tsx b/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..cace632 --- /dev/null +++ b/web/src/components/ui/skeleton.tsx @@ -0,0 +1,47 @@ +import { cn } from "@/lib/utils"; + +interface SkeletonProps { + className?: string; +} + +export function Skeleton({ className }: SkeletonProps) { + return ( +
+ ); +} + +// Predefined skeleton components +export function CardSkeleton() { + return ( +
+ +
+
+ + +
+ + +
+
+ ); +} + +export function ButtonSkeleton() { + return ; +} + +export function TextSkeleton({ lines = 1 }: { lines?: number }) { + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+ ); +} \ No newline at end of file