diff --git a/.hintrc b/.hintrc new file mode 100644 index 0000000..9024f9c --- /dev/null +++ b/.hintrc @@ -0,0 +1,13 @@ +{ + "extends": [ + "development" + ], + "hints": { + "axe/name-role-value": [ + "default", + { + "button-name": "off" + } + ] + } +} \ No newline at end of file diff --git a/package.json b/package.json index f155ed9..752d896 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ "celo-composer", "celo" ], - "packageManager": "yarn@4.5.1" + "packageManager": "yarn@4.5.1", + "dependencies": { + "@wagmi/core": "^2.18.1" + } } diff --git a/packages/hardhat/contracts/TrustPool.sol b/packages/hardhat/contracts/TrustPool.sol index 10ead45..48078ed 100644 --- a/packages/hardhat/contracts/TrustPool.sol +++ b/packages/hardhat/contracts/TrustPool.sol @@ -68,6 +68,7 @@ contract TrustPool is CFASuperAppBase { function onFlowCreated( ISuperToken token, address sender, + int96 /*flowRate*/, bytes calldata ctx ) internal virtual override returns (bytes memory newCtx) { newCtx = ctx; @@ -77,25 +78,23 @@ contract TrustPool is CFASuperAppBase { function onFlowUpdated( ISuperToken token, address sender, + int96 /*flowRate*/, int96 previousFlowRate, - uint256, + uint256 /*lastUpdated*/, bytes calldata ctx ) internal virtual override returns (bytes memory newCtx) { newCtx = ctx; - // console.log("on flow updated"); newCtx = _updateTrust(token, sender, previousFlowRate, newCtx); } - function onFlowDeleted( + function onInFlowDeleted( ISuperToken token, address sender, - address, int96 previousFlowRate, uint256 /*lastUpdated*/, bytes calldata ctx ) internal virtual override returns (bytes memory newCtx) { newCtx = ctx; - // console.log("on flow deleted %s", sender); newCtx = _updateTrust(token, sender, previousFlowRate, newCtx); } diff --git a/packages/hardhat/test/TrustPoolValidation.ts b/packages/hardhat/test/TrustPoolValidation.ts new file mode 100644 index 0000000..08afaf8 --- /dev/null +++ b/packages/hardhat/test/TrustPoolValidation.ts @@ -0,0 +1,482 @@ +import { deploySuperGoodDollar } from '@gooddollar/goodprotocol'; +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; +import { deployTestFramework } from '@superfluid-finance/ethereum-contracts/dev-scripts/deploy-test-framework'; +import { Framework } from '@superfluid-finance/sdk-core'; +import { expect } from 'chai'; +import { TrustPool } from '../typechain-types'; +import { ethers, network } from 'hardhat'; + +type SignerWithAddress = Awaited>; + +const coder = ethers.utils.defaultAbiCoder; + +describe('TrustPool Validation', () => { + let signer: SignerWithAddress; + let signers: SignerWithAddress[]; + let gdframework: Awaited>; + let sfFramework: any = {}; + let sf: Framework; + const baseFlowRate = BigInt(400e9).toString(); + let pool: TrustPool; + + before(async () => { + signers = await ethers.getSigners(); + const { frameworkDeployer } = await deployTestFramework(); + sfFramework = await frameworkDeployer.getFramework(); + const opts = { + chainId: network.config.chainId || 31337, + provider: ethers.provider as any, + resolverAddress: sfFramework.resolver, + protocolReleaseVersion: 'test', + }; + sf = await Framework.create(opts); + gdframework = await deploySuperGoodDollar(signers[0], sfFramework, [ + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ]); + signer = signers[0]; + }); + + const fixture = async () => { + pool = (await ethers.deployContract('TrustPool', [ + sfFramework['host'], + ])) as TrustPool; + }; + + beforeEach(async function () { + await loadFixture(fixture); + }); + + describe('Quadratic Trust Score Formula', () => { + it('should calculate trust score as (sqrt(prev) - sqrt(oldRate) + sqrt(newRate))^2', async () => { + const recipient = signers[1]; + await pool.addMember(recipient.address, 1); + await pool.addMember(signer.address, 1); + + await gdframework.GoodDollar.mint( + signer.address, + ethers.constants.WeiPerEther + ); + const st = await sf.loadSuperToken( + gdframework.GoodDollar.address.toString() + ); + + const userData = coder.encode( + ['address', 'int96'], + [recipient.address, baseFlowRate] + ); + await ( + await st + .createFlow({ + receiver: pool.address.toString(), + sender: signer.address, + flowRate: baseFlowRate, + userData, + }) + .exec(signer) + ).wait(); + + const trustScoreAfterCreate = await pool.trustScore(recipient.address); + // Score should be (sqrt(0) - sqrt(0) + sqrt(baseFlowRate))^2 = baseFlowRate + // Because (sqrt(newRate))^2 = newRate when starting from 0 + expect(trustScoreAfterCreate).to.equal(baseFlowRate); + }); + + it('should increase trust score quadratically with multiple trusters', async () => { + const recipient = signers[1]; + const truster2 = signers[2]; + + await pool.addMember(recipient.address, 1); + await pool.addMember(signer.address, 1); + await pool.addMember(truster2.address, 1); + + await gdframework.GoodDollar.mint( + signer.address, + ethers.constants.WeiPerEther + ); + await gdframework.GoodDollar.mint( + truster2.address, + ethers.constants.WeiPerEther + ); + + const st = await sf.loadSuperToken( + gdframework.GoodDollar.address.toString() + ); + + const userData = coder.encode( + ['address', 'int96'], + [recipient.address, baseFlowRate] + ); + + // First truster + await ( + await st + .createFlow({ + receiver: pool.address.toString(), + sender: signer.address, + flowRate: baseFlowRate, + userData, + }) + .exec(signer) + ).wait(); + + const scoreAfterOne = await pool.trustScore(recipient.address); + + // Second truster (same rate) + await ( + await st + .createFlow({ + receiver: pool.address.toString(), + sender: truster2.address, + flowRate: baseFlowRate, + userData, + }) + .exec(truster2) + ).wait(); + + const scoreAfterTwo = await pool.trustScore(recipient.address); + + // With quadratic funding: 2 trusters at same rate should give + // (sqrt(baseFlowRate) + sqrt(baseFlowRate))^2 = 4 * baseFlowRate + // which is > 2 * scoreAfterOne + expect(scoreAfterTwo).to.be.gt(scoreAfterOne.mul(2)); + }); + + it('should have trust score of 0 after all trusters remove support', async () => { + const recipient = signers[1]; + await pool.addMember(recipient.address, 1); + await pool.addMember(signer.address, 1); + + await gdframework.GoodDollar.mint( + signer.address, + ethers.constants.WeiPerEther + ); + const st = await sf.loadSuperToken( + gdframework.GoodDollar.address.toString() + ); + + const userData = coder.encode( + ['address', 'int96'], + [recipient.address, baseFlowRate] + ); + await ( + await st + .createFlow({ + receiver: pool.address.toString(), + sender: signer.address, + flowRate: baseFlowRate, + userData, + }) + .exec(signer) + ).wait(); + + // Verify score > 0 + let score = await pool.trustScore(recipient.address); + expect(score).to.be.gt(0); + + // Delete the flow + await ( + await st + .deleteFlow({ + receiver: pool.address.toString(), + sender: signer.address, + }) + .exec(signer) + ).wait(); + + score = await pool.trustScore(recipient.address); + expect(score).to.equal(0); + }); + }); + + describe('Reciprocal Streams', () => { + it('should support bidirectional streams between two users', async () => { + const user1 = signer; + const user2 = signers[1]; + + await pool.addMember(user1.address, 1); + await pool.addMember(user2.address, 1); + + await gdframework.GoodDollar.mint( + user1.address, + ethers.constants.WeiPerEther + ); + await gdframework.GoodDollar.mint( + user2.address, + ethers.constants.WeiPerEther + ); + + const st = await sf.loadSuperToken( + gdframework.GoodDollar.address.toString() + ); + + // User1 supports User2 + const userData1 = coder.encode( + ['address', 'int96'], + [user2.address, baseFlowRate] + ); + await expect( + st + .createFlow({ + receiver: pool.address.toString(), + sender: user1.address, + flowRate: baseFlowRate, + userData: userData1, + }) + .exec(user1) + ).not.reverted; + + // User2 supports User1 + const userData2 = coder.encode( + ['address', 'int96'], + [user1.address, baseFlowRate] + ); + await expect( + st + .createFlow({ + receiver: pool.address.toString(), + sender: user2.address, + flowRate: baseFlowRate, + userData: userData2, + }) + .exec(user2) + ).not.reverted; + + // Both should have trust scores > 0 + const score1 = await pool.trustScore(user1.address); + const score2 = await pool.trustScore(user2.address); + + expect(score1).to.be.gt(0); + expect(score2).to.be.gt(0); + + // Both should have incoming flows + const flow1 = await st.getFlow({ + sender: pool.address, + receiver: user1.address, + providerOrSigner: ethers.provider, + }); + const flow2 = await st.getFlow({ + sender: pool.address, + receiver: user2.address, + providerOrSigner: ethers.provider, + }); + + expect(Number(flow1.flowRate)).to.be.gt(0); + expect(Number(flow2.flowRate)).to.be.gt(0); + }); + }); + + describe('Edge Cases', () => { + it('should reject stream to self', async () => { + await pool.addMember(signer.address, 1); + await gdframework.GoodDollar.mint( + signer.address, + ethers.constants.WeiPerEther + ); + + const st = await sf.loadSuperToken( + gdframework.GoodDollar.address.toString() + ); + + const userData = coder.encode( + ['address', 'int96'], + [signer.address, baseFlowRate] + ); + + // Streaming to yourself through the pool should still work at the contract level + // but it's a valid edge case to test + const tx = st + .createFlow({ + receiver: pool.address.toString(), + sender: signer.address, + flowRate: baseFlowRate, + userData, + }) + .exec(signer); + + // This should work if both are on same ID system + await expect(tx).not.reverted; + }); + + it('should allow re-creating stream after deletion', async () => { + const recipient = signers[1]; + await pool.addMember(recipient.address, 1); + await pool.addMember(signer.address, 1); + await gdframework.GoodDollar.mint( + signer.address, + ethers.constants.WeiPerEther + ); + + const st = await sf.loadSuperToken( + gdframework.GoodDollar.address.toString() + ); + + const userData = coder.encode( + ['address', 'int96'], + [recipient.address, baseFlowRate] + ); + + // Create + await ( + await st + .createFlow({ + receiver: pool.address.toString(), + sender: signer.address, + flowRate: baseFlowRate, + userData, + }) + .exec(signer) + ).wait(); + + // Delete + await ( + await st + .deleteFlow({ + receiver: pool.address.toString(), + sender: signer.address, + }) + .exec(signer) + ).wait(); + + // Verify flow is 0 + const flowAfterDelete = await st.getFlow({ + sender: pool.address, + receiver: recipient.address, + providerOrSigner: ethers.provider, + }); + expect(Number(flowAfterDelete.flowRate)).to.equal(0); + + // Re-create + await expect( + st + .createFlow({ + receiver: pool.address.toString(), + sender: signer.address, + flowRate: baseFlowRate, + userData, + }) + .exec(signer) + ).not.reverted; + + // Verify flow is back + const flowAfterRecreate = await st.getFlow({ + sender: pool.address, + receiver: recipient.address, + providerOrSigner: ethers.provider, + }); + expect(Number(flowAfterRecreate.flowRate)).to.equal(Number(baseFlowRate)); + }); + + it('should reject zero flow rate update', async () => { + const recipient = signers[1]; + await pool.addMember(recipient.address, 1); + await pool.addMember(signer.address, 1); + await gdframework.GoodDollar.mint( + signer.address, + ethers.constants.WeiPerEther + ); + + const st = await sf.loadSuperToken( + gdframework.GoodDollar.address.toString() + ); + + const userData = coder.encode( + ['address', 'int96'], + [recipient.address, baseFlowRate] + ); + + await ( + await st + .createFlow({ + receiver: pool.address.toString(), + sender: signer.address, + flowRate: baseFlowRate, + userData, + }) + .exec(signer) + ).wait(); + + // Updating with same flow rate should revert with NO_FLOW_CHANGE + const sameUserData = coder.encode( + ['address', 'int96'], + [recipient.address, baseFlowRate] + ); + const tx = st + .updateFlow({ + receiver: pool.address.toString(), + sender: signer.address, + flowRate: baseFlowRate, + userData: sameUserData, + }) + .exec(signer); + + await expect(tx).reverted; + }); + }); + + describe('Identity Validation', () => { + it('should return None for getMutualId when members have no identity', async () => { + const user1 = signers[3]; + const user2 = signers[4]; + + const mutualId = await pool.getMutualId(user1.address, user2.address); + expect(mutualId).to.equal(0); // IdType.None + }); + + it('should return GoodID when both members have GoodID', async () => { + const user1 = signers[3]; + const user2 = signers[4]; + + await pool.addMember(user1.address, 1); // GoodID + await pool.addMember(user2.address, 1); // GoodID + + const mutualId = await pool.getMutualId(user1.address, user2.address); + expect(mutualId).to.equal(1); // IdType.GoodID + }); + + it('should return WorldCoin when both have WorldCoin', async () => { + const user1 = signers[3]; + const user2 = signers[4]; + + await pool.addMember(user1.address, 2); // WorldCoin + await pool.addMember(user2.address, 2); // WorldCoin + + const mutualId = await pool.getMutualId(user1.address, user2.address); + expect(mutualId).to.equal(2); // IdType.WorldCoin + }); + + it('should return None when members have different id types', async () => { + const user1 = signers[3]; + const user2 = signers[4]; + + await pool.addMember(user1.address, 1); // GoodID + await pool.addMember(user2.address, 2); // WorldCoin + + const mutualId = await pool.getMutualId(user1.address, user2.address); + expect(mutualId).to.equal(0); // IdType.None - different systems + }); + + it('should prioritize GoodID over other id types', async () => { + const user1 = signers[3]; + const user2 = signers[4]; + + // Both have GoodID AND WorldCoin + await pool.addMember(user1.address, 1); // GoodID + await pool.addMember(user1.address, 2); // WorldCoin + await pool.addMember(user2.address, 1); // GoodID + await pool.addMember(user2.address, 2); // WorldCoin + + const mutualId = await pool.getMutualId(user1.address, user2.address); + expect(mutualId).to.equal(1); // GoodID has priority + }); + + it('should only allow manager to addMember', async () => { + const nonManager = signers[5]; + const user = signers[6]; + + await expect( + pool.connect(nonManager).addMember(user.address, 1) + ).reverted; + }); + }); +}); diff --git a/packages/react-app/index.html b/packages/react-app/index.html index e0e8e66..394495c 100644 --- a/packages/react-app/index.html +++ b/packages/react-app/index.html @@ -4,7 +4,7 @@ - Trust squared + Trust2
diff --git a/packages/react-app/package.json b/packages/react-app/package.json index e479da9..de8dbf7 100644 --- a/packages/react-app/package.json +++ b/packages/react-app/package.json @@ -19,8 +19,11 @@ "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", + "@reown/appkit": "^1.8.17", + "@reown/appkit-adapter-wagmi": "^1.8.17", "@tanstack/react-query": "^5.60.5", "@wagmi/connectors": "^5.4.0", + "@wagmi/core": "^2.18.1", "@yudiel/react-qr-scanner": "^2.0.8", "algosdk": "^3.0.0", "class-variance-authority": "^0.7.0", @@ -45,13 +48,13 @@ "@types/react-blockies": "^1.4.4", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", - "autoprefixer": "^10.4.20", + "autoprefixer": "^10.4.21", "eslint": "^9.13.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.15", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", "vite": "^5.4.10" diff --git a/packages/react-app/public/logo1.svg b/packages/react-app/public/logo1.svg index 7b4f3ec..ff6115d 100644 --- a/packages/react-app/public/logo1.svg +++ b/packages/react-app/public/logo1.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/react-app/public/logo2.svg b/packages/react-app/public/logo2.svg index 623d666..3a7017d 100644 --- a/packages/react-app/public/logo2.svg +++ b/packages/react-app/public/logo2.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/react-app/src/App.tsx b/packages/react-app/src/App.tsx index 6b451ab..dec3887 100644 --- a/packages/react-app/src/App.tsx +++ b/packages/react-app/src/App.tsx @@ -1,83 +1,58 @@ +import { useRef } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import { useAccount } from "wagmi"; import "./App.css"; import BottomNavbar from "./components/BottomNavbar"; -import Navbar from "./components/Navbar"; -import Trustees from "./screens/Trustees"; import Home from "./screens/Home"; -import Layout from "./screens/Layout"; import Login from "./screens/Login"; +import Dashborad from "./screens/Dashborad"; +import Profile from "./screens/Profile"; +import Explore from "./screens/Explore"; +import SupportStreams from "./screens/SupportStreams"; import { QrScan } from "./screens/TrustAction"; -import Trusters from "./screens/Trusters"; -import { useIsLoggedIn } from "@dynamic-labs/sdk-react-core"; +import StopSupport from "./screens/StopSupport"; +import StreamDetails from "./screens/StreamDetails"; +import Verify from "./screens/Verify"; +import ClaimGD from "./screens/ClaimGD"; function App() { - const {isConnected} = useAccount() - // const { sdkHasLoaded } = useDynamicContext(); - const isLoggedIn = useIsLoggedIn(); - // const { isConnected, address } = useAccount(); - // console.log({isLoggedIn}, {sdkHasLoaded}, { isConnected }, { address }); + const { isConnected } = useAccount(); + const wasConnected = useRef(false); + const connectCount = useRef(0); + + if (!isConnected) { + if (wasConnected.current) { + connectCount.current += 1; + wasConnected.current = false; + } + return ; + } + wasConnected.current = true; + return ( - - {!isLoggedIn && !isConnected ? ( - - ) : ( + - {/* Other routes with navbars */} - - {/* Add your other routes here */} - - - - } - /> - - - - } - /> - - - - } - /> - {/* - - - } - /> */} - - - - } - /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> - )} ); } diff --git a/packages/react-app/src/components/BottomNavbar.tsx b/packages/react-app/src/components/BottomNavbar.tsx index 647019b..8c83cb9 100644 --- a/packages/react-app/src/components/BottomNavbar.tsx +++ b/packages/react-app/src/components/BottomNavbar.tsx @@ -1,97 +1,56 @@ -import { CiHome as IconHome, CiUser as IconTrusters } from "react-icons/ci"; -import { - FaHandHoldingUsd as IconTrustees, - FaChartArea as IconHistory, -} from "react-icons/fa"; -import { Link } from "react-router-dom"; - -const navbarItems = { - home: { - icon: , - label: "Home", - route: "/", - }, - profile: { - icon: , - label: "Profile", - route: "/profile", - }, - history: { - icon: , - label: "History", - route: "/history", - }, - trustees: { - icon: , - label: "Trustees", - route: "/trustees", - }, -}; +import { Home, Compass, BarChart3, User, Plus } from "lucide-react"; +import { Link, useLocation } from "react-router-dom"; export default function BottomNavbar() { - return ( -
-
-
- -
- - Supporters -
- + const location = useLocation(); - - profile - + const isActive = (route: string) => { + if (route === "/") return location.pathname === "/"; + return location.pathname.startsWith(route); + }; - -
- - Trustees -
- {/* - $200 */} - + const navItems = [ + { to: "/", icon: Home, label: "Home" }, + { to: "/explore", icon: Compass, label: "Explore" }, + { to: "/streams", icon: BarChart3, label: "Streams" }, + { to: "/profile", icon: User, label: "Profile" }, + ]; + + return ( + <> + {/* FAB -- Support button */} + + + - {/* {Object.entries(navbarItems).map(([key, item]) => ( + {/* Bottom Nav */} +
+
+ {navItems.map(({ to, icon: Icon, label }) => ( - {item.icon} - {item.label} +
+ +
+ {label} - ))} */} + ))}
- {/* */}
-
+ ); } diff --git a/packages/react-app/src/components/ErrorState.tsx b/packages/react-app/src/components/ErrorState.tsx new file mode 100644 index 0000000..4db4130 --- /dev/null +++ b/packages/react-app/src/components/ErrorState.tsx @@ -0,0 +1,32 @@ +import { AlertTriangle, RefreshCw } from "lucide-react"; + +interface ErrorStateProps { + title?: string; + message?: string; + onRetry?: () => void; +} + +export default function ErrorState({ + title = "Something went wrong", + message = "We couldn't load this data. Please try again.", + onRetry, +}: ErrorStateProps) { + return ( +
+
+ +
+

{title}

+

{message}

+ {onRetry && ( + + )} +
+ ); +} diff --git a/packages/react-app/src/components/TrustAccount.tsx b/packages/react-app/src/components/TrustAccount.tsx index 2ecc086..5edd084 100644 --- a/packages/react-app/src/components/TrustAccount.tsx +++ b/packages/react-app/src/components/TrustAccount.tsx @@ -1,5 +1,4 @@ import { getAddressLink } from "@/utils"; -import { useMemo } from "react"; import Blockies from "react-blockies"; import { Link } from "react-router-dom"; @@ -7,18 +6,17 @@ interface ProfileCardProps { address: string; name?: string; } -// export const randomWalletAddress = () => { -// const chars = "0123456789abcdef"; -// let address = "0x"; -// for (let i = 0; i < 40; i++) { -// address += chars[Math.floor(Math.random() * chars.length)]; -// } -// return address; -// }; + +// Helper function to truncate address +function truncateAddress(address: string): string { + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} export default function TrustAccount({ address, name = "" }: ProfileCardProps) { + const displayName = name?.trim() || truncateAddress(address); + return ( -
+
{/* Profile Picture */} - {name || truncateAddress(address)} + {displayName} {truncateAddress(address)} @@ -44,9 +41,4 @@ export default function TrustAccount({ address, name = "" }: ProfileCardProps) {
); -} - -// Helper function to truncate address -function truncateAddress(address: string): string { - return `${address.slice(0, 6)}...${address.slice(-4)}`; -} +} \ No newline at end of file diff --git a/packages/react-app/src/components/VerificationBanner.tsx b/packages/react-app/src/components/VerificationBanner.tsx new file mode 100644 index 0000000..3e3ade4 --- /dev/null +++ b/packages/react-app/src/components/VerificationBanner.tsx @@ -0,0 +1,51 @@ +import { useVerifiedIdentities } from "@/hooks/useVerifiedIdentities"; +import { useAccount } from "wagmi"; +import { ShieldAlert, BadgeCheck, ChevronRight } from "lucide-react"; +import { Link } from "react-router-dom"; + +export default function VerificationBanner() { + const { address } = useAccount(); + const identities = useVerifiedIdentities(address); + + const hasAnyIdentity = Object.entries(identities || {}).some(([, v]) => v); + + if (hasAnyIdentity) return null; + + return ( + +
+ +
+
+

