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
+
+[](https://stability.nexus)
+
+> **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}
-
+