+ Verify Your Identity +

+

+ Get verified to start streaming and build your Trust Score +

+
+ + + ); +} + +export function VerifiedBadge({ + identities, +}: { + identities: Record | undefined; +}) { + const verifiedIds = Object.entries(identities || {}).filter(([, v]) => v); + if (verifiedIds.length === 0) return null; + + return ( +
+ + + {verifiedIds.map(([k]) => k).join(" + ")} + +
+ ); +} diff --git a/packages/react-app/src/env.ts b/packages/react-app/src/env.ts index 4898f34..5db8e3b 100644 --- a/packages/react-app/src/env.ts +++ b/packages/react-app/src/env.ts @@ -1,4 +1,4 @@ -export const POOL_CONTRACT = "0x559Fc954873E175Ad8e0334cad4b80CB6D9f1A99"//import.meta.env.POOL_CONTRACT +export const POOL_CONTRACT = "0xe7891De4005Ac5A2B6Cf3982C27c75A5B0F9b0FC"//import.meta.env.POOL_CONTRACT export const SF_FORWARDER = "0xcfA132E353cB4E398080B9700609bb008eceB125" export const GOODDOLLAR = "0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A" export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; diff --git a/packages/react-app/src/hooks/queries/useGenericQuery.ts b/packages/react-app/src/hooks/queries/useGenericQuery.ts index 7c44a4b..0777912 100644 --- a/packages/react-app/src/hooks/queries/useGenericQuery.ts +++ b/packages/react-app/src/hooks/queries/useGenericQuery.ts @@ -2,7 +2,8 @@ import { useQuery } from "@tanstack/react-query"; export const useGenericQuery = ( queryKey: string[], - queryFn: () => Promise + queryFn: () => Promise, + enabled: boolean = true ) => { const { data, @@ -12,6 +13,7 @@ export const useGenericQuery = ( } = useQuery({ queryKey: queryKey, queryFn: queryFn, + enabled: enabled, }); const refetch = async () => { diff --git a/packages/react-app/src/hooks/queries/useGetMember.tsx b/packages/react-app/src/hooks/queries/useGetMember.tsx index 2874011..ac91051 100644 --- a/packages/react-app/src/hooks/queries/useGetMember.tsx +++ b/packages/react-app/src/hooks/queries/useGetMember.tsx @@ -3,7 +3,7 @@ import { useGenericQuery } from "./useGenericQuery"; const fetchTrusteesData = async (memberId: string) => { return fetch( - "https://api.studio.thegraph.com/query/59211/trustsquared/version/latest", + "https://api.studio.thegraph.com/query/1742484/trustsquared/v2.0.0", { method: "POST", headers: { @@ -28,7 +28,7 @@ const fetchTrusteesData = async (memberId: string) => { const fetchMemberData = async (memberId: string) => { return fetch( - "https://api.studio.thegraph.com/query/59211/trustsquared/version/latest", + "https://api.studio.thegraph.com/query/1742484/trustsquared/v2.0.0", { method: "POST", headers: { @@ -52,7 +52,7 @@ const fetchMemberData = async (memberId: string) => { const fetchTrustersData = async (memberId: string) => { return fetch( - "https://api.studio.thegraph.com/query/59211/trustsquared/version/latest", + "https://api.studio.thegraph.com/query/1742484/trustsquared/v2.0.0", { method: "POST", headers: { @@ -88,8 +88,7 @@ export const useGetMemberTrusters = (memberId: string) => { }; export const useGetMember = (memberId: string) => { - const queryKey = ["member", memberId]; const queryFn = () => fetchMemberData(memberId); - return useGenericQuery(queryKey, queryFn); + return useGenericQuery(queryKey, queryFn, !!memberId && memberId.length > 0); }; diff --git a/packages/react-app/src/hooks/useBalanceStream.ts b/packages/react-app/src/hooks/useBalanceStream.ts index b349bf1..45bc2df 100644 --- a/packages/react-app/src/hooks/useBalanceStream.ts +++ b/packages/react-app/src/hooks/useBalanceStream.ts @@ -1,26 +1,32 @@ import { GOODDOLLAR } from "@/env"; import { useState, useEffect } from "react"; -import { useBalance } from "wagmi"; +import { useReadContract } from "wagmi"; +import { erc20Abi } from "viem"; const ANIMATION_MINIMUM_STEP_TIME = 100; export const useBalanceStream = ( account: string | undefined, flowRate: bigint ) => { - const gdBalance = useBalance({ - address: account as any, - token: GOODDOLLAR, - query: { refetchInterval: 60000 }, + const gdBalance = useReadContract({ + address: GOODDOLLAR as `0x${string}`, + abi: erc20Abi, + functionName: "balanceOf", + args: account ? [account as `0x${string}`] : undefined, + query: { + enabled: !!account, + refetchInterval: 60000, + }, }); - const [balance, setBalance] = useState( - gdBalance?.data?.value - ); + const balanceValue = gdBalance.data as bigint | undefined; + + const [balance, setBalance] = useState(balanceValue); const [startTime, setStartTime] = useState(0); useEffect(() => { setStartTime(Date.now()); - }, [gdBalance.data?.value]); + }, [balanceValue]); useEffect(() => { let stopAnimation = false; @@ -31,16 +37,13 @@ export const useBalanceStream = ( return; } if ( - gdBalance.data && + balanceValue !== undefined && currentAnimationTimestamp - lastAnimationTimestamp > ANIMATION_MINIMUM_STEP_TIME ) { - const currentTimestampBigNumber = BigInt( - new Date().valueOf() // Milliseconds elapsed since UTC epoch, disregards timezone. - ); const timePassed = BigInt(Date.now() - startTime) / 1000n; const update = BigInt(flowRate || 0) * timePassed; - setBalance(gdBalance.data.value + update); + setBalance(balanceValue + update); lastAnimationTimestamp = currentAnimationTimestamp; } @@ -52,7 +55,9 @@ export const useBalanceStream = ( return () => { stopAnimation = true; }; - }, [gdBalance, startTime]); + }, [balanceValue, startTime, flowRate]); - return balance ? (Number(balance) / 1e18).toLocaleString() : undefined; + return balance !== undefined + ? (Number(balance) / 1e18).toLocaleString() + : undefined; }; diff --git a/packages/react-app/src/hooks/useVerifiedIdentities.ts b/packages/react-app/src/hooks/useVerifiedIdentities.ts index 198b6b1..c38c6c6 100644 --- a/packages/react-app/src/hooks/useVerifiedIdentities.ts +++ b/packages/react-app/src/hooks/useVerifiedIdentities.ts @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { multicall } from '@wagmi/core' -import { config } from '../providers/dynamicProvider' +import { config } from '../providers/reownProvider' import { POOL_CONTRACT } from "@/env"; import { abi } from '../abis/TrustPool' @@ -39,6 +39,8 @@ export const useVerifiedIdentities = (account: `0x${string}` | undefined) => { }).then(result => { const ids = { GoodID: !!result[0].result, WorldID: !!result[1].result, NoundsDAO: !!result[2].result, BrightID: !!result[3].result } setIdentities(ids) + }).catch(() => { + setIdentities({ GoodID: false, WorldID: false, NoundsDAO: false, BrightID: false }) }) }, [account]) diff --git a/packages/react-app/src/index.css b/packages/react-app/src/index.css index 1070b8e..666b1f9 100644 --- a/packages/react-app/src/index.css +++ b/packages/react-app/src/index.css @@ -3,16 +3,18 @@ @tailwind utilities; #root { - background-color: #F1FFF3; + background: linear-gradient(180deg, #0a1a0a 0%, #0d1210 50%, #0a1a0a 100%); + color: #f0f0f0ff; + min-height: 100vh; } /* ... */ @layer base { :root { - --background: 0 0% 100%; + --background: 130 30% 5%; --foreground: 240 10% 3.9%; - --card: 0 0% 100%; + --card: 130 20% 8%; --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; @@ -38,24 +40,24 @@ } .dark { - --background: 20 14.3% 4.1%; + --background: 130 30% 5%; --foreground: 0 0% 95%; - --card: 24 9.8% 10%; + --card: 130 20% 8%; --card-foreground: 0 0% 95%; - --popover: 0 0% 9%; + --popover: 130 20% 6%; --popover-foreground: 0 0% 95%; --primary: 142.1 70.6% 45.3%; --primary-foreground: 144.9 80.4% 10%; - --secondary: 240 3.7% 15.9%; + --secondary: 130 10% 14%; --secondary-foreground: 0 0% 98%; - --muted: 0 0% 15%; - --muted-foreground: 240 5% 64.9%; - --accent: 12 6.5% 15.1%; + --muted: 130 10% 12%; + --muted-foreground: 130 5% 55%; + --accent: 130 10% 14%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 85.7% 97.3%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; + --border: 130 15% 15%; + --input: 130 15% 15%; --ring: 142.4 71.8% 29.2%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; @@ -70,6 +72,6 @@ @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-t2-dark text-foreground; } } diff --git a/packages/react-app/src/main.tsx b/packages/react-app/src/main.tsx index 113bbc2..3d158a0 100644 --- a/packages/react-app/src/main.tsx +++ b/packages/react-app/src/main.tsx @@ -12,3 +12,4 @@ createRoot(document.getElementById("root")!).render( ); + diff --git a/packages/react-app/src/providers/dynamic.tsx b/packages/react-app/src/providers/dynamic.tsx index a7f3788..0413923 100644 --- a/packages/react-app/src/providers/dynamic.tsx +++ b/packages/react-app/src/providers/dynamic.tsx @@ -1,6 +1,6 @@ import React from "react"; import MiniPayProvider from "./minipayProvider"; -import DynamicProvider from "./dynamicProvider"; +import ReownProvider from "./reownProvider"; import { QueryClient } from "@tanstack/react-query"; export const queryClient = new QueryClient(); @@ -12,25 +12,17 @@ export default function WalletProvider({ }) { const isMiniPay = () => { if (window && window.ethereum) { - // User has a injected wallet - - // @ts-expect-error + // @ts-expect-error MiniPay detection if (window.ethereum.isMiniPay) { - console.log("MiniPay detected"); return true; } } - console.log("MiniPay not detected"); return false; }; - console.log("isMiniPay", isMiniPay()); - - - return isMiniPay() ? ( {children} ) : ( - {children} + {children} ); } diff --git a/packages/react-app/src/providers/minipayProvider.tsx b/packages/react-app/src/providers/minipayProvider.tsx index 162996e..681e9cb 100644 --- a/packages/react-app/src/providers/minipayProvider.tsx +++ b/packages/react-app/src/providers/minipayProvider.tsx @@ -1,11 +1,9 @@ -import { createConfig, useConnect, useAccount, WagmiProvider } from "wagmi"; +import { createConfig, WagmiProvider } from "wagmi"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { injected } from "@wagmi/connectors"; import { celo } from "viem/chains"; import { http } from "viem"; -import { DynamicContextProvider } from "@dynamic-labs/sdk-react-core"; -// Create config const config = createConfig({ chains: [celo], connectors: [injected()], @@ -22,43 +20,10 @@ export default function MiniPayProvider({ queryClient: QueryClient; }) { return ( - - - - {/* */} - {children} - - - - ); -} - -function MiniPayWidget() { - const { connect } = useConnect(); - const { address, isConnected } = useAccount(); - - const onConnect = async () => { - try { - await connect({ - connector: injected(), - }); - } catch (error) { - console.error("Error connecting:", error); - } - }; - - return ( -
- {isConnected ? ( -
- ) : ( - - )} -
+ + + {children} + + ); } diff --git a/packages/react-app/src/providers/reownProvider.tsx b/packages/react-app/src/providers/reownProvider.tsx new file mode 100644 index 0000000..97851bc --- /dev/null +++ b/packages/react-app/src/providers/reownProvider.tsx @@ -0,0 +1,51 @@ +import { createAppKit } from "@reown/appkit/react"; +import { WagmiAdapter } from "@reown/appkit-adapter-wagmi"; +import { celo } from "@reown/appkit/networks"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WagmiProvider } from "wagmi"; + +// Reown Cloud project ID - get one at https://cloud.reown.com +const projectId = import.meta.env.VITE_REOWN_PROJECT_ID || "YOUR_PROJECT_ID"; + +const metadata = { + name: "Trust²", + description: "P2P community trust and funding with G$ streams", + url: window.location.origin, + icons: ["/logo2.svg"], +}; + +const wagmiAdapter = new WagmiAdapter({ + projectId, + networks: [celo], +}); + +createAppKit({ + adapters: [wagmiAdapter], + networks: [celo], + projectId, + metadata, + features: { + analytics: false, + }, + themeMode: "dark", + themeVariables: { + "--w3m-accent": "#16a34a", + "--w3m-border-radius-master": "2px", + }, +}); + +export const config = wagmiAdapter.wagmiConfig; + +export default function ReownProvider({ + children, + queryClient, +}: { + children: React.ReactNode; + queryClient: QueryClient; +}) { + return ( + + {children} + + ); +} diff --git a/packages/react-app/src/screens/ClaimGD.tsx b/packages/react-app/src/screens/ClaimGD.tsx new file mode 100644 index 0000000..328413b --- /dev/null +++ b/packages/react-app/src/screens/ClaimGD.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { + useAccount, + useReadContract, + useWriteContract, +} from "wagmi"; +import { useNavigate } from "react-router-dom"; +import { GOODDOLLAR } from "@/env"; +import { + ArrowLeft, + Gift, + Clock, + Loader2, + CheckCircle2, + Coins, +} from "lucide-react"; +import { formatUnits } from "viem"; + +const UBI_CONTRACT = "0x43d72Ff17701B2DA814620735C39C620Ce0ea4A1" as const; + +const UBI_ABI = [ + { + name: "checkEntitlement", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + name: "claim", + type: "function", + stateMutability: "nonpayable", + inputs: [], + outputs: [{ name: "", type: "bool" }], + }, +] as const; + +const ERC20_BALANCE_ABI = [ + { + name: "balanceOf", + type: "function", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +const isMiniPay = !!( + window?.ethereum && + "isMiniPay" in window.ethereum && + (window.ethereum as Record).isMiniPay +); +const gasOpts = isMiniPay + ? {} + : { + maxFeePerGas: BigInt(25.1e9), + maxPriorityFeePerGas: BigInt(1e8), + }; + +type ClaimStep = "idle" | "claiming" | "success" | "error"; + +export default function ClaimGD() { + const navigate = useNavigate(); + const { address } = useAccount(); + const { writeContractAsync } = useWriteContract(); + const [step, setStep] = useState("idle"); + + const { + data: entitlement, + isLoading: entitlementLoading, + refetch: refetchEntitlement, + } = useReadContract({ + address: UBI_CONTRACT, + abi: UBI_ABI, + functionName: "checkEntitlement", + query: { enabled: !!address }, + }); + + const { + data: gdBalance, + refetch: refetchBalance, + } = useReadContract({ + address: GOODDOLLAR as `0x${string}`, + abi: ERC20_BALANCE_ABI, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + const claimableAmount = entitlement ? BigInt(entitlement) : 0n; + const canClaim = claimableAmount > 0n; + const formattedClaimable = claimableAmount > 0n + ? Number(formatUnits(claimableAmount, 18)).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : "0.00"; + + const formattedBalance = gdBalance + ? Number(formatUnits(BigInt(gdBalance as bigint), 18)).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : "0.00"; + + const handleClaim = async () => { + if (!address || !canClaim) return; + setStep("claiming"); + try { + await writeContractAsync({ + ...gasOpts, + address: UBI_CONTRACT, + abi: UBI_ABI, + functionName: "claim", + }); + setStep("success"); + refetchEntitlement(); + refetchBalance(); + } catch { + setStep("error"); + setTimeout(() => setStep("idle"), 2000); + } + }; + + if (step === "claiming") { + return ( +
+ +

Claiming G$

+

Confirm in your wallet...

+
+ ); + } + + if (step === "success") { + return ( +
+ +

G$ Claimed!

+

+ +{formattedClaimable} G$ +

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ +

Claim G$

+
+ +
+ {/* Balance */} +
+
+ +
+

+ Your G$ Balance +

+

+ {formattedBalance} + G$ +

+
+ + {/* Claim Card */} +
+ {entitlementLoading ? ( +
+ +

Checking claim status...

+
+ ) : canClaim ? ( +
+
+ + + UBI Available! + +
+

+ {formattedClaimable} + G$ +

+ +
+ ) : ( +
+ +

Already Claimed

+

+ Come back tomorrow for your next claim. +

+
+ )} + + {step === "error" && ( +
+

Claim failed. Try again.

+
+ )} +
+
+
+ ); +} diff --git a/packages/react-app/src/screens/Dashborad.tsx b/packages/react-app/src/screens/Dashborad.tsx new file mode 100644 index 0000000..bc94d32 --- /dev/null +++ b/packages/react-app/src/screens/Dashborad.tsx @@ -0,0 +1,161 @@ +import { useGetMember, useGetMemberTrustees, useGetMemberTrusters } from "@/hooks/queries/useGetMember"; +import { useBalanceStream } from "@/hooks/useBalanceStream"; +import { formatScore, formatFlow, truncateAddress } from "@/utils"; +import { useAccount } from "wagmi"; +import Blockies from "react-blockies"; + +export default function Dashboard() { + const account = useAccount(); + const { data: memberData } = useGetMember(account.address as string); + const { data: trusteesData } = useGetMemberTrustees(account.address ?? ""); + const { data: trustersData } = useGetMemberTrusters(account.address ?? ""); + + const inFlowRate = BigInt(memberData?.data?.member?.inFlowRate || 0); + const outFlowRate = BigInt(memberData?.data?.member?.outFlowRate || 0); + const netFlowRate = inFlowRate - outFlowRate; + + const balance = useBalanceStream(account.address, netFlowRate); + + const supporters = trustersData?.data?.member?.trusters?.length || 0; + const receivers = trusteesData?.data?.member?.trustees?.length || 0; + const trustScore = formatScore(memberData?.data?.member?.trustScore || ""); + + const displayName = truncateAddress(account.address || ""); + + // Calculate donut progress (percentage fill based on activity) + const totalActivity = supporters + receivers; + const progress = Math.min(totalActivity / 20, 1); + const circumference = 2 * Math.PI * 50; + const dashOffset = circumference * (1 - progress); + + return ( +
+ {/* Header */} +
+
+ {account.address && ( + + )} +
+

{displayName}

+
+ Trust Score + {trustScore} +
+
+
+
+ +
+ {/* Stats + Donut Chart */} +
+
+
+
+
+

Supporters

+

{supporters}

+
+
+
+
+
+

Receivers

+

{receivers}

+
+
+
+ + {/* Net Flow Donut */} +
+ + + = 0n ? "text-green-500" : "text-red-500"} + strokeLinecap="round" + /> + +
+

= 0n ? "text-green-400" : "text-red-400" + }`} + > + {balance || "0"} +

+

G$ Balance

+
+
+
+ + {/* Flow Summary Cards */} +
+
+

Incoming

+

+ {inFlowRate > 0n ? formatFlow(inFlowRate.toString()) : "0 G$"} +

+

per month

+
+
+

Outgoing

+

+ {outFlowRate > 0n ? formatFlow(outFlowRate.toString()) : "0 G$"} +

+

per month

+
+
+ + {/* Active Streams Summary */} +
+

Active Streams

+
+
+ Outgoing streams + {receivers} +
+
+ Incoming streams + {supporters} +
+
+ Net flow + = 0n ? "text-green-400" : "text-red-400" + }`} + > + {netFlowRate !== 0n + ? `${netFlowRate > 0n ? "+" : "-"}${formatFlow( + (netFlowRate > 0n ? netFlowRate : -netFlowRate).toString() + )}` + : "0 G$"} + +
+
+
+
+
+ ); +} diff --git a/packages/react-app/src/screens/Explore.tsx b/packages/react-app/src/screens/Explore.tsx new file mode 100644 index 0000000..b3df559 --- /dev/null +++ b/packages/react-app/src/screens/Explore.tsx @@ -0,0 +1,256 @@ +import { useMemo, useState } from "react"; +import { useGenericQuery } from "@/hooks/queries/useGenericQuery"; +import { formatScore, truncateAddress } from "@/utils"; +import { ArrowLeft, Search, Users } from "lucide-react"; +import Blockies from "react-blockies"; +import { Link, useNavigate } from "react-router-dom"; +import ErrorState from "@/components/ErrorState"; + +interface CommunityMember { + id: string; + trustScore: string; + inFlowRate: string; + trusters: { id: string }[]; +} + +const SUBGRAPH_URL = "https://api.studio.thegraph.com/query/1742484/trustsquared/v2.0.0"; + +// Fetch all members + newest-order info in one go +const fetchAllMembers = async () => { + const [membersRes, eventsRes] = await Promise.all([ + fetch(SUBGRAPH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `{ + members(first: 100, orderBy: trustScore, orderDirection: desc) { + id + trustScore + inFlowRate + trusters { id } + } + }`, + }), + }).then((r) => r.json()), + fetch(SUBGRAPH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `{ + trustUpdateds(first: 200, orderBy: blockTimestamp, orderDirection: desc) { + truster + recipient + } + }`, + }), + }).then((r) => r.json()), + ]); + + // Build newest-order map from events + const seen = new Set(); + const newestOrder: string[] = []; + for (const e of eventsRes?.data?.trustUpdateds || []) { + const truster = (e.truster as string).toLowerCase(); + const recipient = (e.recipient as string).toLowerCase(); + if (!seen.has(recipient)) { seen.add(recipient); newestOrder.push(recipient); } + if (!seen.has(truster)) { seen.add(truster); newestOrder.push(truster); } + } + + return { + members: (membersRes?.data?.members || []) as CommunityMember[], + newestOrder, + }; +}; + +type SortMode = "trustScore" | "supporters" | "newest"; + +export default function Explore() { + const navigate = useNavigate(); + const [sortBy, setSortBy] = useState("trustScore"); + const [searchQuery, setSearchQuery] = useState(""); + + const { data, status, refetch } = useGenericQuery( + ["community"], + fetchAllMembers + ); + + const members = data?.members || []; + const newestOrder = data?.newestOrder || []; + + // Sort client-side based on selected tab + const sortedMembers = useMemo(() => { + const list = [...members]; + if (sortBy === "trustScore") { + list.sort((a, b) => { + const aScore = BigInt(a.trustScore || "0"); + const bScore = BigInt(b.trustScore || "0"); + if (bScore > aScore) return 1; + if (bScore < aScore) return -1; + return 0; + }); + } else if (sortBy === "supporters") { + list.sort((a, b) => (b.trusters?.length || 0) - (a.trusters?.length || 0)); + } else { + const orderMap = new Map(newestOrder.map((addr, i) => [addr, i])); + list.sort((a, b) => + (orderMap.get(a.id.toLowerCase()) ?? 999) - (orderMap.get(b.id.toLowerCase()) ?? 999) + ); + } + return list; + }, [members, newestOrder, sortBy]); + + const query = searchQuery.toLowerCase().replace(/^@/, ""); + const filteredMembers = query + ? sortedMembers.filter((m) => m.id.toLowerCase().includes(query)) + : sortedMembers; + + return ( +
+ {/* Header */} +
+ +

Explore Community

+
+ +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full bg-t2-card border border-t2-border rounded-xl pl-10 pr-4 py-3 text-sm text-white placeholder:text-gray-500 focus:border-green-600 focus:outline-none transition-colors" + /> +
+ + {/* Sort Tabs - pill shaped */} +
+ + + +
+ + {/* Members List */} + {status === "error" ? ( + + ) : status === "pending" ? ( +
+
+
+ ) : filteredMembers.length === 0 ? ( +
+ +

No members found

+
+ ) : ( +
+ {filteredMembers.map((member) => { + const score = formatScore(member.trustScore); + const scoreNum = score.replace(" ☘️", ""); + const supporterCount = member.trusters?.length || 0; + const isNew = Number(member.trustScore) === 0; + + return ( +
+ {/* Top row: avatar + name + score */} +
+
+ +
+

+ {truncateAddress(member.id)} +

+

+ @{member.id.slice(2, 10).toUpperCase()} +

+
+
+
+ {isNew ? ( + <> +

- - -

+

PENDING

+ + ) : ( + <> +

{scoreNum}

+

TRUST SCORE

+ + )} +
+
+ + {/* Bottom row: supporters count + support button */} +
+
+ + + {supporterCount >= 1000 + ? (supporterCount / 1000).toFixed(1) + "k" + : supporterCount} + + Supporters +
+ + Support + +
+ + {isNew && ( +

NEW MEMBER

+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/packages/react-app/src/screens/Home.tsx b/packages/react-app/src/screens/Home.tsx index 1e2fa30..1f4f98c 100644 --- a/packages/react-app/src/screens/Home.tsx +++ b/packages/react-app/src/screens/Home.tsx @@ -1,102 +1,424 @@ -import TrustAccount from "@/components/TrustAccount"; -import { useGetMember } from "@/hooks/queries/useGetMember"; -import { useVerifier } from "@/hooks/queries/useVerifier"; -import { useBalanceStream } from "@/hooks/useBalanceStream"; -import { formatScore } from "@/utils"; -import { useDynamicContext } from "@dynamic-labs/sdk-react-core"; -import { QRCodeSVG } from "qrcode.react"; -import { useAccount } from "wagmi"; -import { useVerifiedIdentities } from "@/hooks/useVerifiedIdentities"; -import { BadgeCheck } from "lucide-react"; - -// const formatScore = (rate: string) => { -// const score = ((Number(rate) / 1e18) * 1e5).toFixed(2); -// return score + " ☘️"; -// }; +import { useGetMember, useGetMemberTrustees, useGetMemberTrusters } from "@/hooks/queries/useGetMember"; +import { formatScore, truncateAddress } from "@/utils"; +import { useAccount, useReadContract } from "wagmi"; +import { useQuery } from "@tanstack/react-query"; +import { formatUnits } from "viem"; +import VerificationBanner from "@/components/VerificationBanner"; +import { Gift, Search, Bell, ChevronRight, ThumbsUp, BarChart3, Loader2 } from "lucide-react"; +import { Link } from "react-router-dom"; +import Blockies from "react-blockies"; +import { useState } from "react"; -export default function Home() { - //this will try to get user verified by the backened - const verifierResult = useVerifier(); - const account = useAccount(); - const { data } = useGetMember(account.address as string); - // console.log({data, addr: account.address}) - const identities = useVerifiedIdentities(account.address); - // console.log({identities}) - - // @ts-ignore - const balance = useBalanceStream( - account.address, - // @ts-ignore - BigInt(data?.data?.member?.inFlowRate || 0) - - BigInt(data?.data?.member?.outFlowRate || 0) - ); +const SUBGRAPH_URL = "https://api.studio.thegraph.com/query/1742484/trustsquared/v2.0.0"; - const { user = {} } = useDynamicContext(); +type FlowEvent = { rate: bigint; timestamp: number }; - // return
home
; +// Fetch all TrustUpdated events for this address (both in and out) +function useFlowEvents(address: string | undefined) { + return useQuery({ + queryKey: ["flowEvents", address], + queryFn: async () => { + const addr = address!.toLowerCase(); - // return - return ( -
-
- {account.address && ( - - )} + const [inRes, outRes] = await Promise.all([ + fetch(SUBGRAPH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `{ trustUpdateds(where: { recipient: "${addr}" } orderBy: blockTimestamp orderDirection: asc first: 1000) { totalTrusteeInFlow blockTimestamp } }`, + }), + }), + fetch(SUBGRAPH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `{ trustUpdateds(where: { truster: "${addr}" } orderBy: blockTimestamp orderDirection: asc first: 1000) { totalTrusterOutFlow blockTimestamp } }`, + }), + }), + ]); + + const inJson = await inRes.json(); + const outJson = await outRes.json(); + + const inEvents: FlowEvent[] = (inJson?.data?.trustUpdateds || []).map( + (e: { totalTrusteeInFlow: string; blockTimestamp: string }) => ({ + rate: BigInt(e.totalTrusteeInFlow), + timestamp: Number(e.blockTimestamp), + }) + ); + const outEvents: FlowEvent[] = (outJson?.data?.trustUpdateds || []).map( + (e: { totalTrusterOutFlow: string; blockTimestamp: string }) => ({ + rate: BigInt(e.totalTrusterOutFlow), + timestamp: Number(e.blockTimestamp), + }) + ); + + return { inEvents, outEvents }; + }, + enabled: !!address, + }); +} + +// Calculate total G$ flowed for a list of rate-change events within a time range +function calcVolume(events: FlowEvent[], rangeStart: number, rangeEnd: number): bigint { + if (events.length === 0) return 0n; + let total = 0n; + for (let i = 0; i < events.length; i++) { + const rate = events[i].rate; + const evStart = events[i].timestamp; + const evEnd = i + 1 < events.length ? events[i + 1].timestamp : rangeEnd; + // Clamp to range + const start = Math.max(evStart, rangeStart); + const end = Math.min(evEnd, rangeEnd); + if (end > start) { + total += rate * BigInt(end - start); + } + } + return total; +} + +// Get bucket labels and time ranges for each period +function getBuckets(period: TimePeriod): { label: string; start: number; end: number }[] { + const now = Math.floor(Date.now() / 1000); + const buckets: { label: string; start: number; end: number }[] = []; + + if (period === "1 Min") { + // Last 7 minutes + for (let i = 6; i >= 0; i--) { + const end = now - i * 60; + const start = end - 60; + buckets.push({ label: `${i === 0 ? "Now" : `-${i}m`}`, start, end }); + } + } else if (period === "Day") { + // Last 7 days (Sun-Sat style) + const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + for (let i = 6; i >= 0; i--) { + const d = new Date((now - i * 86400) * 1000); + const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() / 1000; + buckets.push({ label: DAYS[d.getDay()], start: dayStart, end: dayStart + 86400 }); + } + } else if (period === "Week") { + // Last 4 weeks + for (let i = 3; i >= 0; i--) { + const end = now - i * 7 * 86400; + const start = end - 7 * 86400; + buckets.push({ label: `W${4 - i}`, start, end }); + } + } else if (period === "Month") { + // Last 6 months + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + for (let i = 5; i >= 0; i--) { + const d = new Date(); + d.setMonth(d.getMonth() - i); + const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime() / 1000; + const endD = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime() / 1000; + buckets.push({ label: MONTHS[d.getMonth()], start, end: endD }); + } + } else { + // Year - last 5 years + const thisYear = new Date().getFullYear(); + for (let i = 4; i >= 0; i--) { + const y = thisYear - i; + const start = new Date(y, 0, 1).getTime() / 1000; + const end = new Date(y + 1, 0, 1).getTime() / 1000; + buckets.push({ label: `${y}`, start, end }); + } + } + return buckets; +} + +// Compute all-time totals from events +function calcAllTime(inEvents: FlowEvent[], outEvents: FlowEvent[]) { + const now = Math.floor(Date.now() / 1000); + let totalIn = 0n; + for (let i = 0; i < inEvents.length; i++) { + const start = inEvents[i].timestamp; + const end = i + 1 < inEvents.length ? inEvents[i + 1].timestamp : now; + totalIn += inEvents[i].rate * BigInt(end - start); + } + let totalOut = 0n; + for (let i = 0; i < outEvents.length; i++) { + const start = outEvents[i].timestamp; + const end = i + 1 < outEvents.length ? outEvents[i + 1].timestamp : now; + totalOut += outEvents[i].rate * BigInt(end - start); + } + return { totalIn, totalOut, net: totalIn - totalOut }; +} + +function formatGd(wei: bigint): string { + const val = Number(formatUnits(wei, 18)); + if (val === 0) return "0"; + if (val < 0.01 && val > -0.01) return val.toFixed(4); + return val.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} + +const UBI_CONTRACT = "0x43d72Ff17701B2DA814620735C39C620Ce0ea4A1" as const; +const UBI_ABI = [ + { + name: "checkEntitlement", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +type TimePeriod = "1 Min" | "Day" | "Week" | "Month" | "Year"; + +function BarChart({ inEvents, outEvents, period }: { + inEvents: FlowEvent[]; + outEvents: FlowEvent[]; + period: TimePeriod; +}) { + const buckets = getBuckets(period); + const maxBarHeight = 80; + + // Compute in/out/net per bucket + const data = buckets.map((b) => { + const inVal = Number(formatUnits(calcVolume(inEvents, b.start, b.end), 18)); + const outVal = Number(formatUnits(calcVolume(outEvents, b.start, b.end), 18)); + return { label: b.label, inVal, outVal, net: inVal - outVal }; + }); + + const maxVal = Math.max(...data.map((d) => Math.max(d.inVal, d.outVal)), 1); - -
- {Object.entries(identities || {}).map(([k, v]) => { - if (v) - return ( -
- {k} + // Y-axis labels + const yTop = Math.ceil(maxVal * 1.2); + const yLabels = [yTop, Math.round(yTop * 0.75), Math.round(yTop * 0.5), Math.round(yTop * 0.25), 0]; + const fmtY = (v: number) => v >= 1000 ? `${(v / 1000).toFixed(0)}K` : `${v}`; + + return ( +
+
+ {/* Y axis */} +
+ {yLabels.map((label, i) => ( + {fmtY(label)} + ))} +
+ {/* Bars */} +
+ {data.map((d) => { + const inH = yTop > 0 ? (d.inVal / yTop) * maxBarHeight : 0; + const outH = yTop > 0 ? (d.outVal / yTop) * maxBarHeight : 0; + const netH = yTop > 0 ? (Math.abs(d.net) / yTop) * maxBarHeight : 0; + return ( +
+
+
+
+
- ); + {d.label} +
+ ); })}
-
-
- Trust Score - - {formatScore(data?.data?.member?.trustScore || "")} - -
+
+
+ ); +} + +export default function Home() { + const account = useAccount(); + const { data, status: memberStatus } = useGetMember(account.address ?? ""); + const { data: trusteesData } = useGetMemberTrustees(account.address ?? ""); + const { data: trustersData } = useGetMemberTrusters(account.address ?? ""); + + const { data: flowData } = useFlowEvents(account.address); + const inEvents = flowData?.inEvents ?? []; + const outEvents = flowData?.outEvents ?? []; + + const trustScore = formatScore(data?.data?.member?.trustScore || ""); + const displayName = truncateAddress(account.address || ""); + + const supporters = trustersData?.data?.member?.trusters?.length || 0; + const receivers = trusteesData?.data?.member?.trustees?.length || 0; + + const [activePeriod, setActivePeriod] = useState("Day"); + + // Compute net flow for selected period + const periodBuckets = getBuckets(activePeriod); + const periodStart = periodBuckets[0]?.start ?? 0; + const periodEnd = periodBuckets[periodBuckets.length - 1]?.end ?? Math.floor(Date.now() / 1000); + const periodIn = calcVolume(inEvents, periodStart, periodEnd); + const periodOut = calcVolume(outEvents, periodStart, periodEnd); + const netFlow = periodIn - periodOut; + + const { data: entitlement } = useReadContract({ + address: UBI_CONTRACT, + abi: UBI_ABI, + functionName: "checkEntitlement", + query: { enabled: !!account.address }, + }); + + const canClaim = entitlement ? BigInt(entitlement) > 0n : false; + + // Donut chart calculations + const totalActivity = supporters + receivers; + const progress = Math.min(totalActivity / 20, 1); + const circumference = 2 * Math.PI * 50; + const dashOffset = circumference * (1 - progress); + + const periods: TimePeriod[] = ["1 Min", "Day", "Week", "Month", "Year"]; + + if (!account.address) { + return ( +
+ +
+ ); + } -
- Balance - - {balance?.toString()} G$ - + return ( +
+ {/* Header */} +
+
+
+ {account.address && ( + + )} +
+

{displayName}

+
+ Trust Score + {trustScore} +
+
- {/*
- -

Total Supporters

+ + + + + +
-

15

-
*/} +
+
- {/*
-
- -

Total Amount

+ + +
+ {/* Time Period Tabs */} +
+ {periods.map((period) => ( + + ))} +
+ + {/* Supporters / Receivers + Net Flow Donut */} +
+
+
+
+
+ Supporters +
+

{supporters}

+
+
+
+
+ Receivers +
+

{receivers}

+
-

$12,345

-
*/} + + {/* Net Flow Donut */} +
+ + + = 0n ? "text-green-500" : "text-red-500"} + strokeLinecap="round" + /> + +
+ {memberStatus === "pending" ? ( +
+ ) : ( + <> +

= 0n ? "text-green-400" : "text-red-400"}`}> + {formatGd(netFlow)} G$ +

+

Net Flow

+ + )} +
+
+
+ + {/* Bar Chart */} + + + {/* Claim button */} + {canClaim && ( + + + Claim Daily G$ + + )} + + {/* Quick Actions */} +
+

Quick Actions

+ +
+
+ +
+ Support Someone +
+ + + +
+
+ +
+ View My Support Streams +
+ +
diff --git a/packages/react-app/src/screens/Login.tsx b/packages/react-app/src/screens/Login.tsx index cedd25f..b194e2b 100644 --- a/packages/react-app/src/screens/Login.tsx +++ b/packages/react-app/src/screens/Login.tsx @@ -1,43 +1,109 @@ -import { Button } from "@/components/ui/button"; -import { DynamicConnectButton } from "@dynamic-labs/sdk-react-core"; +import { useState } from "react"; import { injected } from "@wagmi/connectors"; import { useConnect } from "wagmi"; -import { useAccount } from "wagmi"; +import { useAppKit } from "@reown/appkit/react"; +import Welcome from "./Welcome"; +import { Zap, Users, TrendingUp, Shield } from "lucide-react"; export default function Login() { const { connect } = useConnect(); + const { open } = useAppKit(); + const [showWelcome, setShowWelcome] = useState(() => { + return !localStorage.getItem("trust2_onboarded"); + }); - let isMiniPlay = false; - if (window && window.ethereum) { - // User has a injected wallet + const isMiniPay = !!( + window?.ethereum && "isMiniPay" in window.ethereum && window.ethereum.isMiniPay + ); - // @ts-expect-error - if (window.ethereum.isMiniPay) { - isMiniPlay = true; - } + const handleOnboardingComplete = () => { + localStorage.setItem("trust2_onboarded", "true"); + setShowWelcome(false); + }; + + if (showWelcome) { + return ; } - const onConnectMiniPay = () => { - try { - connect({ - connector: injected(), - }); - } catch (error) { - console.error("Error connecting:", error); + const handleConnect = () => { + if (isMiniPay) { + try { + connect({ connector: injected() }); + } catch (error) { + console.error("Error connecting:", error); + } + } else { + open(); } }; return ( -
- logo - {isMiniPlay ? ( - - ) : ( - +
+ {/* Hero section */} +
+ {/* Logo */} +
+
+
+
+
+
+
+

+ Trust2 +

+

+ Stream G$ to the people you trust. Build reputation. Grow your community. +

+
+ + {/* What you can do — 3 feature highlights */} +
+
+
+ +
+
+

Real-time G$ Streams

+

Support others with continuous token flows

+
+
+
+
+ +
+
+

On-chain Trust Score

+

Your reputation grows as people support you

+
+
+
+
+ +
+
+

Community Powered

+

Discover and support trusted community members

+
+
+
+ + {/* Spacer */} +
+ + {/* Connect Wallet CTA */} +
+ +

+ MetaMask, WalletConnect, Coinbase & more +

+
); } diff --git a/packages/react-app/src/screens/Profile.tsx b/packages/react-app/src/screens/Profile.tsx new file mode 100644 index 0000000..838aa62 --- /dev/null +++ b/packages/react-app/src/screens/Profile.tsx @@ -0,0 +1,255 @@ +import { useState } from "react"; +import { useGetMember, useGetMemberTrustees, useGetMemberTrusters } from "@/hooks/queries/useGetMember"; +import { useBalanceStream } from "@/hooks/useBalanceStream"; +import { formatScore, formatFlow, truncateAddress } from "@/utils"; +import { QRCodeSVG } from "qrcode.react"; +import { useAccount, useDisconnect } from "wagmi"; +import { Share2, LogOut, TrendingUp, TrendingDown, Clock, Check } from "lucide-react"; +import Blockies from "react-blockies"; +import { Link } from "react-router-dom"; + +export default function Profile() { + const account = useAccount(); + const { disconnect } = useDisconnect(); + const [copied, setCopied] = useState(false); + const [showDisconnect, setShowDisconnect] = useState(false); + const { data: memberData } = useGetMember(account.address as string); + const { data: trusteesData } = useGetMemberTrustees(account.address ?? ""); + const { data: trustersData } = useGetMemberTrusters(account.address ?? ""); + + const inFlowRate = BigInt(memberData?.data?.member?.inFlowRate || 0); + const outFlowRate = BigInt(memberData?.data?.member?.outFlowRate || 0); + const netFlowRate = inFlowRate - outFlowRate; + + const balance = useBalanceStream(account.address, netFlowRate); + + const trusteesArr = trusteesData?.data?.member?.trustees || []; + const trustersArr = trustersData?.data?.member?.trusters || []; + const activeSupporters = trustersArr.filter((t: { flowRate: string }) => BigInt(t.flowRate) > 0n).length; + const activeTrustees = trusteesArr.filter((t: { flowRate: string }) => BigInt(t.flowRate) > 0n).length; + const trustScore = formatScore(memberData?.data?.member?.trustScore || ""); + const displayName = truncateAddress(account.address || ""); + + const shareUrl = window.location.origin + `/?address=${account.address}`; + + const handleShare = async () => { + try { + if (navigator.share) { + await navigator.share({ + title: `${displayName} on Trust²`, + text: `Support me on Trust² — stream G$ to build community trust.`, + url: shareUrl, + }); + } else { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } catch { + // User cancelled share dialog + } + }; + + return ( +
+ {/* Header */} +
+
+ {account.address && ( + + )} + {displayName} +
+
+ + +
+
+ + {/* Copied toast */} + {copied && ( +
+ Link copied to clipboard +
+ )} + +
+ {/* QR Code */} +
+
+ +
+

Scan to receive your funds

+
+ + {/* Incoming & Outgoing */} +
+
+
+ +

Incoming

+
+

+ {inFlowRate > 0n ? formatFlow(inFlowRate.toString()) : "0 G$"} +

+

From {activeSupporters} active stream{activeSupporters !== 1 ? "s" : ""}

+
+
+
+ +

Outgoing

+
+

+ {outFlowRate > 0n ? formatFlow(outFlowRate.toString()) : "0 G$"} +

+

To {activeTrustees} active stream{activeTrustees !== 1 ? "s" : ""}

+
+
+ + {/* Balance */} +
+ Balance + + {balance ? balance : "0.00"} G$ + +
+ + {/* Trust Score */} +
+ Trust Score + {trustScore} +
+ + {/* Recent Activity */} +
+
+

Recent Activity

+ +
+ + {/* Incoming streams (trusters) */} + {trustersArr.map((truster) => { + const addr = truster.id.split("_")[0]; + const isActive = BigInt(truster.flowRate) > 0n; + const monthlyFlow = formatFlow(truster.flowRate.toString()); + return ( +
+
+
+ +
+
+

{truncateAddress(addr)}

+

{isActive ? "Active stream" : "Paused"}

+
+ + {isActive ? `+${monthlyFlow}` : "Paused"} + +
+
+ ); + })} + + {/* Outgoing streams (trustees) */} + {trusteesArr.map((trustee) => { + const addr = trustee.id.split("_")[1]; + const isActive = BigInt(trustee.flowRate) > 0n; + const monthlyFlow = formatFlow(trustee.flowRate.toString()); + return ( +
+
+
+ +
+
+

{truncateAddress(addr)}

+

{isActive ? "Monthly stream" : "Paused"}

+
+ + {isActive ? `-${monthlyFlow}` : "Paused"} + +
+ {isActive ? ( + + STOP SUPPORT + + ) : ( + + RESUME SUPPORT + + )} +
+ ); + })} + + {trustersArr.length === 0 && trusteesArr.length === 0 && ( +
+

No recent activity

+
+ )} +
+
+ + {/* Disconnect Wallet Modal */} + {showDisconnect && ( +
setShowDisconnect(false)} + > +
e.stopPropagation()} + > +

Disconnect Wallet

+

+ Are you sure you want to disconnect your wallet? +

+ + +
+
+ )} +
+ ); +} diff --git a/packages/react-app/src/screens/StopSupport.tsx b/packages/react-app/src/screens/StopSupport.tsx new file mode 100644 index 0000000..151bce6 --- /dev/null +++ b/packages/react-app/src/screens/StopSupport.tsx @@ -0,0 +1,263 @@ +import { useState } from "react"; +import { useWriteContract, useAccount, useReadContract } from "wagmi"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useToast } from "@/hooks/use-toast"; +import ABI from "../abis/CFAv1Forwarder.json"; +import { GOODDOLLAR, SF_FORWARDER, POOL_CONTRACT } from "@/env"; +import { + encodeAbiParameters, + parseAbiParameters, +} from "viem"; +import { + ArrowLeft, + X, + AlertTriangle, + Loader2, + CheckCircle2, +} from "lucide-react"; +import { truncateAddress, formatFlow } from "@/utils"; +import Blockies from "react-blockies"; + +const isMiniPay = !!(window?.ethereum && 'isMiniPay' in window.ethereum && (window.ethereum as Record).isMiniPay); +const gasOpts = isMiniPay + ? {} + : { + maxFeePerGas: BigInt(25.1e9), + maxPriorityFeePerGas: BigInt(1e8), + }; + +const useGetFlowRate = (sender: string | undefined) => { + const result = useReadContract({ + address: SF_FORWARDER as `0x${string}`, + abi: [ + { + name: "getFlowrate", + type: "function", + stateMutability: "view", + inputs: [ + { name: "token", type: "address" }, + { name: "sender", type: "address" }, + { name: "receiver", type: "address" }, + ], + outputs: [{ name: "", type: "int96" }], + }, + ], + functionName: "getFlowrate", + args: [ + GOODDOLLAR as `0x${string}`, + (sender || "0x0000000000000000000000000000000000000000") as `0x${string}`, + POOL_CONTRACT as `0x${string}`, + ], + query: { enabled: !!sender }, + }); + if (!sender || !result.data) return 0n; + return BigInt(result.data); +}; + +type Step = "confirm" | "processing" | "done"; + +export default function StopSupport() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const account = useAccount(); + const { writeContractAsync } = useWriteContract(); + const { toast } = useToast(); + + const trusteeAddress = searchParams.get("address") || ""; + const flowRateStr = searchParams.get("flowRate") || "0"; + const existingFlowRate = useGetFlowRate(account.address); + + const [step, setStep] = useState("confirm"); + + const monthlyAmount = formatFlow(flowRateStr); + + const handleStopSupport = async () => { + if (!trusteeAddress || !account.address) return; + + setStep("processing"); + + try { + const trusteeFlowRate = BigInt(flowRateStr); + const newFlowRate = existingFlowRate - trusteeFlowRate; + + if (newFlowRate <= 0n) { + const userData = encodeAbiParameters( + parseAbiParameters("address,int96"), + [trusteeAddress as `0x${string}`, 0n] + ); + + await writeContractAsync({ + ...gasOpts, + abi: ABI, + functionName: "deleteFlow", + address: SF_FORWARDER as `0x${string}`, + args: [GOODDOLLAR, account.address, POOL_CONTRACT, userData], + }); + } else { + const userData = encodeAbiParameters( + parseAbiParameters("address,int96"), + [trusteeAddress as `0x${string}`, 0n] + ); + + await writeContractAsync({ + ...gasOpts, + abi: ABI, + functionName: "updateFlow", + address: SF_FORWARDER as `0x${string}`, + args: [ + GOODDOLLAR, + account.address, + POOL_CONTRACT, + newFlowRate, + userData, + ], + }); + } + + setStep("done"); + toast({ + title: "Support stopped", + description: `You are no longer supporting ${truncateAddress(trusteeAddress)}`, + }); + } catch (e: unknown) { + setStep("confirm"); + + const errStr = (e as Error)?.message || String(e); + let description = "Could not stop support. Please try again."; + + if (errStr.includes("rejected") || errStr.includes("denied")) { + description = "You rejected the transaction in your wallet."; + } else if (errStr.includes("insufficient")) { + description = "Insufficient funds for gas fees."; + } + + toast({ + title: "Transaction failed", + description, + }); + } + }; + + // Processing + if (step === "processing") { + return ( +
+ +

Stopping Support

+

+ Please confirm in your wallet and wait... +

+
+ ); + } + + // Done + if (step === "done") { + return ( +
+ +

Support Stopped

+

+ You are no longer streaming to {truncateAddress(trusteeAddress)} +

+ +
+ ); + } + + // Confirm Step - bottom sheet style over stream details background + return ( +
+ {/* Header */} +
+ +

Stream Details

+ +
+ + {/* Background stream info (dimmed) */} +
+
+ {account.address && ( +
+
+ +
+

You

+
+ )} +
+ + + +
+
+
+ +
+

{truncateAddress(trusteeAddress)}

+
+
+
+

{monthlyAmount}

+
+ per month + ACTIVE +
+
+
+ + {/* Bottom sheet modal */} +
+
+ {/* Drag handle */} +
+ + {/* Warning icon */} +
+
+ +
+
+ + {/* Text */} +
+

+ Stop Supporting {truncateAddress(trusteeAddress)}? +

+

+ Stopping will reduce their Trust Score impact. + This action cannot be undone immediately. +

+
+ + {/* Buttons */} +
+ + +
+ +

TRUST² PLATFORM

+
+
+ ); +} diff --git a/packages/react-app/src/screens/StreamDetails.tsx b/packages/react-app/src/screens/StreamDetails.tsx new file mode 100644 index 0000000..de6fbd4 --- /dev/null +++ b/packages/react-app/src/screens/StreamDetails.tsx @@ -0,0 +1,228 @@ +import { ArrowLeft, X, Loader2 } from "lucide-react"; +import { useNavigate, useSearchParams, Link } from "react-router-dom"; +import { useAccount } from "wagmi"; +import { useQuery } from "@tanstack/react-query"; +import { formatFlow, formatScore, truncateAddress } from "@/utils"; +import Blockies from "react-blockies"; + +const SUBGRAPH_URL = "https://api.studio.thegraph.com/query/1742484/trustsquared/v2.0.0"; + +function useTrustEvent(truster: string, recipient: string) { + return useQuery({ + queryKey: ["trustEvent", truster, recipient], + queryFn: async () => { + const res = await fetch(SUBGRAPH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `query TrustEvent($truster: Bytes!, $recipient: Bytes!) { + trustUpdateds( + where: { truster: $truster, recipient: $recipient } + orderBy: blockTimestamp + orderDirection: asc + first: 1 + ) { + prevTrustScore + newTrustScore + blockTimestamp + } + }`, + variables: { + truster: truster.toLowerCase(), + recipient: recipient.toLowerCase(), + }, + }), + }); + const json = await res.json(); + return json?.data?.trustUpdateds?.[0] ?? null; + }, + enabled: !!truster && !!recipient, + }); +} + +export default function StreamDetails() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const account = useAccount(); + + const trustId = searchParams.get("trustId") || ""; + const parts = trustId.split("_"); + const supporter = parts[0] || ""; + const receiver = parts[1] || ""; + + const isSupporter = + account.address?.toLowerCase() === supporter.toLowerCase(); + + const flowRateParam = searchParams.get("flowRate"); + const monthlyFlow = flowRateParam ? formatFlow(flowRateParam) : "Active Stream"; + const isActive = flowRateParam ? BigInt(flowRateParam) > 0n : true; + + const { data: trustEvent, isLoading: eventLoading } = useTrustEvent(supporter, receiver); + + // Calculate real trust impact + const scoreChange = trustEvent + ? formatScore(trustEvent.newTrustScore) + : null; + + // Format start date + const startDate = trustEvent?.blockTimestamp + ? new Date(Number(trustEvent.blockTimestamp) * 1000).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : null; + + return ( +
+ {/* Header */} +
+ +

Stream Details

+ +
+ +
+ {/* 1. Avatars — spread to edges with icon in center */} +
+
+
+ +
+

+ {isSupporter ? "You" : truncateAddress(supporter)} +

+
+ + {/* Horizontal line connecting avatars */} +
+ + {/* Streaming icon — centered on the line */} +
+ + + + + + +
+ +
+
+ +
+

+ {!isSupporter ? "You" : truncateAddress(receiver)} +

+
+
+ + {/* 2. Amount — large centered text */} +
+

+ {flowRateParam ? monthlyFlow : "Active"} +

+
+ per month + + {isActive ? "ACTIVE" : "PAUSED"} + +
+
+ + {/* 3. Trust Impact — card with green ring icon */} +
+
+
+ + + +
+
+

Trust Impact

+

Contribution to network health

+
+
+ {eventLoading ? ( + + ) : ( + + {scoreChange ? `+${scoreChange}` : "+0 Score"} + + )} +
+ + {/* Thin separator line */} +
+ + {/* 4. Transaction Details — heading + table card */} +
+

Transaction Details

+
+
+ Supporter + + {isSupporter ? "You" : truncateAddress(supporter)} + +
+
+ Receiver + + {!isSupporter ? "You" : truncateAddress(receiver)} + +
+ {flowRateParam && ( +
+ Network Rate + + {formatFlow(flowRateParam)}/month + +
+ )} +
+ Start Date + + {startDate ?? "Active"} + +
+
+
+ + {/* 5. Action Button — pushed to bottom with auto margin */} + {isSupporter && ( +
+ {isActive ? ( + + Stop Support Stream + + ) : ( + + Resume Support + + )} +
+ )} +
+
+ ); +} diff --git a/packages/react-app/src/screens/SupportStreams.tsx b/packages/react-app/src/screens/SupportStreams.tsx new file mode 100644 index 0000000..2818484 --- /dev/null +++ b/packages/react-app/src/screens/SupportStreams.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { useGetMemberTrustees, useGetMemberTrusters } from "@/hooks/queries/useGetMember"; +import { formatFlow, truncateAddress } from "@/utils"; +import { ArrowLeft, X, FileText, ChevronRight, Loader2, Users } from "lucide-react"; +import Blockies from "react-blockies"; +import { useAccount } from "wagmi"; +import { useNavigate, Link } from "react-router-dom"; +import ErrorState from "@/components/ErrorState"; + +type Tab = "give" | "receive"; + +export default function SupportStreams() { + const navigate = useNavigate(); + const { address } = useAccount(); + const [activeTab, setActiveTab] = useState("give"); + + const { data: trusteesData, status: trusteesStatus, refetch: refetchTrustees } = useGetMemberTrustees(address ?? ""); + const { data: trustersData, status: trustersStatus, refetch: refetchTrusters } = useGetMemberTrusters(address ?? ""); + + const isLoading = trusteesStatus === "pending" || trustersStatus === "pending"; + const isError = trusteesStatus === "error" || trustersStatus === "error"; + + const trustees = trusteesData?.data?.member?.trustees || []; + const trusters = trustersData?.data?.member?.trusters || []; + + const listData = activeTab === "give" ? trustees : trusters; + + // Stats for "receive" tab (only count active streams) + const activeTrusters = trusters.filter((t) => BigInt(t.flowRate) > 0n); + const totalSupporters = activeTrusters.length; + const totalIncoming = activeTrusters.reduce((acc, t) => { + const flow = formatFlow(t.flowRate.toString()); + return acc + parseFloat(flow.replace(/[^0-9.]/g, "") || "0"); + }, 0); + + return ( +
+ {/* Header */} +
+ +

Support Streams

+ +
+ +
+ {/* Tab Navigation */} +
+ + +
+ + {/* Stats cards for "receive" tab */} + {activeTab === "receive" && !isLoading && !isError && ( + <> +
+
+

Total Supporters

+

{totalSupporters}

+
+
+

Total Incoming

+

+ {totalIncoming.toFixed(0)} G$/month +

+
+
+ + {totalSupporters > 0 && ( +
+

Active Incoming Streams

+ LIVE +
+ )} + + )} + + {/* Stream List */} + {isLoading ? ( +
+ +
+ ) : isError ? ( + { refetchTrustees(); refetchTrusters(); }} + /> + ) : listData.length === 0 ? ( +
+ +

+ {activeTab === "give" + ? "You're not supporting anyone yet" + : "No one is supporting you yet"} +

+ {activeTab === "give" && ( + + Start supporting someone + + )} +
+ ) : ( +
+ {listData.map((item) => { + const addr = + activeTab === "give" + ? item.id.split("_")[1] + : item.id.split("_")[0]; + const isActive = BigInt(item.flowRate) > 0n; + const monthlyFlow = formatFlow(item.flowRate.toString()); + + return ( +
+
+
+ +
+

{truncateAddress(addr)}

+

+ {monthlyFlow} / month +

+

+ {isActive ? "Started: Active" : "Last Active: Stopped"} +

+
+
+ + + + {isActive ? "ACTIVE" : "PAUSED"} + + +
+ + {activeTab === "give" ? ( +
+ {isActive ? ( + <> + + View Details + + + Stop Support + + + ) : ( + <> + + Resume Support + + + + )} +
+ ) : ( + + + View Details + + + )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/packages/react-app/src/screens/TrustAction.tsx b/packages/react-app/src/screens/TrustAction.tsx index 0b23af3..c4dc824 100644 --- a/packages/react-app/src/screens/TrustAction.tsx +++ b/packages/react-app/src/screens/TrustAction.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useState } from "react"; -import { IDetectedBarcode, Scanner } from "@yudiel/react-qr-scanner"; -import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { Scanner, IDetectedBarcode } from "@yudiel/react-qr-scanner"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; - -import { useWriteContract, useAccount } from "wagmi"; +import { useWriteContract, useAccount, useReadContract } from "wagmi"; import ABI from "../abis/CFAv1Forwarder.json"; import { GOODDOLLAR, SF_FORWARDER, POOL_CONTRACT } from "@/env"; import { @@ -13,123 +11,439 @@ import { parseAbiParameters, isAddress, } from "viem"; -import { Loader2 } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { + Loader2, + ArrowLeft, + Zap, + Shield, + Clock, + CheckCircle2, + AlertTriangle, + Lock, + X, +} from "lucide-react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { PasteInput } from "@/components/PasteInput"; -import { useGetMember } from "@/hooks/queries/useGetMember"; -import { truncateAddress } from "@/utils"; - -// @ts-expect-error -const isMiniPay = window?.ethereum?.isMiniPay; -const gasOpts = isMiniPay ? {} : { - maxFeePerGas: BigInt(5e9), - maxPriorityFeePerGas: BigInt(0) -} - +import { truncateAddress, formatFlow } from "@/utils"; +import Blockies from "react-blockies"; + +const isMiniPay = !!(window?.ethereum && 'isMiniPay' in window.ethereum && (window.ethereum as Record).isMiniPay); +const gasOpts = isMiniPay + ? {} + : { + maxFeePerGas: BigInt(25.1e9), + maxPriorityFeePerGas: BigInt(1e8), + }; + const useGetFlowRate = (sender: string | undefined) => { - if (!sender) return undefined; - const memberData = useGetMember(sender); - // @ts-ignore - return memberData.status === "success" ? BigInt(memberData.data?.data?.outFlowRate || 0) : undefined + const result = useReadContract({ + address: SF_FORWARDER as `0x${string}`, + abi: [ + { + name: "getFlowrate", + type: "function", + stateMutability: "view", + inputs: [ + { name: "token", type: "address" }, + { name: "sender", type: "address" }, + { name: "receiver", type: "address" }, + ], + outputs: [{ name: "", type: "int96" }], + }, + ], + functionName: "getFlowrate", + args: [ + GOODDOLLAR as `0x${string}`, + (sender || "0x0000000000000000000000000000000000000000") as `0x${string}`, + POOL_CONTRACT as `0x${string}`, + ], + query: { enabled: !!sender }, + }); + if (!sender || !result.data) return 0n; + return BigInt(result.data); +}; + +type Step = "scan" | "amount" | "confirming" | "success"; +type RatePeriod = "month" | "minute"; +const PERIOD_SECONDS: Record = { + month: 60 * 60 * 24 * 30, + minute: 60, }; export const QrScan = () => { - const navigation = useNavigate(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const account = useAccount(); - const existingFlowRate = useGetFlowRate(account.address); - console.log({ existingFlowRate }); const { writeContractAsync } = useWriteContract(); - - const [result, setResult] = useState(undefined); - const [amount, setAmount] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); const { toast } = useToast(); - const validRecipient = isAddress(result || ""); + const initialAddress = searchParams.get("address") || undefined; + const [recipient, setRecipient] = useState(initialAddress); + const [amount, setAmount] = useState(""); + const [ratePeriod, setRatePeriod] = useState( + searchParams.get("mode") === "minute" ? "minute" : "month" + ); + const [step, setStep] = useState(initialAddress && isAddress(initialAddress) ? "amount" : "scan"); + const [txHash, setTxHash] = useState(""); + + const numAmount = parseFloat(amount) || 0; + const [errorMsg, setErrorMsg] = useState(""); + + const handleScan = (results: IDetectedBarcode[]) => { + if (results.length > 0) { + const scanned = results[0].rawValue; + setRecipient(scanned); + if (isAddress(scanned)) { + setStep("amount"); + } + } + }; - const handleScan = (result: IDetectedBarcode[]) => { - console.log(result); - if (result.length > 0) { - setResult(result[0].rawValue); + const handleRecipientSubmit = (text: string) => { + setRecipient(text); + if (isAddress(text)) { + setStep("amount"); } }; + const isSelfStream = + recipient && account.address + ? recipient.toLowerCase() === account.address.toLowerCase() + : false; + const trust = async () => { - if (existingFlowRate !== undefined && result) { - const monthlyTrustRate = - parseEther(amount.toString()) / BigInt(60 * 60 * 24 * 30); - console.log("Trusting:", { existingFlowRate, result, monthlyTrustRate }); + if (!recipient || numAmount <= 0) return; + + if (isSelfStream) { + setErrorMsg("You cannot stream to your own address."); + toast({ + title: "Invalid recipient", + description: "You cannot stream to your own address.", + }); + return; + } + + setErrorMsg(""); + setStep("confirming"); + + try { + const monthlyTrustRate = parseEther(amount) / BigInt(PERIOD_SECONDS[ratePeriod]); const newFlowRate = existingFlowRate + monthlyTrustRate; const userData = encodeAbiParameters( parseAbiParameters("address,int96"), - [result as "0x${string}", monthlyTrustRate] + [recipient as `0x${string}`, monthlyTrustRate] ); - const resultPromise = writeContractAsync({ + + const hash = await writeContractAsync({ ...gasOpts, abi: ABI, functionName: existingFlowRate === 0n ? "createFlow" : "updateFlow", - address: SF_FORWARDER, - args: [ - GOODDOLLAR, - account.address, - POOL_CONTRACT, - newFlowRate, - userData, - ], + address: SF_FORWARDER as `0x${string}`, + args: [GOODDOLLAR, account.address, POOL_CONTRACT, newFlowRate, userData], + }); + + setTxHash(hash); + setStep("success"); + toast({ + title: "Support started!", + description: "Your stream is now active", }); - setLoading(true); - setError(""); - try { - await resultPromise; - toast({ - title: "Trust success!", - description: "You've made your community better", - }); - navigation("/"); - } catch (e: any) { - console.log({ e }) - setLoading(false); - toast({ - title: "Transaction failed", - description: "Please try again", - }); + } catch (e: unknown) { + setStep("amount"); + + const errStr = (e as Error)?.message || String(e); + let title = "Transaction failed"; + let description = "Please try again."; + + if (errStr.includes("insufficient") || errStr.includes("exceeds balance")) { + title = "Insufficient G$ balance"; + description = "You don't have enough G$ to start this stream. Claim your daily G$ first."; + } else if (errStr.includes("NO_FLOW_CHANGE")) { + title = "Flow already exists"; + description = "You already have an active stream to this address with the same rate."; + } else if (errStr.includes("rejected") || errStr.includes("denied")) { + title = "Transaction rejected"; + description = "You rejected the transaction in your wallet."; + } else if (errStr.includes("NotAcceptedSuperToken")) { + title = "Token not supported"; + description = "The token is not accepted by the TrustPool contract."; } + + setErrorMsg(description); + toast({ title, description }); } }; - if (validRecipient) { - const buttonContent = loading ? ( - <> - Please wait - - ) : ( - "Trust" + // Scan Step + if (step === "scan") { + return ( +
+
+ +

Support Someone

+
+ +
+
+ +
+ +
+

+ Or enter a wallet address manually +

+ +
+
+
); + } + + // Amount Step + if (step === "amount") { return ( -
-
{truncateAddress(result || "")}
- setAmount(Number(e.currentTarget.value))} - /> - +
+ {/* Header */} +
+ +

Start Supporting

+
+ +
+ {/* Recipient avatar + name */} +
+ {recipient && ( +
+ +
+ )} +
+

+ {truncateAddress(recipient || "")} +

+

Receiver on Trust²

+
+
+ + {/* Amount Input Card */} +
+
+

Amount per {ratePeriod}

+

Set the {ratePeriod === "minute" ? "per-minute (test)" : "monthly"} stream value

+
+ + {/* Period toggle */} +
+ {(["month", "minute"] as RatePeriod[]).map((p) => ( + + ))} +
+ +
+ setAmount(e.target.value)} + placeholder="0.00" + className="flex-1 bg-transparent px-4 py-3.5 text-white text-2xl font-bold outline-none placeholder:text-gray-600" + min="0" + step="0.01" + /> + G$ +
+ +
+
+
+ +
+ + Duration: Continuous until stopped + +
+
+
+ +
+ Safe & Secure Transaction +
+
+
+ + {/* Self-stream warning */} + {isSelfStream && ( +
+ +

+ You cannot stream to your own address. +

+
+ )} + + {/* Error message */} + {errorMsg && !isSelfStream && ( +
+ +

{errorMsg}

+
+ )} + + {/* Disclaimer */} +

+ By starting this stream, you agree to monthly recurring charges. You + can cancel or edit this support stream at any time from your Trust² + dashboard. +

+ + {/* Spacer to push button to bottom */} +
+ + {/* Submit Button */} + + +

+ ENCRYPTED BY TRUST² +

+
); - } else { + } + + // Confirming Step + if (step === "confirming") { return ( -
- - setResult(text)} /> - {/*
Trustee Address:{result}
*/} +
+
+ +

Processing Transaction

+

+ Please confirm in your wallet and wait for the transaction to complete... +

+
); } + + // Success Step - matches Confirmation.png mockup + return ( +
+ {/* Header */} +
+ +

Confirmation

+ +
+ +
+ {/* Success Icon */} +
+ +
+ +
+

+ Support Started +

+

+ Successfully +

+

+ You are now streaming {amount} G$/{ratePeriod} +

+

+ to {truncateAddress(recipient || "")} +

+
+ + {/* Transaction Details */} +
+
+ Recipient +
+ {recipient && ( +
+ {recipient.slice(2, 3).toUpperCase()} +
+ )} + {truncateAddress(recipient || "")} +
+
+
+ Amount + {amount} G$ +
+
+ Frequency + {ratePeriod === "minute" ? "Per Minute" : "Monthly"} +
+
+ Network Fee + 0.001 G$ +
+
+ + {/* Spacer */} +
+ + {/* Actions */} +
+ {txHash ? ( + + View Transaction Details + + ) : ( + + )} + +
+
+
+ ); }; diff --git a/packages/react-app/src/screens/Trustees.tsx b/packages/react-app/src/screens/Trustees.tsx index 6f79a01..8072010 100644 --- a/packages/react-app/src/screens/Trustees.tsx +++ b/packages/react-app/src/screens/Trustees.tsx @@ -1,122 +1,130 @@ -import TrustAccount from "@/components/TrustAccount"; -import { useGetMemberTrustees } from "@/hooks/queries/useGetMember"; -import { formatFlow, getAddressLink, truncateAddress } from "@/utils"; -import { ExternalLink } from "lucide-react"; +import { useGetMemberTrustees, useGetMemberTrusters } from "@/hooks/queries/useGetMember"; +import { formatFlow, truncateAddress } from "@/utils"; import Blockies from "react-blockies"; -import { CiLocationArrow1, CiUser } from "react-icons/ci"; -import { Link } from "react-router-dom"; import { useAccount } from "wagmi"; +import { useState } from "react"; +import { ArrowLeft } from "lucide-react"; +import { useNavigate } from "react-router-dom"; -export const stats = { - score: { - icon: , - label: "Score", - value: 1, - }, - netFlow: { - icon: , - label: "Net Flow", - value: 1, - }, - supporters: { - icon: , - label: "Supporters", - value: 1, - }, - - trustees: { - icon: , - label: "Trustees", - value: 1, - }, -}; +export default function Trustees() { + const { address } = useAccount(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState<'trustees' | 'trusters'>('trustees'); -export function Stat({ - label, - value, - icon, -}: (typeof stats)[keyof typeof stats]) { - return ( -
- {icon} - -

{value}

-
- ); -} + const { data: trusteesData } = useGetMemberTrustees(address ?? ""); + const { data: trustersData } = useGetMemberTrusters(address ?? ""); -export default function History() { - const { address } = useAccount(); - const { data, status } = useGetMemberTrustees(address ?? ""); + const listData = activeTab === 'trustees' + ? trusteesData?.data?.member?.trustees + : trustersData?.data?.member?.trusters; - const totalTrustees = data?.data?.member?.trustees.length; - const totalFlow = data?.data?.member?.trustees.reduce( - (acc, curr) => acc + Number(curr.flowRate), - 0 - ); + const totalCount = listData?.length || 0; + const totalFlow = listData?.reduce((acc, curr) => acc + Number(curr.flowRate), 0) || 0; return ( -
-
- +
+ {/* Header */} +
+ +

Trust Network

+
-
-
- -
{"Total Trustees"}
-
{totalTrustees}
-
+ {/* Tab Navigation */} +
+
+ + +
+
-
- -
{"Total Outflow"}
-
- {totalFlow ? formatFlow(totalFlow.toString()) : "0"} -
-
+ {/* Stats Cards */} +
+
+ + {activeTab === 'trustees' ? 'Total Supporters' : 'Total Trusters'} + + {totalCount} +
+ +
+ + {activeTab === 'trustees' ? 'Total Inflow' : 'Total Outflow'} + + + {totalFlow ? formatFlow(totalFlow.toString()) : '0 G$'} +
- {/* Headers */} -
-
Address
-
Amount Trusted
+ {/* Table Headers */} +
+
+ Name + Amount +
- {/* List */} -
- {data?.data?.member?.trustees.map((t) => - { - const account = t.id.split("_")[1] - return( -
-
- -
- {/*
{trustee.name}
*/} -
- - {truncateAddress(account)} - + {/* List Items */} +
+ {!listData || listData.length === 0 ? ( +
+

No {activeTab === 'trustees' ? 'trustees' : 'trusters'} yet

+
+ ) : ( +
+ {listData.map((item) => { + const addr = activeTab === 'trustees' + ? item.id.split("_")[1] + : item.id.split("_")[0]; + + return ( +
+
+
+ +
+
+ {truncateAddress(addr)} +
+
+
+
+ {formatFlow(item.flowRate.toString())} +
+
-
-
-
- {formatFlow(t.flowRate.toString())} -
+ ); + })}
- )})} + )}
+ +
); } diff --git a/packages/react-app/src/screens/Verify.tsx b/packages/react-app/src/screens/Verify.tsx new file mode 100644 index 0000000..9a55c66 --- /dev/null +++ b/packages/react-app/src/screens/Verify.tsx @@ -0,0 +1,168 @@ +import { useVerifiedIdentities } from "@/hooks/useVerifiedIdentities"; +import { useVerifier } from "@/hooks/queries/useVerifier"; +import { useAccount } from "wagmi"; +import { ArrowLeft, BadgeCheck, ExternalLink, Shield, Loader2 } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +const GOODDOLLAR_VERIFY_URL = "https://wallet.gooddollar.org"; + +export default function Verify() { + const navigate = useNavigate(); + const { address } = useAccount(); + const identities = useVerifiedIdentities(address); + const { data: verifierData, status } = useVerifier(); + + const identityList = [ + { + name: "GoodID", + key: "GoodID", + description: "Verify through GoodDollar's identity system", + verified: identities?.GoodID || false, + action: GOODDOLLAR_VERIFY_URL, + }, + { + name: "World ID", + key: "WorldID", + description: "Verify through World ID biometric proof", + verified: identities?.WorldID || false, + action: null, + }, + { + name: "Nouns DAO", + key: "NoundsDAO", + description: "Verify through Nouns DAO membership", + verified: identities?.NoundsDAO || false, + action: null, + }, + { + name: "BrightID", + key: "BrightID", + description: "Verify through BrightID social identity", + verified: identities?.BrightID || false, + action: null, + }, + ]; + + const verifiedCount = identityList.filter((i) => i.verified).length; + + return ( +
+ {/* Header */} +
+ +

Identity Verification

+
+ +
+ {/* Status Card */} +
+
+ +
+

+ {verifiedCount > 0 ? `${verifiedCount} Identity Verified` : "Not Verified Yet"} +

+

+ {verifiedCount > 0 + ? "You can stream G$ and build your Trust Score" + : "Verify at least one identity to start streaming G$ to others"} +

+
+ + {/* Why Verify */} +
+

Why verify?

+
    +
  • + + Both parties must share a verified identity to stream +
  • +
  • + + Prevents sybil attacks and ensures unique humans +
  • +
  • + + Higher verification increases your Trust Score weight +
  • +
+
+ + {/* Verifier Status */} + {status === "pending" && ( +
+ +
+ )} + + {verifierData && ( +
+

API Verification Status

+
+
+ GoodID (API) + + {verifierData.isGoodId ? "Verified" : "Not verified"} + +
+
+ Nouns (API) + + {verifierData.isNouns ? "Verified" : "Not verified"} + +
+
+
+ )} + + {/* Identity Options */} +
+

Verification Methods

+ {identityList.map((identity) => ( +
+
+
+ {identity.verified ? ( + + ) : ( + + )} +
+
+

{identity.name}

+

{identity.description}

+
+
+ + {identity.verified ? ( + + Verified + + ) : identity.action ? ( + + Verify + + ) : ( + Coming soon + )} +
+ ))} +
+
+
+ ); +} diff --git a/packages/react-app/src/screens/Welcome.tsx b/packages/react-app/src/screens/Welcome.tsx new file mode 100644 index 0000000..e9620f4 --- /dev/null +++ b/packages/react-app/src/screens/Welcome.tsx @@ -0,0 +1,37 @@ +interface WelcomeProps { + onComplete: () => void; +} + +export default function Welcome({ onComplete }: WelcomeProps) { + return ( +
+
+ +
+ {/* Sound wave bars icon */} +
+
+
+
+
+
+
+ +

+ Trust2 +

+ +

+ Build your reputation through trust and contributions. +

+
+ + +
+ ); +} diff --git a/packages/react-app/src/types/react-blockies.d.ts b/packages/react-app/src/types/react-blockies.d.ts new file mode 100644 index 0000000..acdce68 --- /dev/null +++ b/packages/react-app/src/types/react-blockies.d.ts @@ -0,0 +1,16 @@ +declare module "react-blockies" { + import { FC } from "react"; + + interface BlockiesProps { + seed: string; + size?: number; + scale?: number; + color?: string; + bgColor?: string; + spotColor?: string; + className?: string; + } + + const Blockies: FC; + export default Blockies; +} diff --git a/packages/react-app/src/utils.ts b/packages/react-app/src/utils.ts index aa56687..c5b0f32 100644 --- a/packages/react-app/src/utils.ts +++ b/packages/react-app/src/utils.ts @@ -1,41 +1,10 @@ import { formatUnits } from "viem"; -import { stats } from "./screens/Trusters"; - -type Stat = (typeof stats)[keyof typeof stats]; // Helper function to truncate address export function truncateAddress(address: string): string { return `${address.slice(0, 6)}...${address.slice(-4)}`; } -export function calculateStats( - timePeriod: "day" | "week" | "month" | "year", - stat: Stat -) { - switch (timePeriod) { - case "day": - return { - ...stat, - value: stat.value / 1, - }; - case "week": - return { - ...stat, - value: stat.value / 7, - }; - case "month": - return { - ...stat, - value: stat.value / 30, - }; - case "year": - return { - ...stat, - value: stat.value / 365, - }; - } -} - export const SAMPLE_ADDRESS = "0x2CeADe86A04e474F3cf9BD87208514d818010627"; export const formatScore = (rate: string) => { diff --git a/packages/react-app/tailwind.config.js b/packages/react-app/tailwind.config.js index 3fc12b0..510133f 100644 --- a/packages/react-app/tailwind.config.js +++ b/packages/react-app/tailwind.config.js @@ -1,57 +1,61 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - darkMode: ["class"], - content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - '1': 'hsl(var(--chart-1))', - '2': 'hsl(var(--chart-2))', - '3': 'hsl(var(--chart-3))', - '4': 'hsl(var(--chart-4))', - '5': 'hsl(var(--chart-5))' - } - } - } + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + colors: { + 't2-dark': '#0a1a0a', + 't2-card': '#111a11', + 't2-card-light': '#162016', + 't2-border': '#1a2e1a', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + } + } }, plugins: [require("tailwindcss-animate")], } diff --git a/packages/subgraph/subgraph.yaml b/packages/subgraph/subgraph.yaml index a312338..3376d93 100644 --- a/packages/subgraph/subgraph.yaml +++ b/packages/subgraph/subgraph.yaml @@ -8,9 +8,9 @@ dataSources: name: TrustPool network: celo source: - address: "0x559Fc954873E175Ad8e0334cad4b80CB6D9f1A99" + address: "0xe7891De4005Ac5A2B6Cf3982C27c75A5B0F9b0FC" abi: TrustPool - startBlock: 28811137 + startBlock: 56212000 mapping: kind: ethereum/events apiVersion: 0.0.7 diff --git a/packages/verifier/next.config.ts b/packages/verifier/next.config.ts index 044129c..71ecef0 100644 --- a/packages/verifier/next.config.ts +++ b/packages/verifier/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + serverExternalPackages: ["ethers"], typescript: { // !! WARN !! // Dangerously allow production builds to successfully complete even if diff --git a/packages/verifier/src/app/api/verify/route.ts b/packages/verifier/src/app/api/verify/route.ts index 77e5af7..a5d252e 100644 --- a/packages/verifier/src/app/api/verify/route.ts +++ b/packages/verifier/src/app/api/verify/route.ts @@ -5,14 +5,8 @@ import { NextRequest } from 'next/server' import ABI from "../../../../abi/TrustPool.json" export const dynamic = 'force-dynamic'; // static by default, unless reading the request -const provider = new ethers.providers.JsonRpcProvider({ - skipFetchSetup: true, - url: 'https://forno.celo.org' -}) -const mainnet = new ethers.providers.JsonRpcProvider({ - skipFetchSetup: true, - url: 'https://rpc.sepolia.org' -}) +const provider = new ethers.providers.StaticJsonRpcProvider("https://forno.celo.org", 42220) +const mainnet = new ethers.providers.StaticJsonRpcProvider("https://rpc.sepolia.org", 11155111) const wallet = new ethers.Wallet(process.env.PRIVATE_KEY as string).connect(provider) const poolContract = new ethers.Contract(process.env.POOL_CONTRACT as string, ABI.abi).connect(wallet) const identityContract = new ethers.Contract("0xC361A6E67822a0EDc17D899227dd9FC50BD62F42" as string, ["function getWhitelistedRoot(address) external view returns(address)"]).connect(provider) @@ -28,8 +22,7 @@ export async function GET(request: NextRequest) { if (memberAddress) { const root = await identityContract.getWhitelistedRoot(memberAddress); isGoodID = root.toLowerCase() === memberAddress.toLowerCase() - const nouns = await nounsContract.balanceOf(memberAddress) - isNoun = Number(nouns) > 0 + try { const nouns = await nounsContract.balanceOf(memberAddress); isNoun = Number(nouns) > 0 } catch(e: any) { console.log("nouns check failed", e.message) } let existing = true diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..3fc12b0 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,57 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], + theme: { + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + } + } + }, + plugins: [require("tailwindcss-animate")], +}